Skip to main content

djvu_rs/
editor.rs

1//! Typed, validated document editing operations.
2//!
3//! This module is the library-side operation model for the declarative editor
4//! work tracked in issue #688. It deliberately builds on
5//! [`crate::djvu_mut::DjVuDocumentMut`] rather than exposing chunk paths: a
6//! request is validated in full before any output file is replaced.
7
8use std::fs::{self, OpenOptions};
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use crate::DjVuBookmark;
14use crate::annotation::{Annotation, MapArea};
15use crate::djvu_mut::{DjVuDocumentMut, MutError, PageMut};
16use crate::metadata::DjVuMetadata;
17use crate::text::TextLayer;
18
19/// Version of the typed editor operation schema.
20pub const EDIT_SCHEMA_VERSION: u16 = 1;
21
22/// A versioned list of semantic editing operations.
23#[derive(Debug, Clone)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25pub struct EditRequest {
26    /// Wire/schema version for this request.
27    pub version: u16,
28    /// Operations are validated and applied in this order.
29    pub operations: Vec<EditOperation>,
30}
31
32impl EditRequest {
33    /// Construct a request using the current schema version.
34    pub fn new(operations: Vec<EditOperation>) -> Self {
35        Self {
36            version: EDIT_SCHEMA_VERSION,
37            operations,
38        }
39    }
40}
41
42/// A semantic editing operation supported by the first editor slice.
43#[derive(Debug, Clone)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45#[cfg_attr(feature = "serde", serde(tag = "op", rename_all = "snake_case"))]
46pub enum EditOperation {
47    /// Replace a page's text layer.
48    SetText { page: usize, layer: TextLayer },
49    /// Remove a page's text layer.
50    RemoveText { page: usize },
51    /// Replace a page's annotation layer.
52    SetPageAnnotations {
53        page: usize,
54        annotation: Annotation,
55        areas: Vec<MapArea>,
56    },
57    /// Remove a page's annotation layer.
58    RemovePageAnnotations { page: usize },
59    /// Replace page-level METa/METz metadata.
60    SetPageMetadata { page: usize, metadata: DjVuMetadata },
61    /// Remove page-level METa/METz metadata.
62    RemovePageMetadata { page: usize },
63    /// Replace document-level METa/METz metadata.
64    SetDocumentMetadata { metadata: DjVuMetadata },
65    /// Remove document-level METa/METz metadata.
66    RemoveDocumentMetadata,
67    /// Replace the document's NAVM bookmarks.
68    SetBookmarks { bookmarks: Vec<DjVuBookmark> },
69    /// Remove the document's NAVM bookmarks.
70    RemoveBookmarks,
71}
72
73impl EditOperation {
74    /// Return the stable semantic kind used in dry-run plans and diagnostics.
75    pub fn kind(&self) -> EditOperationKind {
76        match self {
77            Self::SetText { .. } => EditOperationKind::SetText,
78            Self::RemoveText { .. } => EditOperationKind::RemoveText,
79            Self::SetPageAnnotations { .. } => EditOperationKind::SetPageAnnotations,
80            Self::RemovePageAnnotations { .. } => EditOperationKind::RemovePageAnnotations,
81            Self::SetPageMetadata { .. } => EditOperationKind::SetPageMetadata,
82            Self::RemovePageMetadata { .. } => EditOperationKind::RemovePageMetadata,
83            Self::SetDocumentMetadata { .. } => EditOperationKind::SetDocumentMetadata,
84            Self::RemoveDocumentMetadata => EditOperationKind::RemoveDocumentMetadata,
85            Self::SetBookmarks { .. } => EditOperationKind::SetBookmarks,
86            Self::RemoveBookmarks => EditOperationKind::RemoveBookmarks,
87        }
88    }
89
90    fn target(&self) -> EditTarget {
91        match self {
92            Self::SetText { page, .. }
93            | Self::RemoveText { page }
94            | Self::SetPageAnnotations { page, .. }
95            | Self::RemovePageAnnotations { page }
96            | Self::SetPageMetadata { page, .. }
97            | Self::RemovePageMetadata { page } => EditTarget::Page { page: *page },
98            Self::SetDocumentMetadata { .. }
99            | Self::RemoveDocumentMetadata
100            | Self::SetBookmarks { .. }
101            | Self::RemoveBookmarks => EditTarget::Document,
102        }
103    }
104}
105
106/// Stable semantic operation kind in an [`EditPlan`].
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
109#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
110pub enum EditOperationKind {
111    SetText,
112    RemoveText,
113    SetPageAnnotations,
114    RemovePageAnnotations,
115    SetPageMetadata,
116    RemovePageMetadata,
117    SetDocumentMetadata,
118    RemoveDocumentMetadata,
119    SetBookmarks,
120    RemoveBookmarks,
121}
122
123/// Semantic target of one planned operation.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
126#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
127pub enum EditTarget {
128    /// A page in document page order.
129    Page { page: usize },
130    /// Document-level state.
131    Document,
132}
133
134/// One operation in a dry-run semantic plan.
135#[derive(Debug, Clone, PartialEq, Eq)]
136#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
137pub struct PlannedEdit {
138    /// Zero-based index in the request's operation list.
139    pub operation: usize,
140    /// Semantic operation kind.
141    pub kind: EditOperationKind,
142    /// Page or document target.
143    pub target: EditTarget,
144}
145
146/// Validated semantic change plan.
147#[derive(Debug, Clone, PartialEq, Eq)]
148#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
149pub struct EditPlan {
150    /// Request schema version.
151    pub schema_version: u16,
152    /// Number of pages available to page-targeted operations.
153    pub page_count: usize,
154    /// Operations in application order.
155    pub operations: Vec<PlannedEdit>,
156}
157
158/// Errors from request validation, application, or atomic output.
159#[derive(Debug, thiserror::Error)]
160#[non_exhaustive]
161pub enum EditError {
162    /// The request version is not supported by this editor.
163    #[error("unsupported editor schema version {found}; expected {EDIT_SCHEMA_VERSION}")]
164    UnsupportedSchemaVersion { found: u16 },
165
166    /// A page-targeted operation names a page outside the document.
167    #[error("operation {operation} targets page {page}, but the document has {page_count} pages")]
168    PageOutOfRange {
169        /// Zero-based operation index.
170        operation: usize,
171        /// Requested page.
172        page: usize,
173        /// Available page count.
174        page_count: usize,
175    },
176
177    /// This first slice intentionally supports only single-page and bundled
178    /// documents; indirect documents need their external commit model.
179    #[error("document shape is unsupported for this editor: {detail}")]
180    UnsupportedDocumentShape {
181        /// Reason for the shape restriction.
182        detail: &'static str,
183    },
184
185    /// The underlying mutation primitive rejected one operation.
186    #[error("operation {operation} ({kind:?}) failed: {source}")]
187    OperationFailed {
188        /// Zero-based operation index.
189        operation: usize,
190        /// Semantic operation kind.
191        kind: EditOperationKind,
192        /// Underlying mutation failure.
193        #[source]
194        source: MutError,
195    },
196
197    /// The input bytes could not be parsed as a mutable DjVu document.
198    #[error("could not open document for editing: {0}")]
199    Parse(#[source] MutError),
200
201    /// The edited mutation tree could not be serialized.
202    #[error("could not serialize edited document: {0}")]
203    Serialize(#[source] MutError),
204
205    /// Input and output resolve to the same file.
206    #[error("editor input and output paths must differ")]
207    OutputAliasesInput,
208
209    /// The edited output failed pre-commit validation (#696): applying the
210    /// request produced bytes with error-severity validation findings, so the
211    /// destination was left untouched.
212    #[error("edited output failed pre-commit validation: {summary}")]
213    InvalidPlannedOutput {
214        /// Compact `code` list of the error-severity findings.
215        summary: String,
216    },
217
218    /// Filesystem failure while reading or atomically replacing output.
219    #[error("editor I/O error: {0}")]
220    Io(#[from] std::io::Error),
221}
222
223/// Stateless entry point for typed editor operations.
224#[derive(Debug, Clone, Copy, Default)]
225pub struct DocumentEditor;
226
227impl DocumentEditor {
228    /// Validate a request and return its semantic dry-run plan.
229    ///
230    /// Validation applies every operation to a private mutation clone, so a
231    /// later invalid operation is reported before the caller receives any
232    /// output bytes or touches a destination path.
233    pub fn plan(input: &[u8], request: &EditRequest) -> Result<EditPlan, EditError> {
234        let doc = DjVuDocumentMut::from_bytes(input).map_err(EditError::Parse)?;
235        validate_schema(request)?;
236        ensure_supported_shape(&doc)?;
237
238        let plan = EditPlan {
239            schema_version: request.version,
240            page_count: doc.page_count(),
241            operations: request
242                .operations
243                .iter()
244                .enumerate()
245                .map(|(operation, edit)| PlannedEdit {
246                    operation,
247                    kind: edit.kind(),
248                    target: edit.target(),
249                })
250                .collect(),
251        };
252
253        let mut probe = doc.clone();
254        for (operation, edit) in request.operations.iter().enumerate() {
255            apply_one(&mut probe, operation, edit)?;
256        }
257        Ok(plan)
258    }
259
260    /// Validate and apply a request, returning the edited DjVu bytes.
261    pub fn apply(input: &[u8], request: &EditRequest) -> Result<Vec<u8>, EditError> {
262        let _ = Self::plan(input, request)?;
263        let mut doc = DjVuDocumentMut::from_bytes(input).map_err(EditError::Parse)?;
264        for (operation, edit) in request.operations.iter().enumerate() {
265            apply_one(&mut doc, operation, edit)?;
266        }
267        doc.try_into_bytes().map_err(EditError::Serialize)
268    }
269
270    /// Validate, apply, and atomically replace `output` with the edited bytes.
271    ///
272    /// The complete request is validated before a sibling temporary file is
273    /// created. The temporary file is flushed and synced before rename, so a
274    /// failed request leaves an existing output untouched.
275    pub fn apply_to_path(
276        input: &Path,
277        output: &Path,
278        request: &EditRequest,
279    ) -> Result<(), EditError> {
280        let input_identity = fs::canonicalize(input)?;
281        let output_identity = if output.exists() {
282            fs::canonicalize(output)?
283        } else {
284            absolute_path(output)?
285        };
286        if input_identity == output_identity {
287            return Err(EditError::OutputAliasesInput);
288        }
289
290        let input_bytes = fs::read(input)?;
291        let output_bytes = Self::apply(&input_bytes, request)?;
292        // #696: validate the planned output before touching the destination.
293        // Error-severity findings abort the commit; warnings do not block.
294        if let Err(findings) = crate::validate::validate_planned_output(&output_bytes) {
295            let summary = findings
296                .iter()
297                .map(|finding| finding.code)
298                .collect::<Vec<_>>()
299                .join(", ");
300            return Err(EditError::InvalidPlannedOutput { summary });
301        }
302        let parent = output.parent().unwrap_or_else(|| Path::new("."));
303        fs::create_dir_all(parent)?;
304        let temp = create_sibling_temp(output)?;
305        let write_result = (|| -> Result<(), EditError> {
306            let mut file = OpenOptions::new().write(true).open(&temp)?;
307            file.write_all(&output_bytes)?;
308            file.sync_all()?;
309            fs::rename(&temp, output)?;
310            Ok(())
311        })();
312        if write_result.is_err() {
313            let _ = fs::remove_file(&temp);
314        }
315        write_result
316    }
317}
318
319fn validate_schema(request: &EditRequest) -> Result<(), EditError> {
320    if request.version != EDIT_SCHEMA_VERSION {
321        return Err(EditError::UnsupportedSchemaVersion {
322            found: request.version,
323        });
324    }
325    Ok(())
326}
327
328fn ensure_supported_shape(doc: &DjVuDocumentMut) -> Result<(), EditError> {
329    if doc.root_form_type() == Some(b"DJVM") && doc.page_count() == 0 {
330        return Err(EditError::UnsupportedDocumentShape {
331            detail: "indirect or empty DJVM documents require an external commit model",
332        });
333    }
334    Ok(())
335}
336
337fn apply_one(
338    doc: &mut DjVuDocumentMut,
339    operation: usize,
340    edit: &EditOperation,
341) -> Result<(), EditError> {
342    let page_count = doc.page_count();
343    let page = match edit {
344        EditOperation::SetText { page, .. }
345        | EditOperation::RemoveText { page }
346        | EditOperation::SetPageAnnotations { page, .. }
347        | EditOperation::RemovePageAnnotations { page }
348        | EditOperation::SetPageMetadata { page, .. }
349        | EditOperation::RemovePageMetadata { page } => Some(*page),
350        _ => None,
351    };
352    if let Some(page) = page
353        && page >= page_count
354    {
355        return Err(EditError::PageOutOfRange {
356            operation,
357            page,
358            page_count,
359        });
360    }
361
362    let result = match edit {
363        EditOperation::SetText { page, layer } => {
364            page_mut_for_operation(doc, operation, *page, edit.kind())?.set_text_layer(layer)
365        }
366        EditOperation::RemoveText { page } => {
367            page_mut_for_operation(doc, operation, *page, edit.kind())?.remove_text_layer();
368            Ok(())
369        }
370        EditOperation::SetPageAnnotations {
371            page,
372            annotation,
373            areas,
374        } => {
375            page_mut_for_operation(doc, operation, *page, edit.kind())?
376                .set_annotations(annotation, areas);
377            Ok(())
378        }
379        EditOperation::RemovePageAnnotations { page } => {
380            page_mut_for_operation(doc, operation, *page, edit.kind())?.remove_annotations();
381            Ok(())
382        }
383        EditOperation::SetPageMetadata { page, metadata } => {
384            page_mut_for_operation(doc, operation, *page, edit.kind())?.set_metadata(metadata);
385            Ok(())
386        }
387        EditOperation::RemovePageMetadata { page } => {
388            page_mut_for_operation(doc, operation, *page, edit.kind())?.remove_metadata();
389            Ok(())
390        }
391        EditOperation::SetDocumentMetadata { metadata } => {
392            doc.set_metadata(metadata);
393            Ok(())
394        }
395        EditOperation::RemoveDocumentMetadata => {
396            doc.remove_metadata();
397            Ok(())
398        }
399        EditOperation::SetBookmarks { bookmarks } => doc.set_bookmarks(bookmarks),
400        EditOperation::RemoveBookmarks => doc.set_bookmarks(&[]),
401    };
402    result.map_err(|source| EditError::OperationFailed {
403        operation,
404        kind: edit.kind(),
405        source,
406    })
407}
408
409fn page_mut_for_operation(
410    doc: &mut DjVuDocumentMut,
411    operation: usize,
412    page: usize,
413    kind: EditOperationKind,
414) -> Result<PageMut<'_>, EditError> {
415    doc.page_mut(page)
416        .map_err(|source| EditError::OperationFailed {
417            operation,
418            kind,
419            source,
420        })
421}
422
423fn absolute_path(path: &Path) -> Result<PathBuf, EditError> {
424    if path.is_absolute() {
425        return Ok(path.to_path_buf());
426    }
427    Ok(std::env::current_dir()?.join(path))
428}
429
430fn create_sibling_temp(output: &Path) -> Result<PathBuf, EditError> {
431    let parent = output.parent().unwrap_or_else(|| Path::new("."));
432    let file_name = output
433        .file_name()
434        .and_then(|name| name.to_str())
435        .unwrap_or("output");
436    let stamp = SystemTime::now()
437        .duration_since(UNIX_EPOCH)
438        .map(|duration| duration.as_nanos())
439        .unwrap_or_default();
440    for attempt in 0..32u32 {
441        let candidate = parent.join(format!(".{file_name}.djvu-rs-edit-{stamp}-{attempt}.tmp"));
442        match OpenOptions::new()
443            .write(true)
444            .create_new(true)
445            .open(&candidate)
446        {
447            Ok(file) => {
448                drop(file);
449                return Ok(candidate);
450            }
451            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
452            Err(error) => return Err(error.into()),
453        }
454    }
455    Err(std::io::Error::new(
456        std::io::ErrorKind::AlreadyExists,
457        "could not allocate a unique editor temporary file",
458    )
459    .into())
460}