1use 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
19pub const EDIT_SCHEMA_VERSION: u16 = 1;
21
22#[derive(Debug, Clone)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25pub struct EditRequest {
26 pub version: u16,
28 pub operations: Vec<EditOperation>,
30}
31
32impl EditRequest {
33 pub fn new(operations: Vec<EditOperation>) -> Self {
35 Self {
36 version: EDIT_SCHEMA_VERSION,
37 operations,
38 }
39 }
40}
41
42#[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 SetText { page: usize, layer: TextLayer },
49 RemoveText { page: usize },
51 SetPageAnnotations {
53 page: usize,
54 annotation: Annotation,
55 areas: Vec<MapArea>,
56 },
57 RemovePageAnnotations { page: usize },
59 SetPageMetadata { page: usize, metadata: DjVuMetadata },
61 RemovePageMetadata { page: usize },
63 SetDocumentMetadata { metadata: DjVuMetadata },
65 RemoveDocumentMetadata,
67 SetBookmarks { bookmarks: Vec<DjVuBookmark> },
69 RemoveBookmarks,
71}
72
73impl EditOperation {
74 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#[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#[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 Page { page: usize },
130 Document,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
136#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
137pub struct PlannedEdit {
138 pub operation: usize,
140 pub kind: EditOperationKind,
142 pub target: EditTarget,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
149pub struct EditPlan {
150 pub schema_version: u16,
152 pub page_count: usize,
154 pub operations: Vec<PlannedEdit>,
156}
157
158#[derive(Debug, thiserror::Error)]
160#[non_exhaustive]
161pub enum EditError {
162 #[error("unsupported editor schema version {found}; expected {EDIT_SCHEMA_VERSION}")]
164 UnsupportedSchemaVersion { found: u16 },
165
166 #[error("operation {operation} targets page {page}, but the document has {page_count} pages")]
168 PageOutOfRange {
169 operation: usize,
171 page: usize,
173 page_count: usize,
175 },
176
177 #[error("document shape is unsupported for this editor: {detail}")]
180 UnsupportedDocumentShape {
181 detail: &'static str,
183 },
184
185 #[error("operation {operation} ({kind:?}) failed: {source}")]
187 OperationFailed {
188 operation: usize,
190 kind: EditOperationKind,
192 #[source]
194 source: MutError,
195 },
196
197 #[error("could not open document for editing: {0}")]
199 Parse(#[source] MutError),
200
201 #[error("could not serialize edited document: {0}")]
203 Serialize(#[source] MutError),
204
205 #[error("editor input and output paths must differ")]
207 OutputAliasesInput,
208
209 #[error("edited output failed pre-commit validation: {summary}")]
213 InvalidPlannedOutput {
214 summary: String,
216 },
217
218 #[error("editor I/O error: {0}")]
220 Io(#[from] std::io::Error),
221}
222
223#[derive(Debug, Clone, Copy, Default)]
225pub struct DocumentEditor;
226
227impl DocumentEditor {
228 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 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 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 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}