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 stored in the `DIRM` chunk so they
1012/// point at each `FORM:DJVU`/`FORM:DJVI` component in the about-to-be-emitted
1013/// 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///
1020/// No-op for non-DJVM roots and for indirect DIRM (no offset table).
1021fn recompute_dirm_offsets(root: &mut Chunk) -> Result<(), MutError> {
1022    let Chunk::Form {
1023        secondary_id,
1024        children,
1025        ..
1026    } = root
1027    else {
1028        return Ok(());
1029    };
1030    if secondary_id != b"DJVM" {
1031        return Ok(());
1032    }
1033
1034    // Absolute byte position of the next chunk inside the FORM:DJVM body:
1035    // AT&T(4) + FORM(4) + length(4) + secondary_id "DJVM"(4) = 16.
1036    let mut pos: usize = 16;
1037    let mut new_offsets: Vec<u32> = Vec::new();
1038    let mut dirm_idx: Option<usize> = None;
1039
1040    // The `id == b"DIRM"` guard form is needed: `id` is `[u8; 4]` reached
1041    // through a `&` reference, so a by-value pattern would require `*b"DIRM"`
1042    // which clippy's redundant-guards autofix doesn't propose.
1043    #[allow(clippy::redundant_guards)]
1044    for (i, child) in children.iter().enumerate() {
1045        match child {
1046            Chunk::Leaf { id, .. } if id == b"DIRM" => {
1047                dirm_idx = Some(i);
1048            }
1049            Chunk::Form {
1050                secondary_id: sid, ..
1051            } if sid == b"DJVU" || sid == b"DJVI" || sid == b"THUM" => {
1052                new_offsets.push(u32::try_from(pos).map_err(|_| {
1053                    MutError::DirmMalformed("component offset exceeds u32 (file > 4 GiB)")
1054                })?);
1055            }
1056            _ => {}
1057        }
1058        pos += iff::emitted_size(child);
1059    }
1060
1061    let Some(dirm_idx) = dirm_idx else {
1062        // Bundled DJVM with no DIRM is malformed by spec, but tolerate it
1063        // (parse_dirm would have failed during from_bytes if it mattered).
1064        return Ok(());
1065    };
1066
1067    let dirm = &mut children[dirm_idx];
1068    let Chunk::Leaf { data, .. } = dirm else {
1069        return Err(MutError::DirmMalformed("DIRM is not a leaf chunk"));
1070    };
1071
1072    // Decode through the shared DIRM model, swap in the recomputed offsets, and
1073    // re-encode. The metadata tail is preserved verbatim, so only the 4-byte
1074    // offset slots change — the rewrite stays byte-preserving everywhere else.
1075    let mut payload = DirmPayload::decode(data).map_err(MutError::DirmMalformed)?;
1076    if !payload.is_bundled() {
1077        // Indirect DIRM has no offset table to update.
1078        return Ok(());
1079    }
1080    if payload.nfiles as usize != new_offsets.len() {
1081        return Err(MutError::DirmComponentCountMismatch {
1082            dirm: payload.nfiles as usize,
1083            children: new_offsets.len(),
1084        });
1085    }
1086    payload.offsets = new_offsets;
1087    *data = payload.encode();
1088    Ok(())
1089}
1090
1091/// Replace, insert, or remove a paired leaf chunk in a FORM container.
1092///
1093/// `insert_at` is used only when neither variant exists; `None` appends at the
1094/// end of the form. An empty payload removes every copy of either variant so a
1095/// malformed document cannot retain a stale compressed/uncompressed twin.
1096fn replace_or_insert_form_chunk(
1097    form: &mut Chunk,
1098    id_a: &[u8; 4],
1099    id_z: &[u8; 4],
1100    data: Vec<u8>,
1101    insert_at: Option<usize>,
1102) {
1103    let children = match form {
1104        Chunk::Form { children, .. } => children,
1105        Chunk::Leaf { .. } => unreachable!("chunk-pair helper requires a FORM"),
1106    };
1107    if data.is_empty() {
1108        children.retain(|c| !matches!(c, Chunk::Leaf { id, .. } if id == id_a || id == id_z));
1109        return;
1110    }
1111
1112    if let Some(pos) = children
1113        .iter()
1114        .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == id_a || id == id_z))
1115    {
1116        children[pos] = Chunk::Leaf { id: *id_z, data };
1117    } else {
1118        let pos = insert_at.unwrap_or(children.len()).min(children.len());
1119        children.insert(pos, Chunk::Leaf { id: *id_z, data });
1120    }
1121}
1122
1123/// A mutable handle to one page's `FORM:DJVU` chunk inside a
1124/// [`DjVuDocumentMut`]. Returned by [`DjVuDocumentMut::page_mut`].
1125///
1126/// Each setter replaces the corresponding chunk in place, or appends a new
1127/// chunk if the page does not have one yet. The compressed `*z` chunk variant
1128/// is preferred on insert (TXTz / ANTz / METz) for size; if an existing
1129/// uncompressed `*a` chunk is present, the setter replaces *that* chunk and
1130/// upgrades its identifier to the `*z` form.
1131pub struct PageMut<'doc> {
1132    form: &'doc mut Chunk,
1133    dirty: &'doc mut bool,
1134}
1135
1136impl PageMut<'_> {
1137    /// Replace (or insert) the page's text layer with the BZZ-compressed
1138    /// `TXTz` form of `layer`. Page height is read from the page's `INFO`
1139    /// chunk; missing INFO yields [`MutError::MissingPageInfo`].
1140    pub fn set_text_layer(&mut self, layer: &TextLayer) -> Result<(), MutError> {
1141        let info_data = self
1142            .find_leaf_data(b"INFO")
1143            .ok_or(MutError::MissingPageInfo)?;
1144        let info = PageInfo::parse(info_data)?;
1145        let plain = encode_text_layer(layer, info.height as u32);
1146        let compressed = crate::bzz_encode::bzz_encode(&plain);
1147        self.replace_or_insert_text(compressed);
1148        *self.dirty = true;
1149        Ok(())
1150    }
1151
1152    /// Remove both TXTa and TXTz text-layer chunks from the page.
1153    pub fn remove_text_layer(&mut self) {
1154        self.replace_or_insert_text(Vec::new());
1155        *self.dirty = true;
1156    }
1157
1158    /// Replace (or insert) the page's annotation chunk with the
1159    /// BZZ-compressed `ANTz` form of `(annotation, areas)`.
1160    pub fn set_annotations(&mut self, annotation: &Annotation, areas: &[MapArea]) {
1161        let bytes = encode_annotations_bzz(annotation, areas);
1162        self.replace_or_insert(b"ANTa", b"ANTz", bytes);
1163        *self.dirty = true;
1164    }
1165
1166    /// Remove both ANTa and ANTz annotation chunks from the page.
1167    pub fn remove_annotations(&mut self) {
1168        self.replace_or_insert(b"ANTa", b"ANTz", Vec::new());
1169        *self.dirty = true;
1170    }
1171
1172    /// Replace (or insert) the page's metadata chunk with the
1173    /// BZZ-compressed `METz` form of `meta`. An empty `meta` value removes
1174    /// any existing METa/METz chunk.
1175    pub fn set_metadata(&mut self, meta: &DjVuMetadata) {
1176        let bytes = encode_metadata_bzz(meta);
1177        self.replace_or_insert(b"METa", b"METz", bytes);
1178        *self.dirty = true;
1179    }
1180
1181    /// Remove both METa and METz page-metadata chunks.
1182    pub fn remove_metadata(&mut self) {
1183        self.replace_or_insert(b"METa", b"METz", Vec::new());
1184        *self.dirty = true;
1185    }
1186
1187    fn find_leaf_data(&self, id: &[u8; 4]) -> Option<&[u8]> {
1188        for child in self.form.children() {
1189            if let Chunk::Leaf { id: cid, data } = child
1190                && cid == id
1191            {
1192                return Some(data);
1193            }
1194        }
1195        None
1196    }
1197
1198    /// Replace either the `*a` or `*z` variant of a chunk pair, picking `*z`
1199    /// (compressed) for any newly inserted chunk. If `data` is empty, removes
1200    /// the existing chunk (whichever variant is present) and does not insert.
1201    fn replace_or_insert(&mut self, id_a: &[u8; 4], id_z: &[u8; 4], data: Vec<u8>) {
1202        replace_or_insert_form_chunk(self.form, id_a, id_z, data, None);
1203    }
1204
1205    /// TXTa / TXTz variant of `replace_or_insert` (kept separate for clarity).
1206    fn replace_or_insert_text(&mut self, data: Vec<u8>) {
1207        self.replace_or_insert(b"TXTa", b"TXTz", data);
1208    }
1209}
1210
1211// ---- #326: explicit external-file rewrite plan for indirect DJVM -----------
1212
1213/// One entry in an [`IndirectRewritePlan`] preview, describing a file the plan
1214/// will touch on commit.
1215#[cfg(feature = "std")]
1216#[derive(Debug, Clone, PartialEq, Eq)]
1217pub struct RewriteItem {
1218    /// The file name (relative to the destination directory). For the root
1219    /// index this is the name passed to [`IndirectRewritePlan::commit_to_dir`].
1220    pub name: String,
1221    /// Whether this is the root DJVM index file (`true`) or a page/shared
1222    /// component (`false`).
1223    pub is_root: bool,
1224    /// Whether this file's bytes differ from the resolved original — i.e.
1225    /// whether an edit changed it. Unchanged files are still (re)written on
1226    /// commit so the destination directory holds a complete component set.
1227    pub changed: bool,
1228}
1229
1230/// One resolved component staged inside an [`IndirectRewritePlan`].
1231#[cfg(feature = "std")]
1232#[derive(Debug, Clone)]
1233struct PlannedComponent {
1234    /// DIRM component id, used both as the resolver key and the external file
1235    /// name. Validated to be a safe relative file name at construction.
1236    name: String,
1237    /// Whether this component is a page (vs. shared dictionary / thumbnail).
1238    is_page: bool,
1239    /// The bytes originally returned by the resolver.
1240    original: Vec<u8>,
1241    /// Edited bytes, if a page edit changed this component.
1242    edited: Option<Vec<u8>>,
1243}
1244
1245/// A staged, side-effect-free plan to rewrite an **indirect** `FORM:DJVM`
1246/// document across its external component files.
1247///
1248/// This is the explicit multi-file counterpart to
1249/// [`DjVuDocumentMut::from_indirect_resolved`]. Where `from_indirect_resolved`
1250/// collapses an indirect document into a single self-contained **bundled**
1251/// byte stream (no destination policy needed), `IndirectRewritePlan` keeps the
1252/// document **indirect**: each page stays in its own external file, and edits
1253/// are written back to per-component files in a destination directory.
1254///
1255/// The two paths differ deliberately:
1256///
1257/// | | `from_indirect_resolved` | `IndirectRewritePlan` |
1258/// |---|---|---|
1259/// | Output | one bundled DJVM byte stream | a directory of component files + index |
1260/// | Side effects | none (`try_into_bytes` returns bytes) | files written on `commit_to_dir` |
1261/// | Caller policy | none | destination dir, file names, atomicity |
1262/// | Document shape | becomes bundled | stays indirect |
1263///
1264/// # Mutation model
1265///
1266/// Edits never touch the filesystem. They are staged in memory via
1267/// [`Self::edit_page`] / [`Self::set_bookmarks`] and only written when
1268/// [`Self::commit_to_dir`] is called. Call [`Self::plan`] at any time to
1269/// preview exactly which files a commit will write and which have changed.
1270///
1271/// # Name safety
1272///
1273/// Every DIRM component id (and the root index name supplied at commit) must be
1274/// a safe *flat* relative file name: no path separators, no `.`/`..`, no
1275/// drive-letter `:`/absolute path, no embedded NUL. Names that could escape the
1276/// destination directory are rejected with [`MutError::UnsafeComponentName`];
1277/// two entries mapping to one file name are rejected with
1278/// [`MutError::DuplicateComponentName`]. Both checks run at construction, so an
1279/// invalid directory can never reach the write phase. Nested component
1280/// sub-directories are intentionally not supported by this path.
1281///
1282/// # Atomicity
1283///
1284/// Each file is written by staging a sibling temporary file in the destination
1285/// directory and atomically renaming it over the target, so a reader never sees
1286/// a half-written component file (on platforms where same-directory rename is
1287/// atomic — POSIX and modern Windows `ReplaceFile`/`rename`). The root index is
1288/// written **last**.
1289///
1290/// What is **not** guaranteed: the multi-file commit is not transactional. A
1291/// crash partway through can leave some component files updated and others not.
1292/// Because indirect components are independent, self-describing page files
1293/// (the index lists names, not byte offsets), every individual file remains a
1294/// valid DjVu page either way — but the document set as a whole may be a mix of
1295/// old and new pages until the commit finishes. Callers needing cross-file
1296/// atomicity should commit to a fresh directory and swap it in themselves.
1297#[cfg(feature = "std")]
1298#[derive(Debug, Clone)]
1299pub struct IndirectRewritePlan {
1300    /// The current (possibly edited) root index bytes.
1301    root_bytes: Vec<u8>,
1302    /// Whether the root index has been edited since construction.
1303    root_changed: bool,
1304    components: Vec<PlannedComponent>,
1305}
1306
1307#[cfg(feature = "std")]
1308impl IndirectRewritePlan {
1309    /// Resolve an indirect `FORM:DJVM` document into a rewrite plan, fetching
1310    /// every external component through `resolver`.
1311    ///
1312    /// The resolver is called once per `DIRM` entry with that entry's id (the
1313    /// same key [`DjVuDocumentMut::from_indirect_resolved`] uses), which is also
1314    /// the external file name the component will be written back to.
1315    ///
1316    /// # Errors
1317    ///
1318    /// - [`MutError::NotIndirectDjvm`] if `root_bytes` is not an indirect
1319    ///   `FORM:DJVM`.
1320    /// - [`MutError::UnsafeComponentName`] / [`MutError::DuplicateComponentName`]
1321    ///   if a DIRM component id is not a safe, unique flat file name.
1322    /// - [`MutError::ComponentResolve`] if the resolver fails for a component.
1323    /// - [`MutError::ComponentMalformed`] if a resolved component does not parse
1324    ///   as a `FORM:DJVU`/`DJVI`/`THUM`.
1325    /// - [`MutError::DirmMalformed`] / [`MutError::InfoParse`] if the index or its
1326    ///   `DIRM` chunk cannot be read.
1327    pub fn from_indirect_resolved<R, E>(root_bytes: &[u8], resolver: R) -> Result<Self, MutError>
1328    where
1329        R: Fn(&str) -> Result<Vec<u8>, E>,
1330    {
1331        // The rewrite plan keeps the original index bytes verbatim, so the DIRM
1332        // bytes returned by the shared prologue are not needed here.
1333        let (_dirm_data, infos) = resolve_indirect_components(root_bytes)?;
1334
1335        // Validate every component file name up front: safe + unique. This runs
1336        // before any resolution or write, so an invalid directory is rejected
1337        // without side effects.
1338        let mut seen = std::collections::HashSet::new();
1339        for info in &infos {
1340            validate_safe_component_name(&info.id)?;
1341            if !seen.insert(info.id.clone()) {
1342                return Err(MutError::DuplicateComponentName {
1343                    name: info.id.clone(),
1344                });
1345            }
1346        }
1347
1348        let mut components = Vec::with_capacity(infos.len());
1349        for info in &infos {
1350            let bytes = resolver(&info.id).map_err(|_| MutError::ComponentResolve {
1351                name: info.id.clone(),
1352            })?;
1353            // Validate the bytes parse as a component FORM so later commits never
1354            // write a file we already know is malformed.
1355            let parsed = iff::parse(&bytes).map_err(|_| MutError::ComponentMalformed {
1356                name: info.id.clone(),
1357                reason: "not a parseable IFF document",
1358            })?;
1359            match &parsed.root {
1360                Chunk::Form { secondary_id, .. }
1361                    if secondary_id == b"DJVU"
1362                        || secondary_id == b"DJVI"
1363                        || secondary_id == b"THUM" => {}
1364                _ => {
1365                    return Err(MutError::ComponentMalformed {
1366                        name: info.id.clone(),
1367                        reason: "root is not a FORM:DJVU/DJVI/THUM",
1368                    });
1369                }
1370            }
1371            components.push(PlannedComponent {
1372                name: info.id.clone(),
1373                is_page: info.kind == DirmComponentKind::Page,
1374                original: bytes,
1375                edited: None,
1376            });
1377        }
1378
1379        Ok(Self {
1380            root_bytes: root_bytes.to_vec(),
1381            root_changed: false,
1382            components,
1383        })
1384    }
1385
1386    /// Number of page components in the document (shared dictionaries and
1387    /// thumbnails are not counted).
1388    pub fn page_count(&self) -> usize {
1389        self.components.iter().filter(|c| c.is_page).count()
1390    }
1391
1392    /// Total number of components (pages + shared dictionaries + thumbnails).
1393    pub fn component_count(&self) -> usize {
1394        self.components.len()
1395    }
1396
1397    /// Edit the `index`-th page component in memory.
1398    ///
1399    /// The closure receives a [`DjVuDocumentMut`] opened on that page's current
1400    /// (possibly already-edited) bytes — a single-page `FORM:DJVU`, so
1401    /// `doc.page_mut(0)` exposes the usual `set_text_layer` / `set_metadata` /
1402    /// `set_annotations` setters. Nothing is written to disk; the resulting
1403    /// bytes are staged for the next [`Self::commit_to_dir`].
1404    ///
1405    /// # Errors
1406    ///
1407    /// - [`MutError::PageOutOfRange`] if `index >= self.page_count()`.
1408    /// - Any [`MutError`] returned by the closure or by re-serialising the page.
1409    pub fn edit_page<F>(&mut self, index: usize, edit: F) -> Result<(), MutError>
1410    where
1411        F: FnOnce(&mut DjVuDocumentMut) -> Result<(), MutError>,
1412    {
1413        let count = self.page_count();
1414        let comp = self
1415            .components
1416            .iter_mut()
1417            .filter(|c| c.is_page)
1418            .nth(index)
1419            .ok_or(MutError::PageOutOfRange { index, count })?;
1420        let current: &[u8] = comp.edited.as_deref().unwrap_or(&comp.original);
1421        let mut doc = DjVuDocumentMut::from_bytes(current)?;
1422        edit(&mut doc)?;
1423        if doc.is_dirty() {
1424            comp.edited = Some(doc.try_into_bytes()?);
1425        }
1426        Ok(())
1427    }
1428
1429    /// Replace, insert, or remove the document's `NAVM` bookmarks in the root
1430    /// index file. The edit is staged in memory and written on commit; only the
1431    /// root index file changes (bookmarks live in the index, not page files).
1432    pub fn set_bookmarks(&mut self, bookmarks: &[DjVuBookmark]) -> Result<(), MutError> {
1433        let mut root = DjVuDocumentMut::from_bytes(&self.root_bytes)?;
1434        root.set_bookmarks(bookmarks)?;
1435        if root.is_dirty() {
1436            self.root_bytes = root.try_into_bytes()?;
1437            self.root_changed = true;
1438        }
1439        Ok(())
1440    }
1441
1442    /// Preview the files a [`Self::commit_to_dir`] will write, in commit order
1443    /// (every component, then the root index). `changed` flags which files
1444    /// differ from their resolved originals.
1445    ///
1446    /// `root_name` is the file name the root index will be written under; it is
1447    /// reported as the final, `is_root` item but is **not** validated here (that
1448    /// happens at commit).
1449    pub fn plan(&self, root_name: &str) -> Vec<RewriteItem> {
1450        let mut items: Vec<RewriteItem> = self
1451            .components
1452            .iter()
1453            .map(|c| RewriteItem {
1454                name: c.name.clone(),
1455                is_root: false,
1456                changed: c.edited.is_some(),
1457            })
1458            .collect();
1459        items.push(RewriteItem {
1460            name: root_name.to_string(),
1461            is_root: true,
1462            changed: self.root_changed,
1463        });
1464        items
1465    }
1466
1467    /// Commit the plan: write the full indirect document set (every component
1468    /// plus the root index) into `dir`, staging each file as a sibling temporary
1469    /// file and atomically renaming it into place. The root index is written
1470    /// last.
1471    ///
1472    /// All name validation happens before the first byte is written, so a
1473    /// validation failure (e.g. an unsafe `root_name`) leaves `dir` untouched.
1474    /// Returns the absolute paths written, in the same order as [`Self::plan`].
1475    ///
1476    /// See the type-level docs for the atomicity guarantees and their limits.
1477    pub fn commit_to_dir(
1478        &self,
1479        dir: impl AsRef<std::path::Path>,
1480        root_name: &str,
1481    ) -> Result<Vec<std::path::PathBuf>, MutError> {
1482        let dir = dir.as_ref();
1483
1484        // ---- Validate everything before writing anything --------------------
1485        validate_safe_component_name(root_name)?;
1486        // Component names were validated at construction, but the root name must
1487        // also not collide with a component file.
1488        if self.components.iter().any(|c| c.name == root_name) {
1489            return Err(MutError::DuplicateComponentName {
1490                name: root_name.to_string(),
1491            });
1492        }
1493
1494        std::fs::create_dir_all(dir).map_err(|e| MutError::RewriteIo {
1495            name: dir.display().to_string(),
1496            message: e.to_string(),
1497        })?;
1498
1499        // ---- Write component files, then the root index ---------------------
1500        let mut written = Vec::with_capacity(self.components.len() + 1);
1501        for comp in &self.components {
1502            let bytes = comp.edited.as_deref().unwrap_or(&comp.original);
1503            written.push(stage_and_rename(dir, &comp.name, bytes)?);
1504        }
1505        written.push(stage_and_rename(dir, root_name, &self.root_bytes)?);
1506        Ok(written)
1507    }
1508}
1509
1510/// Reject any component / index name that is not a safe flat relative file name.
1511///
1512/// Permitted names are non-empty, contain no path separator (`/` or `\\`), no
1513/// drive/ADS colon, no NUL, and are not `.` or `..`. This guarantees a write can
1514/// never escape the destination directory.
1515#[cfg(feature = "std")]
1516fn validate_safe_component_name(name: &str) -> Result<(), MutError> {
1517    let reject = |reason: &'static str| {
1518        Err(MutError::UnsafeComponentName {
1519            name: name.to_string(),
1520            reason,
1521        })
1522    };
1523    if name.is_empty() {
1524        return reject("name is empty");
1525    }
1526    if name.contains('\0') {
1527        return reject("name contains a NUL byte");
1528    }
1529    if name.contains('/') || name.contains('\\') {
1530        return reject("name contains a path separator");
1531    }
1532    if name.contains(':') {
1533        return reject("name contains a drive-letter / stream colon");
1534    }
1535    if name == "." || name == ".." {
1536        return reject("name is a relative directory reference");
1537    }
1538    Ok(())
1539}
1540
1541/// Write `bytes` to `dir/name` by staging a sibling temp file and atomically
1542/// renaming it over the target. Returns the final path.
1543#[cfg(feature = "std")]
1544fn stage_and_rename(
1545    dir: &std::path::Path,
1546    name: &str,
1547    bytes: &[u8],
1548) -> Result<std::path::PathBuf, MutError> {
1549    use std::io::Write;
1550
1551    let final_path = dir.join(name);
1552    // A stable, collision-resistant-enough temp name in the same directory so
1553    // the rename stays on one filesystem (and is therefore atomic).
1554    let tmp_path = dir.join(format!(".{name}.djvu-rs.tmp"));
1555
1556    let io_err = |path: &std::path::Path, e: std::io::Error| MutError::RewriteIo {
1557        name: path.display().to_string(),
1558        message: e.to_string(),
1559    };
1560
1561    {
1562        let mut f = std::fs::File::create(&tmp_path).map_err(|e| io_err(&tmp_path, e))?;
1563        f.write_all(bytes).map_err(|e| io_err(&tmp_path, e))?;
1564        f.sync_all().map_err(|e| io_err(&tmp_path, e))?;
1565    }
1566    std::fs::rename(&tmp_path, &final_path).map_err(|e| {
1567        // Best-effort cleanup of the temp file on rename failure.
1568        let _ = std::fs::remove_file(&tmp_path);
1569        io_err(&final_path, e)
1570    })?;
1571    Ok(final_path)
1572}
1573
1574#[cfg(test)]
1575#[allow(clippy::field_reassign_with_default)]
1576mod tests {
1577    use super::*;
1578    use std::path::PathBuf;
1579
1580    fn corpus_path(name: &str) -> PathBuf {
1581        let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1582        p.push("tests/fixtures");
1583        p.push(name);
1584        p
1585    }
1586
1587    fn read_corpus(name: &str) -> Vec<u8> {
1588        std::fs::read(corpus_path(name)).expect("corpus fixture missing")
1589    }
1590
1591    /// #595: `save_patched` must leave the file byte-identical to
1592    /// `try_into_bytes` for clean, same-size-edit, and size-changing-edit
1593    /// saves — and its `bytes_written` must reflect the incremental win.
1594    #[test]
1595    fn save_patched_matches_full_serialization() {
1596        let original = read_corpus("navm_fgbz.djvu");
1597        let tmp = tempfile::NamedTempFile::new().unwrap();
1598
1599        // Clean save: nothing written.
1600        std::fs::write(tmp.path(), &original).unwrap();
1601        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1602        let mut f = std::fs::OpenOptions::new()
1603            .read(true)
1604            .write(true)
1605            .open(tmp.path())
1606            .unwrap();
1607        let stats = doc.save_patched(&mut f).unwrap();
1608        assert_eq!(stats.bytes_written, 0);
1609        assert_eq!(std::fs::read(tmp.path()).unwrap(), original);
1610
1611        // Size-changing edit (bookmarks): file equals the full serialization,
1612        // and the untouched head is skipped.
1613        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1614        let bookmarks = vec![DjVuBookmark {
1615            title: "patched".into(),
1616            url: "#1".into(),
1617            children: Vec::new(),
1618        }];
1619        doc.set_bookmarks(&bookmarks).unwrap();
1620        let expected = {
1621            let mut clone = DjVuDocumentMut::from_bytes(&original).unwrap();
1622            clone.set_bookmarks(&bookmarks).unwrap();
1623            clone.try_into_bytes().unwrap()
1624        };
1625        std::fs::write(tmp.path(), &original).unwrap();
1626        let mut f = std::fs::OpenOptions::new()
1627            .read(true)
1628            .write(true)
1629            .open(tmp.path())
1630            .unwrap();
1631        let stats = doc.save_patched(&mut f).unwrap();
1632        assert_eq!(std::fs::read(tmp.path()).unwrap(), expected);
1633        assert_eq!(stats.file_len, expected.len() as u64);
1634        assert!(
1635            stats.bytes_written < expected.len() as u64,
1636            "size-changing edit must still skip the untouched head"
1637        );
1638
1639        // Same-size edit (replace a leaf with an equal-length payload): only
1640        // that component's bytes are written; DIRM stays untouched on disk.
1641        // Same-size scenario needs an emit-stable base: navm_fgbz.djvu itself
1642        // lacks the final IFF pad byte (odd root FORM length), which
1643        // `iff::emit` normalizes (+1 byte). Use the normalized bytes from the
1644        // bookmark edit above as the on-disk original.
1645        let original = expected;
1646        let doc0 = DjVuDocumentMut::from_bytes(&original).unwrap();
1647        // Find a page leaf to overwrite with same-length data: page 0's INFO.
1648        let info_path = (0..doc0.root_child_count())
1649            .find_map(|i| match doc0.chunk_at_path(&[i]) {
1650                Ok(Chunk::Form {
1651                    secondary_id: [b'D', b'J', b'V', b'U'],
1652                    children,
1653                    ..
1654                }) => children.iter().enumerate().find_map(|(j, c)| match c {
1655                    Chunk::Leaf {
1656                        id: [b'I', b'N', b'F', b'O'],
1657                        ..
1658                    } => Some(vec![i, j]),
1659                    _ => None,
1660                }),
1661                _ => None,
1662            })
1663            .expect("bundle has a page with INFO");
1664        let mut new_info = doc0.chunk_at_path(&info_path).unwrap().data().to_vec();
1665        // Flip the gamma byte (offset 7 = 10*gamma) — same length, real edit.
1666        new_info[7] ^= 1;
1667        let mut doc = doc0.clone();
1668        doc.replace_leaf(&info_path, new_info.clone()).unwrap();
1669        let expected = {
1670            let mut clone = doc0.clone();
1671            clone.replace_leaf(&info_path, new_info).unwrap();
1672            clone.try_into_bytes().unwrap()
1673        };
1674        assert_eq!(expected.len(), original.len(), "edit must be same-size");
1675        std::fs::write(tmp.path(), &original).unwrap();
1676        let mut f = std::fs::OpenOptions::new()
1677            .read(true)
1678            .write(true)
1679            .open(tmp.path())
1680            .unwrap();
1681        let stats = doc.save_patched(&mut f).unwrap();
1682        assert_eq!(std::fs::read(tmp.path()).unwrap(), expected);
1683        assert!(
1684            stats.bytes_written <= 64,
1685            "same-size single-byte edit must write only the edited span, wrote {}",
1686            stats.bytes_written
1687        );
1688
1689        // Wrong target: refuse before writing anything.
1690        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1691        std::fs::write(tmp.path(), b"not the original").unwrap();
1692        let mut f = std::fs::OpenOptions::new()
1693            .read(true)
1694            .write(true)
1695            .open(tmp.path())
1696            .unwrap();
1697        assert!(matches!(
1698            doc.save_patched(&mut f),
1699            Err(MutError::PatchTargetMismatch)
1700        ));
1701        assert_eq!(std::fs::read(tmp.path()).unwrap(), b"not the original");
1702    }
1703
1704    /// Round-trip without edits is byte-identical on a single-page document.
1705    #[test]
1706    fn roundtrip_byte_identical_chicken() {
1707        let original = read_corpus("chicken.djvu");
1708        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1709        assert!(!doc.is_dirty());
1710        assert_eq!(doc.into_bytes(), original);
1711    }
1712
1713    /// Round-trip without edits is byte-identical on a bilevel JB2 document.
1714    #[test]
1715    fn roundtrip_byte_identical_boy_jb2() {
1716        let original = read_corpus("boy_jb2.djvu");
1717        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1718        assert_eq!(doc.into_bytes(), original);
1719    }
1720
1721    /// Round-trip without edits is byte-identical on a multi-page DJVM bundle.
1722    #[test]
1723    fn roundtrip_byte_identical_djvm_bundle() {
1724        let original = read_corpus("DjVu3Spec_bundled.djvu");
1725        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1726        assert_eq!(doc.root_form_type(), Some(b"DJVM"));
1727        assert_eq!(doc.into_bytes(), original);
1728    }
1729
1730    /// Round-trip without edits is byte-identical on a navm/fgbz document.
1731    #[test]
1732    fn roundtrip_byte_identical_navm() {
1733        let original = read_corpus("navm_fgbz.djvu");
1734        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1735        assert_eq!(doc.into_bytes(), original);
1736    }
1737
1738    /// `replace_leaf` mutates in place and the serialised output reflects it.
1739    #[test]
1740    fn replace_leaf_changes_emitted_bytes() {
1741        let original = read_corpus("chicken.djvu");
1742        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1743
1744        // Walk to the first leaf — for chicken.djvu (FORM:DJVU) this is INFO.
1745        let first = doc.chunk_at_path(&[0]).unwrap();
1746        let original_first_data = first.data().to_vec();
1747        assert!(!original_first_data.is_empty());
1748
1749        // Replace with a marker and serialise.
1750        let marker = b"PR1_TEST_MARKER".to_vec();
1751        doc.replace_leaf(&[0], marker.clone()).unwrap();
1752        assert!(doc.is_dirty());
1753
1754        let edited = doc.into_bytes();
1755
1756        // Re-parse the edited bytes and confirm the leaf payload changed.
1757        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1758        let new_first = reparsed.chunk_at_path(&[0]).unwrap();
1759        assert_eq!(new_first.data(), marker.as_slice());
1760    }
1761
1762    #[test]
1763    fn single_page_patch_preserves_unedited_child_bytes() {
1764        let original = read_corpus("chicken.djvu");
1765        let original_ranges =
1766            original_single_page_child_ranges(&original).expect("single-page child ranges");
1767        assert!(
1768            original_ranges.len() > 2,
1769            "fixture must have unrelated chunks to preserve"
1770        );
1771
1772        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1773        doc.replace_leaf(&[0], b"PATCHED_INFO".to_vec()).unwrap();
1774        let edited = doc.try_into_bytes().unwrap();
1775        let edited_ranges =
1776            original_single_page_child_ranges(&edited).expect("edited child ranges");
1777        assert_eq!(edited_ranges.len(), original_ranges.len());
1778
1779        for (idx, (before, after)) in original_ranges.iter().zip(edited_ranges.iter()).enumerate() {
1780            if idx == 0 {
1781                assert_ne!(
1782                    &original[before.range.clone()],
1783                    &edited[after.range.clone()]
1784                );
1785                continue;
1786            }
1787            assert_eq!(before.id, after.id);
1788            assert_eq!(
1789                &original[before.range.clone()],
1790                &edited[after.range.clone()],
1791                "unchanged child #{idx} must be copied byte-for-byte"
1792            );
1793        }
1794    }
1795
1796    #[test]
1797    fn single_page_patch_falls_back_for_bundled_djvm() {
1798        let original = read_corpus("DjVu3Spec_bundled.djvu");
1799        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1800        assert!(
1801            emit_patched_single_page(&doc.file.root, &original).is_none(),
1802            "single-page patch path must decline bundled DJVM layouts"
1803        );
1804    }
1805
1806    #[test]
1807    fn replace_leaf_rejects_empty_path() {
1808        let original = read_corpus("chicken.djvu");
1809        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1810        let err = doc.replace_leaf(&[], vec![]).unwrap_err();
1811        assert!(matches!(err, MutError::EmptyPath));
1812    }
1813
1814    #[test]
1815    fn replace_leaf_rejects_out_of_range() {
1816        let original = read_corpus("chicken.djvu");
1817        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1818        let err = doc.replace_leaf(&[9999], vec![]).unwrap_err();
1819        assert!(matches!(err, MutError::PathOutOfRange { .. }));
1820    }
1821
1822    #[test]
1823    fn replace_leaf_rejects_traversing_leaf() {
1824        let original = read_corpus("chicken.djvu");
1825        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1826        // [0] is a leaf (INFO).  [0, 0] tries to descend past it.
1827        let err = doc.replace_leaf(&[0, 0], vec![]).unwrap_err();
1828        assert!(matches!(err, MutError::PathTraversesLeaf { .. }));
1829    }
1830
1831    #[test]
1832    fn replace_leaf_rejects_form_target() {
1833        // For a DJVM bundle, [N] for some N points at a FORM:DJVU page,
1834        // not a leaf.  Picking the last child of DjVu3Spec_bundled (which
1835        // is a page FORM) demonstrates NotALeaf.
1836        let original = read_corpus("DjVu3Spec_bundled.djvu");
1837        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1838        let last_idx = doc.root_child_count() - 1;
1839        let err = doc.replace_leaf(&[last_idx], vec![]).unwrap_err();
1840        assert!(matches!(err, MutError::NotALeaf));
1841    }
1842
1843    #[test]
1844    fn root_form_type_djvu_single_page() {
1845        let original = read_corpus("chicken.djvu");
1846        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1847        assert_eq!(doc.root_form_type(), Some(b"DJVU"));
1848    }
1849
1850    // ---- PR2 setters ------------------------------------------------------
1851
1852    #[test]
1853    fn page_count_single_page_djvu_is_one() {
1854        let original = read_corpus("chicken.djvu");
1855        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1856        assert_eq!(doc.page_count(), 1);
1857    }
1858
1859    #[test]
1860    fn page_count_djvm_bundle_counts_djvu_components_only() {
1861        let original = read_corpus("DjVu3Spec_bundled.djvu");
1862        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1863        // The bundle has multiple FORM:DJVU pages; assert it's > 1 and matches
1864        // the count of DJVU children at the root.
1865        let direct: usize = doc
1866            .file
1867            .root
1868            .children()
1869            .iter()
1870            .filter(|c| {
1871                matches!(c, crate::iff::Chunk::Form { secondary_id, .. } if secondary_id == b"DJVU")
1872            })
1873            .count();
1874        assert!(direct >= 2, "expected multi-page bundle, got {direct}");
1875        assert_eq!(doc.page_count(), direct);
1876    }
1877
1878    #[test]
1879    fn page_mut_out_of_range_errors() {
1880        let original = read_corpus("chicken.djvu");
1881        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1882        let err = doc.page_mut(1).err().unwrap();
1883        assert!(matches!(
1884            err,
1885            MutError::PageOutOfRange { index: 1, count: 1 }
1886        ));
1887    }
1888
1889    #[test]
1890    fn page_mut_djvm_bundle_succeeds_after_pr3() {
1891        // PR3 enables page_mut on bundled FORM:DJVM. Verify it returns a
1892        // valid handle for index 0 and rejects out-of-range indices.
1893        let original = read_corpus("DjVu3Spec_bundled.djvu");
1894        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1895        assert!(doc.page_mut(0).is_ok());
1896        let count = doc.page_count();
1897        let err = doc.page_mut(count).err().unwrap();
1898        assert!(matches!(err, MutError::PageOutOfRange { .. }));
1899    }
1900
1901    #[test]
1902    fn page_mut_indirect_djvm_returns_unsupported_before_range_check() {
1903        let mut doc = DjVuDocumentMut::from_bytes(&indirect_djvm_bytes()).unwrap();
1904        let err = doc.page_mut(0).err().unwrap();
1905        assert!(matches!(err, MutError::IndirectDjvmUnsupported));
1906    }
1907
1908    fn indirect_djvm_bytes() -> Vec<u8> {
1909        let bzz_meta: &[u8] = &[
1910            0xff, 0xff, 0xed, 0xbf, 0x8a, 0x1f, 0xbe, 0xad, 0x14, 0x57, 0x10, 0xc9, 0x63, 0x19,
1911            0x11, 0xf0, 0x85, 0x28, 0x12, 0x8a, 0xbf,
1912        ];
1913
1914        let mut dirm_data = Vec::new();
1915        dirm_data.push(0x00);
1916        dirm_data.push(0x00);
1917        dirm_data.push(0x01);
1918        dirm_data.extend_from_slice(bzz_meta);
1919
1920        // FORM:DJVM carrying a single (indirect) DIRM chunk, built through the
1921        // emission seam rather than hand-assembled framing.
1922        let dirm = Chunk::Leaf {
1923            id: *b"DIRM",
1924            data: dirm_data,
1925        };
1926        iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm)]).expect("fits within u32")
1927    }
1928
1929    #[test]
1930    fn set_text_layer_roundtrip_chicken() {
1931        use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
1932
1933        let original = read_corpus("chicken.djvu");
1934        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1935
1936        let layer = TextLayer {
1937            text: "hello world".to_string(),
1938            zones: vec![TextZone {
1939                kind: TextZoneKind::Page,
1940                rect: Rect {
1941                    x: 0,
1942                    y: 0,
1943                    width: 100,
1944                    height: 50,
1945                },
1946                text: "hello world".to_string(),
1947                children: vec![],
1948            }],
1949        };
1950        doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
1951        assert!(doc.is_dirty());
1952        let edited = doc.into_bytes();
1953
1954        // Re-parse and confirm a TXTz chunk now exists.
1955        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1956        let has_txtz = reparsed
1957            .file
1958            .root
1959            .children()
1960            .iter()
1961            .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
1962        assert!(
1963            has_txtz,
1964            "TXTz chunk should be present after set_text_layer"
1965        );
1966    }
1967
1968    #[test]
1969    fn set_annotations_roundtrip_chicken() {
1970        use crate::annotation::{Annotation, Color};
1971
1972        let original = read_corpus("chicken.djvu");
1973        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1974
1975        let mut ann = Annotation::default();
1976        ann.background = Some(Color {
1977            r: 0xFF,
1978            g: 0xFF,
1979            b: 0xFF,
1980        });
1981        ann.mode = Some("color".to_string());
1982        doc.page_mut(0).unwrap().set_annotations(&ann, &[]);
1983        let edited = doc.into_bytes();
1984
1985        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1986        let antz = reparsed
1987            .file
1988            .root
1989            .children()
1990            .iter()
1991            .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"ANTz"));
1992        assert!(antz.is_some(), "ANTz should be inserted");
1993        let data = antz.unwrap().data();
1994        let decoded = crate::bzz::bzz_decode(data).expect("ANTz must decompress");
1995        let (parsed_ann, _areas) =
1996            crate::annotation::parse_annotations(&decoded).expect("ANTz must round-trip");
1997        assert_eq!(parsed_ann.mode.as_deref(), Some("color"));
1998        assert_eq!(
1999            parsed_ann.background,
2000            Some(Color {
2001                r: 0xFF,
2002                g: 0xFF,
2003                b: 0xFF
2004            })
2005        );
2006    }
2007
2008    #[test]
2009    fn set_metadata_roundtrip_chicken() {
2010        let original = read_corpus("chicken.djvu");
2011        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2012
2013        let mut meta = DjVuMetadata::default();
2014        meta.title = Some("Test Title".into());
2015        meta.author = Some("Tester".into());
2016        doc.page_mut(0).unwrap().set_metadata(&meta);
2017        let edited = doc.into_bytes();
2018
2019        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2020        let metz = reparsed
2021            .file
2022            .root
2023            .children()
2024            .iter()
2025            .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
2026            .expect("METz should be inserted");
2027        let decoded = crate::bzz::bzz_decode(metz.data()).unwrap();
2028        let parsed = crate::metadata::parse_metadata(&decoded).unwrap();
2029        assert_eq!(parsed, meta);
2030    }
2031
2032    #[test]
2033    fn set_metadata_empty_removes_existing_chunk() {
2034        let original = read_corpus("chicken.djvu");
2035        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2036
2037        // Insert one, then clear.
2038        let mut meta = DjVuMetadata::default();
2039        meta.title = Some("X".into());
2040        doc.page_mut(0).unwrap().set_metadata(&meta);
2041        doc.page_mut(0)
2042            .unwrap()
2043            .set_metadata(&DjVuMetadata::default());
2044
2045        let edited = doc.into_bytes();
2046        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2047        let any_meta = reparsed
2048            .file
2049            .root
2050            .children()
2051            .iter()
2052            .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"));
2053        assert!(!any_meta, "set_metadata(empty) should remove any METa/METz");
2054    }
2055
2056    #[test]
2057    fn set_metadata_replaces_existing_chunk_in_place() {
2058        let original = read_corpus("chicken.djvu");
2059        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2060
2061        let mut m1 = DjVuMetadata::default();
2062        m1.title = Some("First".into());
2063        doc.page_mut(0).unwrap().set_metadata(&m1);
2064
2065        let mut m2 = DjVuMetadata::default();
2066        m2.title = Some("Second".into());
2067        doc.page_mut(0).unwrap().set_metadata(&m2);
2068
2069        let edited = doc.into_bytes();
2070        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2071        let metz_count = reparsed
2072            .file
2073            .root
2074            .children()
2075            .iter()
2076            .filter(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"))
2077            .count();
2078        assert_eq!(metz_count, 1, "should not duplicate METz on repeat set");
2079    }
2080
2081    // ---- PR3: bundled DJVM mutation + set_bookmarks -----------------------
2082
2083    /// Helper: parse the FORM:DJVM body, return the DIRM chunk's offset table
2084    /// and the actual file offsets where each component FORM header sits.
2085    fn dirm_offsets_and_actual(data: &[u8]) -> (Vec<u32>, Vec<u32>) {
2086        // Parse top-level FORM
2087        let form = crate::iff::parse_form(data).expect("parse_form");
2088        assert_eq!(&form.form_type, b"DJVM");
2089
2090        let dirm = form
2091            .chunks
2092            .iter()
2093            .find(|c| &c.id == b"DIRM")
2094            .expect("DIRM present");
2095        // Decode through the canonical owner instead of hand-parsing bytes.
2096        let payload = crate::dirm::DirmPayload::decode(dirm.data).expect("decode DIRM");
2097        let declared = payload.offsets;
2098        let nfiles = declared.len();
2099
2100        // Walk the file to find each FORM child's absolute byte offset.
2101        // Layout: AT&T(4) FORM(4) length(4) DJVM(4) chunks…
2102        let mut actual = Vec::with_capacity(nfiles);
2103        let mut pos = 16usize;
2104        let body_end = 8 + u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
2105        while pos < body_end {
2106            let id = &data[pos..pos + 4];
2107            let len =
2108                u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
2109                    as usize;
2110            if id == b"FORM" {
2111                actual.push(pos as u32);
2112            }
2113            let mut next = pos + 8 + len;
2114            if next & 1 == 1 {
2115                next += 1;
2116            }
2117            pos = next;
2118        }
2119        (declared, actual)
2120    }
2121
2122    #[test]
2123    fn dirm_offsets_match_actual_after_no_edit() {
2124        // Sanity: even without edits, the recompute path agrees with the
2125        // original document layout on a real bundle.
2126        let original = read_corpus("DjVu3Spec_bundled.djvu");
2127        let (declared, actual) = dirm_offsets_and_actual(&original);
2128        assert_eq!(declared, actual);
2129    }
2130
2131    #[test]
2132    fn dirm_offsets_recomputed_after_page_metadata_edit() {
2133        let original = read_corpus("DjVu3Spec_bundled.djvu");
2134        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2135
2136        // Edit page 0's metadata so the page FORM grows.
2137        let mut meta = DjVuMetadata::default();
2138        meta.title = Some("PR3 DJVM bundled mutation".into());
2139        meta.author = Some("djvu-rs PR3 tests".into());
2140        doc.page_mut(0).unwrap().set_metadata(&meta);
2141        assert!(doc.is_dirty());
2142
2143        let edited = doc.into_bytes();
2144        // Sizes must have changed (metadata chunk was inserted).
2145        assert_ne!(edited.len(), original.len());
2146
2147        // DIRM offsets in the new bytes must match where the FORM headers
2148        // actually live.
2149        let (declared, actual) = dirm_offsets_and_actual(&edited);
2150        assert_eq!(
2151            declared, actual,
2152            "DIRM offsets must point at the new FORM positions after edit"
2153        );
2154
2155        // The full document must still parse via DjVuDocument and expose the
2156        // expected page count.
2157        let reparsed =
2158            crate::djvu_document::DjVuDocument::parse(&edited).expect("edited bundle must parse");
2159        let original_doc =
2160            crate::djvu_document::DjVuDocument::parse(&original).expect("original bundle parses");
2161        assert_eq!(reparsed.page_count(), original_doc.page_count());
2162    }
2163
2164    #[test]
2165    fn dirm_offsets_recomputed_after_middle_page_edit() {
2166        // Editing a non-first page must shift only the trailing offsets.
2167        let original = read_corpus("DjVu3Spec_bundled.djvu");
2168        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2169        let count = doc.page_count();
2170        assert!(count >= 3);
2171
2172        let mid = count / 2;
2173        let mut meta = DjVuMetadata::default();
2174        meta.title = Some("PR3 mid-page edit".into());
2175        doc.page_mut(mid).unwrap().set_metadata(&meta);
2176
2177        let edited = doc.into_bytes();
2178        let (declared, actual) = dirm_offsets_and_actual(&edited);
2179        assert_eq!(declared, actual);
2180
2181        // Pages before `mid` should have unchanged offsets vs. the original.
2182        let (orig_declared, _) = dirm_offsets_and_actual(&original);
2183        for i in 0..mid {
2184            assert_eq!(
2185                declared[i], orig_declared[i],
2186                "offset for page {i} (before edit) must be unchanged"
2187            );
2188        }
2189    }
2190
2191    #[test]
2192    fn set_bookmarks_replaces_navm_in_bundle() {
2193        use crate::djvu_document::DjVuBookmark;
2194
2195        let original = read_corpus("DjVu3Spec_bundled.djvu");
2196        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2197
2198        let bookmarks = vec![
2199            DjVuBookmark {
2200                title: "Front matter".into(),
2201                url: "#1".into(),
2202                children: vec![DjVuBookmark {
2203                    title: "Acknowledgments".into(),
2204                    url: "#3".into(),
2205                    children: vec![],
2206                }],
2207            },
2208            DjVuBookmark {
2209                title: "Body".into(),
2210                url: "#10".into(),
2211                children: vec![],
2212            },
2213        ];
2214        doc.set_bookmarks(&bookmarks).unwrap();
2215        assert!(doc.is_dirty());
2216        let edited = doc.into_bytes();
2217
2218        // DIRM offsets must still be correct after the NAVM size change.
2219        let (declared, actual) = dirm_offsets_and_actual(&edited);
2220        assert_eq!(declared, actual);
2221
2222        // Round-trip the bookmarks via the high-level DjVuDocument parser.
2223        let reparsed = crate::djvu_document::DjVuDocument::parse(&edited)
2224            .expect("bundle with new bookmarks parses");
2225        let parsed_bms = reparsed.bookmarks();
2226        assert_eq!(parsed_bms.len(), 2);
2227        assert_eq!(parsed_bms[0].title, "Front matter");
2228        assert_eq!(parsed_bms[0].children.len(), 1);
2229        assert_eq!(parsed_bms[0].children[0].title, "Acknowledgments");
2230        assert_eq!(parsed_bms[1].title, "Body");
2231    }
2232
2233    #[test]
2234    fn set_bookmarks_empty_removes_navm() {
2235        let original = read_corpus("DjVu3Spec_bundled.djvu");
2236        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2237        // The fixture might or might not have NAVM; either way, calling with
2238        // an empty slice should result in no NAVM in the output.
2239        doc.set_bookmarks(&[]).unwrap();
2240        let edited = doc.into_bytes();
2241
2242        let form = crate::iff::parse_form(&edited).unwrap();
2243        let has_navm = form.chunks.iter().any(|c| &c.id == b"NAVM");
2244        assert!(!has_navm, "set_bookmarks(&[]) must remove NAVM");
2245
2246        // DIRM offsets still match.
2247        let (declared, actual) = dirm_offsets_and_actual(&edited);
2248        assert_eq!(declared, actual);
2249    }
2250
2251    #[test]
2252    fn set_bookmarks_inserts_navm_when_absent() {
2253        use crate::djvu_document::DjVuBookmark;
2254
2255        // Build a bundle that has no NAVM by first stripping it, then
2256        // re-add bookmarks via set_bookmarks.
2257        let original = read_corpus("DjVu3Spec_bundled.djvu");
2258        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2259        doc.set_bookmarks(&[]).unwrap();
2260        let stripped = doc.into_bytes();
2261
2262        let mut doc = DjVuDocumentMut::from_bytes(&stripped).unwrap();
2263        let bms = vec![DjVuBookmark {
2264            title: "Re-added".into(),
2265            url: "#1".into(),
2266            children: vec![],
2267        }];
2268        doc.set_bookmarks(&bms).unwrap();
2269        let edited = doc.into_bytes();
2270
2271        let form = crate::iff::parse_form(&edited).unwrap();
2272        let navm_pos = form
2273            .chunks
2274            .iter()
2275            .position(|c| &c.id == b"NAVM")
2276            .expect("NAVM should be inserted");
2277        let dirm_pos = form.chunks.iter().position(|c| &c.id == b"DIRM").unwrap();
2278        assert_eq!(
2279            navm_pos,
2280            dirm_pos + 1,
2281            "NAVM should be placed immediately after DIRM"
2282        );
2283
2284        let (declared, actual) = dirm_offsets_and_actual(&edited);
2285        assert_eq!(declared, actual);
2286    }
2287
2288    #[test]
2289    fn set_bookmarks_on_single_page_djvu_errors() {
2290        let original = read_corpus("chicken.djvu");
2291        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2292        let err = doc.set_bookmarks(&[]).err().unwrap();
2293        assert!(matches!(err, MutError::BookmarksRequireDjvm));
2294    }
2295
2296    #[test]
2297    fn page_mut_djvm_text_layer_roundtrip() {
2298        use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
2299
2300        let original = read_corpus("DjVu3Spec_bundled.djvu");
2301        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2302        let layer = TextLayer {
2303            text: "djvm page-3 text".into(),
2304            zones: vec![TextZone {
2305                kind: TextZoneKind::Page,
2306                rect: Rect {
2307                    x: 0,
2308                    y: 0,
2309                    width: 100,
2310                    height: 50,
2311                },
2312                text: "djvm page-3 text".into(),
2313                children: vec![],
2314            }],
2315        };
2316        doc.page_mut(2).unwrap().set_text_layer(&layer).unwrap();
2317        let edited = doc.into_bytes();
2318
2319        let (declared, actual) = dirm_offsets_and_actual(&edited);
2320        assert_eq!(declared, actual);
2321
2322        // Re-open and confirm the targeted page now has a TXTz chunk.
2323        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2324        // The third FORM:DJVU child should have a TXTz leaf.
2325        let mut djvu_seen = 0usize;
2326        let mut found_txtz = false;
2327        for child in reparsed.file.root.children() {
2328            if let Chunk::Form {
2329                secondary_id,
2330                children,
2331                ..
2332            } = child
2333                && secondary_id == b"DJVU"
2334            {
2335                if djvu_seen == 2 {
2336                    found_txtz = children
2337                        .iter()
2338                        .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
2339                    break;
2340                }
2341                djvu_seen += 1;
2342            }
2343        }
2344        assert!(
2345            found_txtz,
2346            "TXTz chunk should be present on page 2 after set_text_layer"
2347        );
2348    }
2349
2350    /// PR4 of #222: editing one page in a bundled DJVM must leave every
2351    /// other page's bytes unchanged. The mutated page itself may grow
2352    /// (e.g. a new METz chunk), but unmutated FORM:DJVU/DJVI components
2353    /// must round-trip byte-identical.
2354    #[test]
2355    fn unmutated_pages_byte_identical_after_metadata_edit() {
2356        use crate::metadata::DjVuMetadata;
2357
2358        let original = read_corpus("DjVu3Spec_bundled.djvu");
2359
2360        let orig_ranges = top_form_ranges(&original);
2361
2362        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2363        let meta = DjVuMetadata {
2364            title: Some("PR4 byte-identical probe".into()),
2365            ..Default::default()
2366        };
2367        doc.page_mut(0).unwrap().set_metadata(&meta);
2368        let edited = doc.into_bytes();
2369
2370        let edited_ranges = top_form_ranges(&edited);
2371        assert_eq!(orig_ranges.len(), edited_ranges.len());
2372
2373        // The first FORM:DJVU child corresponds to page 0 (the one we edited);
2374        // it is allowed to differ. All others must be byte-identical.
2375        let mut djvu_idx = 0usize;
2376        for (i, (or, er)) in orig_ranges.iter().zip(edited_ranges.iter()).enumerate() {
2377            // Only enforce identity on FORM:DJVU/DJVI components — bare leaves
2378            // (DIRM, NAVM) legitimately change when offsets shift.
2379            let is_form_djvu = &original[or.start..or.start + 4] == b"FORM"
2380                && (&original[or.start + 8..or.start + 12] == b"DJVU"
2381                    || &original[or.start + 8..or.start + 12] == b"DJVI");
2382            if !is_form_djvu {
2383                continue;
2384            }
2385            let is_edited_page = djvu_idx == 0;
2386            djvu_idx += 1;
2387            if is_edited_page {
2388                continue;
2389            }
2390            assert_eq!(
2391                &original[or.clone()],
2392                &edited[er.clone()],
2393                "FORM at top-level child #{i} must be byte-identical after edit"
2394            );
2395        }
2396    }
2397
2398    // ---- #325: resolver-backed indirect DJVM rebundling -------------------
2399
2400    /// Build an indirect FORM:DJVM index over `page_names` and a resolver that
2401    /// serves each named fixture from `tests/fixtures`.
2402    fn indirect_over_fixtures(
2403        page_names: &[&str],
2404    ) -> (
2405        Vec<u8>,
2406        impl Fn(&str) -> Result<Vec<u8>, std::io::Error> + use<>,
2407    ) {
2408        let index = crate::djvm::create_indirect(page_names).expect("create_indirect");
2409        // Snapshot the fixture bytes keyed by name so the resolver is owned.
2410        let map: std::collections::HashMap<String, Vec<u8>> = page_names
2411            .iter()
2412            .map(|n| (n.to_string(), read_corpus(n)))
2413            .collect();
2414        let resolver = move |name: &str| -> Result<Vec<u8>, std::io::Error> {
2415            map.get(name).cloned().ok_or_else(|| {
2416                std::io::Error::new(std::io::ErrorKind::NotFound, "no such component")
2417            })
2418        };
2419        (index, resolver)
2420    }
2421
2422    #[test]
2423    fn from_indirect_resolved_rebundles_single_page() {
2424        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2425        let doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2426        assert_eq!(doc.root_form_type(), Some(b"DJVM"));
2427        assert_eq!(doc.page_count(), 1);
2428        assert!(!doc.is_dirty());
2429
2430        // Output must parse as a bundled DJVM without any resolver.
2431        let bundled = doc.try_into_bytes().unwrap();
2432        let reparsed =
2433            crate::djvu_document::DjVuDocument::parse(&bundled).expect("bundled output parses");
2434        assert_eq!(reparsed.page_count(), 1);
2435        // The single page's pixel dimensions come from the resolved chicken.djvu.
2436        assert_eq!(reparsed.page(0).unwrap().width(), 181);
2437        assert_eq!(reparsed.page(0).unwrap().height(), 240);
2438
2439        // DIRM offsets must point at the actual component FORM positions.
2440        let (declared, actual) = dirm_offsets_and_actual(&bundled);
2441        assert_eq!(declared, actual);
2442    }
2443
2444    #[test]
2445    fn from_indirect_resolved_multi_page_preserves_order() {
2446        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2447        let doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2448        assert_eq!(doc.page_count(), 2);
2449        let bundled = doc.try_into_bytes().unwrap();
2450
2451        let reparsed = crate::djvu_document::DjVuDocument::parse(&bundled).expect("parses");
2452        assert_eq!(reparsed.page_count(), 2);
2453        // Page 0 == chicken (181x240), page 1 == irish (different size).
2454        assert_eq!(reparsed.page(0).unwrap().width(), 181);
2455        let irish_doc = crate::djvu_document::DjVuDocument::parse(&read_corpus("irish.djvu"))
2456            .expect("irish parses standalone");
2457        assert_eq!(
2458            reparsed.page(1).unwrap().dimensions(),
2459            irish_doc.page(0).unwrap().dimensions()
2460        );
2461
2462        let (declared, actual) = dirm_offsets_and_actual(&bundled);
2463        assert_eq!(declared, actual);
2464    }
2465
2466    #[test]
2467    fn from_indirect_resolved_then_metadata_edit_roundtrips() {
2468        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2469        let mut doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2470
2471        let meta = DjVuMetadata {
2472            title: Some("rebundled indirect".into()),
2473            author: Some("djvu-rs #325".into()),
2474            ..Default::default()
2475        };
2476        doc.page_mut(1).unwrap().set_metadata(&meta);
2477        assert!(doc.is_dirty());
2478        let edited = doc.into_bytes();
2479
2480        // Offsets stay consistent after the page-1 metadata grows.
2481        let (declared, actual) = dirm_offsets_and_actual(&edited);
2482        assert_eq!(declared, actual);
2483
2484        // Metadata round-trips through the high-level parser on the edited page.
2485        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2486        let mut djvu_seen = 0usize;
2487        let mut found = None;
2488        for child in reparsed.file.root.children() {
2489            if let Chunk::Form {
2490                secondary_id,
2491                children,
2492                ..
2493            } = child
2494                && secondary_id == b"DJVU"
2495            {
2496                if djvu_seen == 1 {
2497                    found = children
2498                        .iter()
2499                        .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
2500                        .map(|c| c.data().to_vec());
2501                    break;
2502                }
2503                djvu_seen += 1;
2504            }
2505        }
2506        let metz = found.expect("page 1 should have METz after edit");
2507        let decoded = crate::bzz::bzz_decode(&metz).unwrap();
2508        let parsed = crate::metadata::parse_metadata(&decoded).unwrap();
2509        assert_eq!(parsed.title.as_deref(), Some("rebundled indirect"));
2510    }
2511
2512    #[test]
2513    fn from_indirect_resolved_then_text_layer_edit() {
2514        use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
2515
2516        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2517        let mut doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2518        let layer = TextLayer {
2519            text: "rebundled text".into(),
2520            zones: vec![TextZone {
2521                kind: TextZoneKind::Page,
2522                rect: Rect {
2523                    x: 0,
2524                    y: 0,
2525                    width: 100,
2526                    height: 50,
2527                },
2528                text: "rebundled text".into(),
2529                children: vec![],
2530            }],
2531        };
2532        doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
2533        let edited = doc.into_bytes();
2534
2535        let reparsed = crate::djvu_document::DjVuDocument::parse(&edited).expect("parses");
2536        let text = reparsed.page(0).unwrap().text_layer().unwrap();
2537        assert!(text.is_some(), "edited page should expose a text layer");
2538        assert_eq!(text.unwrap().text, "rebundled text");
2539    }
2540
2541    #[test]
2542    fn from_indirect_resolved_missing_component_errors() {
2543        // Resolver that never produces bytes ⇒ ComponentResolve.
2544        let index = crate::djvm::create_indirect(&["missing.djvu"]).expect("create_indirect");
2545        let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2546            Err::<Vec<u8>, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "nope"))
2547        })
2548        .unwrap_err();
2549        match err {
2550            MutError::ComponentResolve { name } => assert_eq!(name, "missing.djvu"),
2551            other => panic!("expected ComponentResolve, got {other:?}"),
2552        }
2553    }
2554
2555    #[test]
2556    fn from_indirect_resolved_malformed_component_errors() {
2557        let index = crate::djvm::create_indirect(&["garbage.djvu"]).expect("create_indirect");
2558        let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2559            Ok::<Vec<u8>, std::io::Error>(b"not an iff document".to_vec())
2560        })
2561        .unwrap_err();
2562        assert!(
2563            matches!(err, MutError::ComponentMalformed { .. }),
2564            "{err:?}"
2565        );
2566    }
2567
2568    // Lines 293-298: DjVuDocumentMut::from_indirect_resolved with FORM:FAKE component.
2569    #[test]
2570    fn from_indirect_resolved_wrong_form_type_errors() {
2571        let index = crate::djvm::create_indirect(&["fake.djvu"]).expect("create_indirect");
2572        let fake = iff::emit(&DjvuFile {
2573            root: Chunk::Form {
2574                secondary_id: *b"FAKE",
2575                length: 0,
2576                children: vec![],
2577            },
2578        });
2579        let err = DjVuDocumentMut::from_indirect_resolved(&index, move |_name: &str| {
2580            Ok::<Vec<u8>, std::io::Error>(fake.clone())
2581        })
2582        .unwrap_err();
2583        assert!(
2584            matches!(err, MutError::ComponentMalformed { .. }),
2585            "{err:?}"
2586        );
2587    }
2588
2589    #[test]
2590    fn from_indirect_resolved_rejects_bundled_input() {
2591        // A genuinely bundled DJVM is not indirect ⇒ NotIndirectDjvm.
2592        let bundled = read_corpus("DjVu3Spec_bundled.djvu");
2593        let err = DjVuDocumentMut::from_indirect_resolved(&bundled, |_n: &str| {
2594            Ok::<Vec<u8>, std::io::Error>(Vec::new())
2595        })
2596        .unwrap_err();
2597        assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2598    }
2599
2600    #[test]
2601    fn from_indirect_resolved_rejects_single_page_djvu() {
2602        let chicken = read_corpus("chicken.djvu");
2603        let err = DjVuDocumentMut::from_indirect_resolved(&chicken, |_n: &str| {
2604            Ok::<Vec<u8>, std::io::Error>(Vec::new())
2605        })
2606        .unwrap_err();
2607        assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2608    }
2609
2610    /// Indirect DJVM whose DIRM lists only Shared entries (no Page) fires
2611    /// lines 274-275: DirmMalformed "indirect DIRM lists no page component".
2612    #[test]
2613    fn from_indirect_resolved_no_page_component_returns_dirm_malformed() {
2614        use crate::dirm::DirmPayload;
2615        // Build indirect DJVM with 1 Shared entry (flag=0x00)
2616        let dirm_payload = DirmPayload::build_indirect(1, &[0x00], &["shared.djvi".to_string()]);
2617        let dirm_chunk = iff::Chunk::Leaf {
2618            id: *b"DIRM",
2619            data: dirm_payload.encode(),
2620        };
2621        let index =
2622            iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm_chunk)]).expect("fits");
2623
2624        let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2625            Ok::<Vec<u8>, std::io::Error>(Vec::new())
2626        })
2627        .unwrap_err();
2628        assert!(
2629            matches!(err, MutError::DirmMalformed(_)),
2630            "expected DirmMalformed, got {err:?}"
2631        );
2632    }
2633
2634    #[test]
2635    fn from_bytes_on_indirect_still_unsupported_for_page_mut() {
2636        // The plain entry point keeps the documented unsupported behavior.
2637        let index = crate::djvm::create_indirect(&["chicken.djvu"]).expect("create_indirect");
2638        let mut doc = DjVuDocumentMut::from_bytes(&index).unwrap();
2639        let err = doc.page_mut(0).err().unwrap();
2640        assert!(matches!(err, MutError::IndirectDjvmUnsupported), "{err:?}");
2641    }
2642
2643    // ---- #326: explicit external-file rewrite plan ------------------------
2644
2645    /// A fresh, empty temp directory unique to `tag` (cleared if it exists).
2646    fn fresh_temp_dir(tag: &str) -> PathBuf {
2647        let dir = std::env::temp_dir().join(format!("djvu_rs_rewrite_{tag}"));
2648        let _ = std::fs::remove_dir_all(&dir);
2649        std::fs::create_dir_all(&dir).unwrap();
2650        dir
2651    }
2652
2653    #[test]
2654    fn rewrite_plan_commits_full_set_to_dir() {
2655        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2656        let mut plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2657        assert_eq!(plan.page_count(), 2);
2658
2659        // Edit page 0's metadata in memory only.
2660        plan.edit_page(0, |doc| {
2661            let meta = DjVuMetadata {
2662                title: Some("rewrite path".into()),
2663                ..Default::default()
2664            };
2665            doc.page_mut(0)?.set_metadata(&meta);
2666            Ok(())
2667        })
2668        .unwrap();
2669
2670        // The preview marks page 0 changed, page 1 and root unchanged.
2671        let preview = plan.plan("index.djvu");
2672        assert_eq!(preview.len(), 3);
2673        assert_eq!(preview[0].name, "chicken.djvu");
2674        assert!(preview[0].changed, "edited page must show changed");
2675        assert_eq!(preview[1].name, "irish.djvu");
2676        assert!(!preview[1].changed, "untouched page must be unchanged");
2677        assert!(preview[2].is_root);
2678        assert!(!preview[2].changed, "root unchanged for a page-only edit");
2679
2680        let dir = fresh_temp_dir("commit_full_set");
2681        let written = plan.commit_to_dir(&dir, "index.djvu").unwrap();
2682        assert_eq!(written.len(), 3);
2683        for p in &written {
2684            assert!(p.exists(), "committed file {p:?} must exist");
2685        }
2686        // No stray temp files left behind.
2687        let leftovers: Vec<_> = std::fs::read_dir(&dir)
2688            .unwrap()
2689            .filter_map(|e| e.ok())
2690            .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
2691            .collect();
2692        assert!(leftovers.is_empty(), "temp files must be renamed away");
2693
2694        // The rewritten directory parses as an indirect document and the edit
2695        // landed on page 0.
2696        let index_bytes = std::fs::read(dir.join("index.djvu")).unwrap();
2697        let doc = crate::djvu_document::DjVuDocument::parse_from_dir(&index_bytes, &dir).unwrap();
2698        assert_eq!(doc.page_count(), 2);
2699        let meta_page0 = doc.page(0).unwrap();
2700        // metadata is read at the document level; confirm the edited component
2701        // round-trips through the single-page parser.
2702        let edited_comp = std::fs::read(dir.join("chicken.djvu")).unwrap();
2703        let reparsed = DjVuDocumentMut::from_bytes(&edited_comp).unwrap();
2704        let has_metz = reparsed
2705            .file
2706            .root
2707            .children()
2708            .iter()
2709            .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"));
2710        assert!(has_metz, "edited component file must contain METz");
2711        // The unedited component is byte-identical to the source fixture.
2712        let irish_src = read_corpus("irish.djvu");
2713        let irish_out = std::fs::read(dir.join("irish.djvu")).unwrap();
2714        assert_eq!(irish_out, irish_src, "unedited component copied verbatim");
2715        let _ = meta_page0;
2716
2717        let _ = std::fs::remove_dir_all(&dir);
2718    }
2719
2720    #[test]
2721    fn rewrite_plan_rejects_duplicate_dirm_names() {
2722        // Two DIRM entries with the same id ⇒ DuplicateComponentName.
2723        let index = crate::djvm::create_indirect(&["dup.djvu", "dup.djvu"]).expect("create");
2724        let err = IndirectRewritePlan::from_indirect_resolved(&index, |_n: &str| {
2725            Ok::<Vec<u8>, std::io::Error>(read_corpus("chicken.djvu"))
2726        })
2727        .unwrap_err();
2728        match err {
2729            MutError::DuplicateComponentName { name } => assert_eq!(name, "dup.djvu"),
2730            other => panic!("expected DuplicateComponentName, got {other:?}"),
2731        }
2732    }
2733
2734    #[test]
2735    fn rewrite_plan_rejects_unsafe_dirm_names() {
2736        for bad in [
2737            "../evil.djvu",
2738            "/abs.djvu",
2739            "sub/page.djvu",
2740            "..",
2741            "a:b.djvu",
2742        ] {
2743            let index = crate::djvm::create_indirect(&[bad]).expect("create");
2744            let err = IndirectRewritePlan::from_indirect_resolved(&index, |_n: &str| {
2745                Ok::<Vec<u8>, std::io::Error>(read_corpus("chicken.djvu"))
2746            })
2747            .unwrap_err();
2748            assert!(
2749                matches!(err, MutError::UnsafeComponentName { .. }),
2750                "name {bad:?} should be rejected, got {err:?}"
2751            );
2752        }
2753    }
2754
2755    #[test]
2756    fn rewrite_plan_unsafe_root_name_leaves_dir_unchanged() {
2757        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2758        let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2759
2760        let dir = fresh_temp_dir("unsafe_root");
2761        // Drop a sentinel file that must survive a failed commit.
2762        std::fs::write(dir.join("sentinel"), b"keep me").unwrap();
2763
2764        let err = plan.commit_to_dir(&dir, "../escape.djvu").unwrap_err();
2765        assert!(
2766            matches!(err, MutError::UnsafeComponentName { .. }),
2767            "{err:?}"
2768        );
2769
2770        // Nothing was written: only the sentinel remains.
2771        let entries: Vec<String> = std::fs::read_dir(&dir)
2772            .unwrap()
2773            .filter_map(|e| e.ok())
2774            .map(|e| e.file_name().to_string_lossy().into_owned())
2775            .collect();
2776        assert_eq!(entries, vec!["sentinel".to_string()]);
2777        assert_eq!(std::fs::read(dir.join("sentinel")).unwrap(), b"keep me");
2778
2779        let _ = std::fs::remove_dir_all(&dir);
2780    }
2781
2782    #[test]
2783    fn rewrite_plan_root_name_collision_rejected() {
2784        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2785        let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2786        let dir = fresh_temp_dir("root_collision");
2787        // Root name equals a component name — would shadow the page file.
2788        let err = plan.commit_to_dir(&dir, "chicken.djvu").unwrap_err();
2789        assert!(
2790            matches!(err, MutError::DuplicateComponentName { .. }),
2791            "{err:?}"
2792        );
2793        // Validation failed before writing: directory is still empty.
2794        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
2795        let _ = std::fs::remove_dir_all(&dir);
2796    }
2797
2798    #[test]
2799    fn rewrite_plan_set_bookmarks_marks_root_changed() {
2800        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2801        let mut plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2802        plan.set_bookmarks(&[DjVuBookmark {
2803            title: "Top".into(),
2804            url: "#1".into(),
2805            children: vec![],
2806        }])
2807        .unwrap();
2808
2809        let preview = plan.plan("index.djvu");
2810        let root = preview.iter().find(|i| i.is_root).unwrap();
2811        assert!(root.changed, "root index must be marked changed");
2812
2813        // Commit and confirm the index file carries NAVM bookmarks.
2814        let dir = fresh_temp_dir("bookmarks");
2815        plan.commit_to_dir(&dir, "index.djvu").unwrap();
2816        let index_bytes = std::fs::read(dir.join("index.djvu")).unwrap();
2817        let form = crate::iff::parse_form(&index_bytes).unwrap();
2818        assert!(
2819            form.chunks.iter().any(|c| &c.id == b"NAVM"),
2820            "committed index must contain NAVM"
2821        );
2822        let _ = std::fs::remove_dir_all(&dir);
2823    }
2824
2825    #[test]
2826    fn rewrite_plan_rejects_bundled_input() {
2827        let bundled = read_corpus("DjVu3Spec_bundled.djvu");
2828        let err = IndirectRewritePlan::from_indirect_resolved(&bundled, |_n: &str| {
2829            Ok::<Vec<u8>, std::io::Error>(Vec::new())
2830        })
2831        .unwrap_err();
2832        assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2833    }
2834
2835    #[test]
2836    fn chunk_at_path_rejects_empty_path() {
2837        let original = read_corpus("chicken.djvu");
2838        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2839        let err = doc.chunk_at_path(&[]).unwrap_err();
2840        assert!(matches!(err, MutError::EmptyPath));
2841    }
2842
2843    #[test]
2844    fn root_form_type_returns_some_for_form_root() {
2845        let original = read_corpus("chicken.djvu");
2846        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2847        let t = doc.root_form_type();
2848        assert!(t.is_some());
2849    }
2850
2851    // Line 585: (None, true) branch of set_bookmarks — no-op when DJVM has no NAVM and
2852    // we try to set empty bookmarks.
2853    #[test]
2854    fn set_bookmarks_empty_on_djvm_without_navm_is_noop() {
2855        // Strip NAVM from a bundled doc, then call set_bookmarks(&[]) on the stripped doc.
2856        let original = read_corpus("DjVu3Spec_bundled.djvu");
2857        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2858        doc.set_bookmarks(&[]).unwrap(); // removes NAVM if present
2859        let stripped = doc.into_bytes();
2860
2861        // Now stripped has no NAVM; set_bookmarks(&[]) is a true no-op (None, true).
2862        let mut doc2 = DjVuDocumentMut::from_bytes(&stripped).unwrap();
2863        doc2.set_bookmarks(&[]).unwrap();
2864        assert_eq!(
2865            doc2.into_bytes(),
2866            stripped,
2867            "no-op set_bookmarks should not change bytes"
2868        );
2869    }
2870
2871    // Line 936: (None, true) branch of replace_or_insert — set_metadata with default
2872    // (empty) on a page that has no existing METa/METz chunk.
2873    #[test]
2874    fn set_metadata_empty_on_page_without_meta_is_noop() {
2875        let original = read_corpus("chicken.djvu"); // known: no METa chunk
2876        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2877        // Default metadata → encode_metadata returns empty → (None, true) no-op path.
2878        doc.page_mut(0)
2879            .unwrap()
2880            .set_metadata(&DjVuMetadata::default());
2881        // Dirty is still set (set_metadata always marks dirty), but no METa was inserted.
2882        let bytes = doc.into_bytes();
2883        let reparsed = DjVuDocumentMut::from_bytes(&bytes).unwrap();
2884        let has_meta = reparsed
2885            .file
2886            .root
2887            .children()
2888            .iter()
2889            .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"));
2890        assert!(
2891            !has_meta,
2892            "empty set_metadata should not insert a METa chunk"
2893        );
2894    }
2895
2896    // Lines 391-393: PathTraversesLeaf first branch (children.is_empty && depth < len-1).
2897    // A 3-deep path where [0] reaches INFO (Leaf): depth=1 triggers the first check.
2898    #[test]
2899    fn chunk_at_path_traverses_leaf_first_branch() {
2900        let original = read_corpus("chicken.djvu");
2901        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2902        let err = doc.chunk_at_path(&[0, 0, 0]).unwrap_err();
2903        assert!(
2904            matches!(err, MutError::PathTraversesLeaf { depth: 1, len: 3 }),
2905            "{err:?}"
2906        );
2907    }
2908
2909    // Lines 1089, 1094-1095: IndirectRewritePlan::from_indirect_resolved error paths.
2910    #[test]
2911    fn rewrite_plan_resolver_failure_returns_component_resolve_error() {
2912        let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2913        let err = IndirectRewritePlan::from_indirect_resolved(&index, |_name: &str| {
2914            Err::<Vec<u8>, std::io::Error>(std::io::Error::new(
2915                std::io::ErrorKind::NotFound,
2916                "nope",
2917            ))
2918        })
2919        .unwrap_err();
2920        assert!(matches!(err, MutError::ComponentResolve { .. }), "{err:?}");
2921    }
2922
2923    #[test]
2924    fn rewrite_plan_non_iff_component_returns_malformed_error() {
2925        let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2926        let err = IndirectRewritePlan::from_indirect_resolved(&index, |_name: &str| {
2927            Ok::<Vec<u8>, std::io::Error>(b"not iff".to_vec())
2928        })
2929        .unwrap_err();
2930        assert!(
2931            matches!(err, MutError::ComponentMalformed { .. }),
2932            "{err:?}"
2933        );
2934    }
2935
2936    // Lines 1100-1105: wrong FORM type (not DJVU/DJVI/THUM) → ComponentMalformed.
2937    #[test]
2938    fn rewrite_plan_wrong_form_type_returns_malformed_error() {
2939        let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2940        let fake = iff::emit(&DjvuFile {
2941            root: Chunk::Form {
2942                secondary_id: *b"FAKE",
2943                length: 0,
2944                children: vec![],
2945            },
2946        });
2947        let err = IndirectRewritePlan::from_indirect_resolved(&index, move |_name: &str| {
2948            Ok::<Vec<u8>, std::io::Error>(fake.clone())
2949        })
2950        .unwrap_err();
2951        assert!(
2952            matches!(err, MutError::ComponentMalformed { .. }),
2953            "{err:?}"
2954        );
2955    }
2956
2957    // Lines 1131-1132: component_count() on IndirectRewritePlan.
2958    #[test]
2959    fn rewrite_plan_component_count() {
2960        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2961        let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2962        assert_eq!(plan.component_count(), 2);
2963        assert_eq!(plan.page_count(), 2);
2964    }
2965
2966    #[cfg(feature = "std")]
2967    #[test]
2968    fn validate_safe_component_name_rejects_empty() {
2969        let err = validate_safe_component_name("").unwrap_err();
2970        assert!(matches!(err, MutError::UnsafeComponentName { .. }));
2971    }
2972
2973    #[cfg(feature = "std")]
2974    #[test]
2975    fn validate_safe_component_name_rejects_nul() {
2976        let err = validate_safe_component_name("a\0b").unwrap_err();
2977        assert!(matches!(err, MutError::UnsafeComponentName { .. }));
2978    }
2979
2980    /// Walk top-level children of the outer FORM and return their absolute
2981    /// byte ranges (header+payload+pad).
2982    fn top_form_ranges(data: &[u8]) -> Vec<core::ops::Range<usize>> {
2983        assert_eq!(&data[..4], b"AT&T");
2984        let form_len = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
2985        let body_end = 12 + form_len;
2986        let mut pos = 16usize; // skip AT&T(4) + FORM(4) + len(4) + secondary_id(4)
2987        let mut out = Vec::new();
2988        while pos + 8 <= body_end {
2989            let len =
2990                u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
2991                    as usize;
2992            let mut next = pos + 8 + len;
2993            if next & 1 == 1 && next < body_end {
2994                next += 1;
2995            }
2996            out.push(pos..next);
2997            pos = next;
2998        }
2999        out
3000    }
3001
3002    // ---- is_bundled_djvm edge cases -----------------------------------------
3003
3004    // Line 672: root is a Leaf → returns false immediately.
3005    #[test]
3006    fn is_bundled_djvm_leaf_returns_false() {
3007        let leaf = Chunk::Leaf {
3008            id: *b"INFO",
3009            data: vec![],
3010        };
3011        assert!(!is_bundled_djvm(&leaf));
3012    }
3013
3014    // Line 675: FORM with secondary_id != DJVM → returns false.
3015    #[test]
3016    fn is_bundled_djvm_non_djvm_form_returns_false() {
3017        let form = Chunk::Form {
3018            secondary_id: *b"DJVU",
3019            length: 0,
3020            children: vec![],
3021        };
3022        assert!(!is_bundled_djvm(&form));
3023    }
3024
3025    // ---- resolve_indirect_components / find_leaf_data edge cases ------------
3026
3027    // Line 637: indirect DJVM with nfiles=0 → DirmMalformed("indirect DIRM lists no components").
3028    #[test]
3029    fn from_indirect_resolved_empty_dirm_returns_dirm_malformed() {
3030        let dirm_payload = DirmPayload::build_indirect(0, &[], &[]);
3031        let dirm = Chunk::Leaf {
3032            id: *b"DIRM",
3033            data: dirm_payload.encode(),
3034        };
3035        let index = iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm)]).expect("fits");
3036        let err = DjVuDocumentMut::from_indirect_resolved(&index, |_n: &str| {
3037            Ok::<Vec<u8>, std::io::Error>(Vec::new())
3038        })
3039        .unwrap_err();
3040        assert!(
3041            matches!(err, MutError::DirmMalformed(_)),
3042            "expected DirmMalformed, got {err:?}"
3043        );
3044    }
3045
3046    // Line 915: `find_leaf_data` returns None when the page has no INFO chunk.
3047    // Triggered by calling `set_text_layer` on a FORM:DJVU without an INFO chunk.
3048    #[test]
3049    fn set_text_layer_missing_info_chunk_returns_missing_page_info() {
3050        use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
3051
3052        let bytes = iff::emit(&iff::DjvuFile {
3053            root: Chunk::Form {
3054                secondary_id: *b"DJVU",
3055                length: 0,
3056                // No INFO chunk
3057                children: vec![Chunk::Leaf {
3058                    id: *b"ANTz",
3059                    data: vec![0u8; 4],
3060                }],
3061            },
3062        });
3063        let mut doc = DjVuDocumentMut::from_bytes(&bytes).expect("no-INFO DJVU must parse");
3064        let layer = TextLayer {
3065            text: "hello".to_string(),
3066            zones: vec![TextZone {
3067                kind: TextZoneKind::Page,
3068                rect: Rect {
3069                    x: 0,
3070                    y: 0,
3071                    width: 10,
3072                    height: 10,
3073                },
3074                text: "hello".to_string(),
3075                children: vec![],
3076            }],
3077        };
3078        let err = doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap_err();
3079        assert!(matches!(err, MutError::MissingPageInfo), "{err:?}");
3080    }
3081
3082    // ---- emit_patched_single_page / original_single_page_child_ranges -------
3083
3084    // Line 700: root is a Leaf → emit_patched_single_page returns None immediately.
3085    #[test]
3086    fn emit_patched_leaf_root_returns_none() {
3087        let leaf = Chunk::Leaf {
3088            id: *b"INFO",
3089            data: vec![0u8; 4],
3090        };
3091        assert!(emit_patched_single_page(&leaf, &[]).is_none());
3092    }
3093
3094    // Line 725: DJVU FORM with a nested Form child → returns None.
3095    #[test]
3096    fn emit_patched_form_child_in_djvu_returns_none() {
3097        // Build minimal valid AT&T+FORM:DJVU bytes with one INFO leaf so that
3098        // original_single_page_child_ranges succeeds (1 child, no FORM inside).
3099        let original = iff::partial_emit(
3100            *b"DJVU",
3101            &[iff::EmitPart::Chunk(&Chunk::Leaf {
3102                id: *b"INFO",
3103                data: vec![0u8; 4],
3104            })],
3105        )
3106        .unwrap();
3107
3108        // In-memory tree: same DJVU root but child is a Form instead of the Leaf.
3109        let root = Chunk::Form {
3110            secondary_id: *b"DJVU",
3111            length: 0,
3112            children: vec![Chunk::Form {
3113                secondary_id: *b"INFO",
3114                length: 0,
3115                children: vec![],
3116            }],
3117        };
3118        assert!(emit_patched_single_page(&root, &original).is_none());
3119    }
3120
3121    // Line 734: slice shorter than 16 bytes → original_single_page_child_ranges returns None.
3122    #[test]
3123    fn original_child_ranges_too_short_returns_none() {
3124        assert!(original_single_page_child_ranges(b"AT&TFORM").is_none());
3125    }
3126
3127    // Line 739: secondary_id is not DJVU → returns None.
3128    #[test]
3129    fn original_child_ranges_not_djvu_returns_none() {
3130        let bytes = iff::partial_emit(*b"DJVI", &[]).unwrap();
3131        assert!(original_single_page_child_ranges(&bytes).is_none());
3132    }
3133
3134    // Line 753: DJVU body contains a chunk whose id is b"FORM" → returns None.
3135    #[test]
3136    fn original_child_ranges_nested_form_tag_returns_none() {
3137        // Build AT&T FORM:DJVU with one child whose id bytes are literally "FORM".
3138        // Data length = 4 so header+data = 12 bytes, body = DJVU(4)+12 = 16.
3139        // Use Verbatim so the chunk-id bytes spell "FORM" inside a slice literal,
3140        // routing around the raw-framing seam.
3141        let inner: &[u8] = b"FORM\x00\x00\x00\x04\x00\x00\x00\x00";
3142        let bytes = iff::partial_emit(*b"DJVU", &[iff::EmitPart::Verbatim(inner)]).unwrap();
3143        assert!(original_single_page_child_ranges(&bytes).is_none());
3144    }
3145
3146    // Line 761: last chunk has odd length and is exactly at body_end (no room for pad) → returns None.
3147    #[test]
3148    fn original_child_ranges_odd_length_at_body_end_returns_none() {
3149        // DJVU body = DJVU(4) + INFO header(8) + 3 bytes data = 15 bytes.
3150        // next = 16+8+3 = 27 = body_end → odd tail with no pad room.
3151        // partial_emit would add padding, so build the deliberately odd-body bytes manually.
3152        let form_tag: [u8; 4] = *b"FORM";
3153        let mut bytes: Vec<u8> = Vec::new();
3154        bytes.extend_from_slice(&iff::MAGIC);
3155        bytes.extend_from_slice(&form_tag);
3156        bytes.extend_from_slice(&15u32.to_be_bytes()); // body = 4+8+3 = 15
3157        bytes.extend_from_slice(b"DJVU");
3158        bytes.extend_from_slice(b"INFO");
3159        bytes.extend_from_slice(&3u32.to_be_bytes());
3160        bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
3161        assert!(original_single_page_child_ranges(&bytes).is_none());
3162    }
3163
3164    // Line 776: chunks don't tile the body exactly (1 extra trailing byte) → returns None.
3165    #[test]
3166    fn original_child_ranges_short_tail_returns_none() {
3167        // DJVU body = DJVU(4) + INFO hdr+data (12) + 1 extra byte = 17.
3168        // partial_emit cannot produce a non-tiling body, so build manually.
3169        let form_tag: [u8; 4] = *b"FORM";
3170        let mut bytes: Vec<u8> = Vec::new();
3171        bytes.extend_from_slice(&iff::MAGIC);
3172        bytes.extend_from_slice(&form_tag);
3173        bytes.extend_from_slice(&17u32.to_be_bytes()); // 17 = 4 + 8 + 4 + 1
3174        bytes.extend_from_slice(b"DJVU");
3175        bytes.extend_from_slice(b"INFO");
3176        bytes.extend_from_slice(&4u32.to_be_bytes());
3177        bytes.extend_from_slice(&[0u8; 4]);
3178        bytes.push(0x00); // extra trailing byte
3179        assert!(original_single_page_child_ranges(&bytes).is_none());
3180    }
3181
3182    // ---- recompute_dirm_offsets edge cases ----------------------------------
3183
3184    // Line 798: root is a Leaf → returns Ok immediately.
3185    #[test]
3186    fn recompute_dirm_offsets_leaf_root_is_noop() {
3187        let mut leaf = Chunk::Leaf {
3188            id: *b"INFO",
3189            data: vec![0u8; 4],
3190        };
3191        assert!(recompute_dirm_offsets(&mut leaf).is_ok());
3192    }
3193
3194    // Line 834: DJVM with FORM:DJVU child but no DIRM leaf → returns Ok.
3195    #[test]
3196    fn recompute_dirm_offsets_djvm_no_dirm_is_noop() {
3197        let mut root = Chunk::Form {
3198            secondary_id: *b"DJVM",
3199            length: 0,
3200            children: vec![Chunk::Form {
3201                secondary_id: *b"DJVU",
3202                length: 0,
3203                children: vec![],
3204            }],
3205        };
3206        assert!(recompute_dirm_offsets(&mut root).is_ok());
3207    }
3208
3209    // Lines 851-853: nfiles in DIRM != number of FORM:DJVU children → DirmComponentCountMismatch.
3210    #[test]
3211    fn recompute_dirm_offsets_count_mismatch_errors() {
3212        // DIRM payload with nfiles=2 but bundled, then supply only 1 FORM:DJVU.
3213        let dirm_payload = DirmPayload::build_bundled(
3214            2,
3215            &[0x01, 0x01],
3216            &["p1.djvu".to_string(), "p2.djvu".to_string()],
3217            &[],
3218        );
3219        let dirm_data = dirm_payload.encode();
3220        let mut root = Chunk::Form {
3221            secondary_id: *b"DJVM",
3222            length: 0,
3223            children: vec![
3224                Chunk::Leaf {
3225                    id: *b"DIRM",
3226                    data: dirm_data,
3227                },
3228                Chunk::Form {
3229                    secondary_id: *b"DJVU",
3230                    length: 0,
3231                    children: vec![],
3232                },
3233                // Only 1 FORM:DJVU but DIRM says nfiles=2 → mismatch
3234            ],
3235        };
3236        let err = recompute_dirm_offsets(&mut root).unwrap_err();
3237        assert!(
3238            matches!(err, MutError::DirmComponentCountMismatch { .. }),
3239            "{err:?}"
3240        );
3241    }
3242}