use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::DjVuBookmark;
use crate::annotation::{Annotation, MapArea};
use crate::djvu_mut::{DjVuDocumentMut, MutError, PageMut};
use crate::metadata::DjVuMetadata;
use crate::text::TextLayer;
pub const EDIT_SCHEMA_VERSION: u16 = 1;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EditRequest {
pub version: u16,
pub operations: Vec<EditOperation>,
}
impl EditRequest {
pub fn new(operations: Vec<EditOperation>) -> Self {
Self {
version: EDIT_SCHEMA_VERSION,
operations,
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "op", rename_all = "snake_case"))]
pub enum EditOperation {
SetText { page: usize, layer: TextLayer },
RemoveText { page: usize },
SetPageAnnotations {
page: usize,
annotation: Annotation,
areas: Vec<MapArea>,
},
RemovePageAnnotations { page: usize },
SetPageMetadata { page: usize, metadata: DjVuMetadata },
RemovePageMetadata { page: usize },
SetDocumentMetadata { metadata: DjVuMetadata },
RemoveDocumentMetadata,
SetBookmarks { bookmarks: Vec<DjVuBookmark> },
RemoveBookmarks,
}
impl EditOperation {
pub fn kind(&self) -> EditOperationKind {
match self {
Self::SetText { .. } => EditOperationKind::SetText,
Self::RemoveText { .. } => EditOperationKind::RemoveText,
Self::SetPageAnnotations { .. } => EditOperationKind::SetPageAnnotations,
Self::RemovePageAnnotations { .. } => EditOperationKind::RemovePageAnnotations,
Self::SetPageMetadata { .. } => EditOperationKind::SetPageMetadata,
Self::RemovePageMetadata { .. } => EditOperationKind::RemovePageMetadata,
Self::SetDocumentMetadata { .. } => EditOperationKind::SetDocumentMetadata,
Self::RemoveDocumentMetadata => EditOperationKind::RemoveDocumentMetadata,
Self::SetBookmarks { .. } => EditOperationKind::SetBookmarks,
Self::RemoveBookmarks => EditOperationKind::RemoveBookmarks,
}
}
fn target(&self) -> EditTarget {
match self {
Self::SetText { page, .. }
| Self::RemoveText { page }
| Self::SetPageAnnotations { page, .. }
| Self::RemovePageAnnotations { page }
| Self::SetPageMetadata { page, .. }
| Self::RemovePageMetadata { page } => EditTarget::Page { page: *page },
Self::SetDocumentMetadata { .. }
| Self::RemoveDocumentMetadata
| Self::SetBookmarks { .. }
| Self::RemoveBookmarks => EditTarget::Document,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum EditOperationKind {
SetText,
RemoveText,
SetPageAnnotations,
RemovePageAnnotations,
SetPageMetadata,
RemovePageMetadata,
SetDocumentMetadata,
RemoveDocumentMetadata,
SetBookmarks,
RemoveBookmarks,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum EditTarget {
Page { page: usize },
Document,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PlannedEdit {
pub operation: usize,
pub kind: EditOperationKind,
pub target: EditTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EditPlan {
pub schema_version: u16,
pub page_count: usize,
pub operations: Vec<PlannedEdit>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum EditError {
#[error("unsupported editor schema version {found}; expected {EDIT_SCHEMA_VERSION}")]
UnsupportedSchemaVersion { found: u16 },
#[error("operation {operation} targets page {page}, but the document has {page_count} pages")]
PageOutOfRange {
operation: usize,
page: usize,
page_count: usize,
},
#[error("document shape is unsupported for this editor: {detail}")]
UnsupportedDocumentShape {
detail: &'static str,
},
#[error("operation {operation} ({kind:?}) failed: {source}")]
OperationFailed {
operation: usize,
kind: EditOperationKind,
#[source]
source: MutError,
},
#[error("could not open document for editing: {0}")]
Parse(#[source] MutError),
#[error("could not serialize edited document: {0}")]
Serialize(#[source] MutError),
#[error("editor input and output paths must differ")]
OutputAliasesInput,
#[error("edited output failed pre-commit validation: {summary}")]
InvalidPlannedOutput {
summary: String,
},
#[error("editor I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DocumentEditor;
impl DocumentEditor {
pub fn plan(input: &[u8], request: &EditRequest) -> Result<EditPlan, EditError> {
let doc = DjVuDocumentMut::from_bytes(input).map_err(EditError::Parse)?;
validate_schema(request)?;
ensure_supported_shape(&doc)?;
let plan = EditPlan {
schema_version: request.version,
page_count: doc.page_count(),
operations: request
.operations
.iter()
.enumerate()
.map(|(operation, edit)| PlannedEdit {
operation,
kind: edit.kind(),
target: edit.target(),
})
.collect(),
};
let mut probe = doc.clone();
for (operation, edit) in request.operations.iter().enumerate() {
apply_one(&mut probe, operation, edit)?;
}
Ok(plan)
}
pub fn apply(input: &[u8], request: &EditRequest) -> Result<Vec<u8>, EditError> {
let _ = Self::plan(input, request)?;
let mut doc = DjVuDocumentMut::from_bytes(input).map_err(EditError::Parse)?;
for (operation, edit) in request.operations.iter().enumerate() {
apply_one(&mut doc, operation, edit)?;
}
doc.try_into_bytes().map_err(EditError::Serialize)
}
pub fn apply_to_path(
input: &Path,
output: &Path,
request: &EditRequest,
) -> Result<(), EditError> {
let input_identity = fs::canonicalize(input)?;
let output_identity = if output.exists() {
fs::canonicalize(output)?
} else {
absolute_path(output)?
};
if input_identity == output_identity {
return Err(EditError::OutputAliasesInput);
}
let input_bytes = fs::read(input)?;
let output_bytes = Self::apply(&input_bytes, request)?;
if let Err(findings) = crate::validate::validate_planned_output(&output_bytes) {
let summary = findings
.iter()
.map(|finding| finding.code)
.collect::<Vec<_>>()
.join(", ");
return Err(EditError::InvalidPlannedOutput { summary });
}
let parent = output.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)?;
let temp = create_sibling_temp(output)?;
let write_result = (|| -> Result<(), EditError> {
let mut file = OpenOptions::new().write(true).open(&temp)?;
file.write_all(&output_bytes)?;
file.sync_all()?;
fs::rename(&temp, output)?;
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(&temp);
}
write_result
}
}
fn validate_schema(request: &EditRequest) -> Result<(), EditError> {
if request.version != EDIT_SCHEMA_VERSION {
return Err(EditError::UnsupportedSchemaVersion {
found: request.version,
});
}
Ok(())
}
fn ensure_supported_shape(doc: &DjVuDocumentMut) -> Result<(), EditError> {
if doc.root_form_type() == Some(b"DJVM") && doc.page_count() == 0 {
return Err(EditError::UnsupportedDocumentShape {
detail: "indirect or empty DJVM documents require an external commit model",
});
}
Ok(())
}
fn apply_one(
doc: &mut DjVuDocumentMut,
operation: usize,
edit: &EditOperation,
) -> Result<(), EditError> {
let page_count = doc.page_count();
let page = match edit {
EditOperation::SetText { page, .. }
| EditOperation::RemoveText { page }
| EditOperation::SetPageAnnotations { page, .. }
| EditOperation::RemovePageAnnotations { page }
| EditOperation::SetPageMetadata { page, .. }
| EditOperation::RemovePageMetadata { page } => Some(*page),
_ => None,
};
if let Some(page) = page
&& page >= page_count
{
return Err(EditError::PageOutOfRange {
operation,
page,
page_count,
});
}
let result = match edit {
EditOperation::SetText { page, layer } => {
page_mut_for_operation(doc, operation, *page, edit.kind())?.set_text_layer(layer)
}
EditOperation::RemoveText { page } => {
page_mut_for_operation(doc, operation, *page, edit.kind())?.remove_text_layer();
Ok(())
}
EditOperation::SetPageAnnotations {
page,
annotation,
areas,
} => {
page_mut_for_operation(doc, operation, *page, edit.kind())?
.set_annotations(annotation, areas);
Ok(())
}
EditOperation::RemovePageAnnotations { page } => {
page_mut_for_operation(doc, operation, *page, edit.kind())?.remove_annotations();
Ok(())
}
EditOperation::SetPageMetadata { page, metadata } => {
page_mut_for_operation(doc, operation, *page, edit.kind())?.set_metadata(metadata);
Ok(())
}
EditOperation::RemovePageMetadata { page } => {
page_mut_for_operation(doc, operation, *page, edit.kind())?.remove_metadata();
Ok(())
}
EditOperation::SetDocumentMetadata { metadata } => {
doc.set_metadata(metadata);
Ok(())
}
EditOperation::RemoveDocumentMetadata => {
doc.remove_metadata();
Ok(())
}
EditOperation::SetBookmarks { bookmarks } => doc.set_bookmarks(bookmarks),
EditOperation::RemoveBookmarks => doc.set_bookmarks(&[]),
};
result.map_err(|source| EditError::OperationFailed {
operation,
kind: edit.kind(),
source,
})
}
fn page_mut_for_operation(
doc: &mut DjVuDocumentMut,
operation: usize,
page: usize,
kind: EditOperationKind,
) -> Result<PageMut<'_>, EditError> {
doc.page_mut(page)
.map_err(|source| EditError::OperationFailed {
operation,
kind,
source,
})
}
fn absolute_path(path: &Path) -> Result<PathBuf, EditError> {
if path.is_absolute() {
return Ok(path.to_path_buf());
}
Ok(std::env::current_dir()?.join(path))
}
fn create_sibling_temp(output: &Path) -> Result<PathBuf, EditError> {
let parent = output.parent().unwrap_or_else(|| Path::new("."));
let file_name = output
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("output");
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
for attempt in 0..32u32 {
let candidate = parent.join(format!(".{file_name}.djvu-rs-edit-{stamp}-{attempt}.tmp"));
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(file) => {
drop(file);
return Ok(candidate);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"could not allocate a unique editor temporary file",
)
.into())
}