mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 19:19:10 -06:00
1311 lines
54 KiB
Rust
1311 lines
54 KiB
Rust
//! printcraft-engine — the façade every frontend talks to (architecture §4).
|
||
//!
|
||
//! Holds open documents, their edit history and the tool catalogue. Frontends never touch the
|
||
//! parsing, rendering or editing crates directly.
|
||
//!
|
||
//! **Editing model.** Each document keeps a `printcraft_cos::Document` (the object graph with a
|
||
//! copy-on-write overlay of edits). An edit runs on a clone, and on success the previous state is
|
||
//! pushed onto the undo stack (clones share all unchanged data, so this is cheap). After every
|
||
//! edit the *working file* is produced by an incremental write — original bytes plus one
|
||
//! appended revision — and the view is refreshed from it, so what you see is exactly what Save
|
||
//! will write. Saving rebases onto the written bytes, so the next save appends only new edits.
|
||
|
||
pub mod catalog;
|
||
pub mod commands;
|
||
pub mod export;
|
||
pub mod links;
|
||
|
||
pub use printcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges};
|
||
|
||
/// One file produced by a split: (1-based first page, last page, PDF bytes).
|
||
pub use printcraft_organize::LabelStyle;
|
||
|
||
pub use printcraft_cos::Algorithm;
|
||
pub use printcraft_edit::{Background, HeaderFooter, MarkKind, Watermark};
|
||
pub use printcraft_forms::{Field as FormField, FieldKind as FormFieldKind, FieldValue, Widget as FormWidget, flags as field_flags};
|
||
|
||
/// Comment geometry helpers (text-box line breaking) for frontends.
|
||
pub use printcraft_annot::appearance as annot_text;
|
||
pub use printcraft_annot::{FillMark, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style};
|
||
|
||
pub type SplitPart = (usize, usize, Arc<Vec<u8>>);
|
||
|
||
use std::sync::Arc;
|
||
|
||
use printcraft_cos::{SaveOptions, write_full, write_incremental};
|
||
use printcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect};
|
||
|
||
/// Stable identifier of an open document within a session.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
pub struct DocId(pub u64);
|
||
|
||
/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap.
|
||
const MAX_UNDO: usize = 100;
|
||
|
||
/// The passwords that go with one state of a document (protection can change them).
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
struct Keys {
|
||
/// What the renderer and inspector open the working file with (the user password).
|
||
render: Option<String>,
|
||
/// What the editor re-opens a saved file with: the strongest password known (owner if any).
|
||
reopen: Option<String>,
|
||
}
|
||
|
||
/// What an edit can change, and so what the view data must be rebuilt from.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum Scope {
|
||
/// Anything: re-inspect the whole document.
|
||
Full,
|
||
/// Only comments: re-read the comment list from the object graph.
|
||
Comments,
|
||
/// Only form field values (and their widget appearances).
|
||
Form,
|
||
}
|
||
|
||
fn scope_of(edit: &Edit) -> Scope {
|
||
match edit {
|
||
Edit::AddAnnotation(_)
|
||
| Edit::DeleteAnnotation { .. }
|
||
| Edit::SetAnnotationContents { .. }
|
||
| Edit::ReplyToAnnotation { .. }
|
||
| Edit::SetAnnotationStatus { .. }
|
||
| Edit::MoveAnnotation { .. }
|
||
| Edit::ResizeAnnotation { .. }
|
||
| Edit::StyleAnnotation { .. } => Scope::Comments,
|
||
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } => Scope::Form,
|
||
Edit::Batch { edits, .. } => {
|
||
let mut scopes = edits.iter().map(scope_of);
|
||
let first = scopes.next().unwrap_or(Scope::Full);
|
||
if scopes.all(|s| s == first) { first } else { Scope::Full }
|
||
}
|
||
_ => Scope::Full,
|
||
}
|
||
}
|
||
|
||
/// One undo/redo step: its label, the document state, its passwords, and what it changed.
|
||
type Snapshot = (String, printcraft_cos::Document, Keys, Scope);
|
||
|
||
/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted).
|
||
#[derive(Clone)]
|
||
struct Editor {
|
||
cos: printcraft_cos::Document,
|
||
undo: Vec<Snapshot>,
|
||
redo: Vec<Snapshot>,
|
||
keys: Keys,
|
||
}
|
||
|
||
pub struct Document {
|
||
pub id: DocId,
|
||
pub name: String,
|
||
pub path: Option<String>,
|
||
/// The working file: what Save writes and what is displayed.
|
||
pub bytes: Arc<Vec<u8>>,
|
||
pub info: DocInfo,
|
||
pub renderer: RenderPool,
|
||
/// The password the document was opened with (needed to read attachments, etc.).
|
||
pub password: Option<String>,
|
||
/// `true` when there are edits that have not been saved.
|
||
pub dirty: bool,
|
||
/// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it.
|
||
generation: u64,
|
||
/// The generation last handed out by `autosave_snapshots`.
|
||
snapshot_generation: u64,
|
||
/// Why the document cannot be edited (e.g. encryption), if so.
|
||
pub read_only_reason: Option<String>,
|
||
/// Interactive form fields of the current state (empty without a form).
|
||
pub form: Arc<Vec<printcraft_forms::Field>>,
|
||
/// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove.
|
||
pub marks: Vec<MarkKind>,
|
||
editor: Option<Editor>,
|
||
config: RenderConfig,
|
||
}
|
||
|
||
impl Document {
|
||
pub fn can_undo(&self) -> Option<&str> {
|
||
self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l.as_str())
|
||
}
|
||
|
||
pub fn can_redo(&self) -> Option<&str> {
|
||
self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l.as_str())
|
||
}
|
||
|
||
pub fn editable(&self) -> bool {
|
||
self.editor.is_some()
|
||
}
|
||
|
||
/// What the opening password allows; `None` when the document is not encrypted.
|
||
pub fn permissions(&self) -> Option<printcraft_cos::Permissions> {
|
||
self.editor.as_ref().and_then(|e| e.cos.permissions())
|
||
}
|
||
|
||
/// Page changes (insert, delete, rotate, move, extract) are allowed.
|
||
pub fn allows_assembly(&self) -> bool {
|
||
self.editable() && self.permissions().is_none_or(|p| p.assemble())
|
||
}
|
||
|
||
/// Changes to content and document information are allowed.
|
||
pub fn allows_modification(&self) -> bool {
|
||
self.editable() && self.permissions().is_none_or(|p| p.modify())
|
||
}
|
||
|
||
/// Filling in form fields is allowed (Table 22, bits 6 and 9).
|
||
pub fn allows_form_filling(&self) -> bool {
|
||
self.editable() && self.permissions().is_none_or(|p| p.fill_forms())
|
||
}
|
||
|
||
/// Adding and changing comments is allowed (Table 22, bit 6).
|
||
pub fn allows_annotation(&self) -> bool {
|
||
self.editable() && self.permissions().is_none_or(|p| p.annotate())
|
||
}
|
||
|
||
/// The document's security may be changed (Protect, Remove security): it is editable and,
|
||
/// if encrypted, was opened with the owner password.
|
||
pub fn allows_security_change(&self) -> bool {
|
||
self.editable() && self.permissions().is_none_or(|p| unrestricted(&p))
|
||
}
|
||
|
||
/// A summary of the document's security for Document Properties ▸ Security, including
|
||
/// protection applied in this session (written by the next save).
|
||
pub fn security_summary(&self) -> Option<SecuritySummary> {
|
||
let editor = self.editor.as_ref()?;
|
||
let h = editor.cos.output_handler()?;
|
||
let d = h.dict();
|
||
let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m);
|
||
let method = match (d.v, stream) {
|
||
(1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit",
|
||
(1..=3, _) | (_, Some(printcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit",
|
||
(_, Some(printcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit",
|
||
(_, Some(printcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit",
|
||
_ => "Attachments only",
|
||
};
|
||
let pending = editor.cos.encryption_changed();
|
||
let permissions = if pending { printcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() };
|
||
Some(SecuritySummary { method: method.into(), owner: h.auth() == printcraft_cos::Auth::Owner && !pending, permissions, pending })
|
||
}
|
||
|
||
/// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes.
|
||
pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> {
|
||
self.editor.as_ref().and_then(|e| printcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default()
|
||
}
|
||
|
||
/// Current value of a document-information entry (Title, Author, …).
|
||
pub fn info_value(&self, key: &str) -> Option<String> {
|
||
self.editor.as_ref().and_then(|e| printcraft_organize::info(&e.cos, key))
|
||
}
|
||
|
||
/// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices).
|
||
pub fn repair_log(&self) -> Vec<String> {
|
||
self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default()
|
||
}
|
||
}
|
||
|
||
fn render_threads() -> usize {
|
||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
|
||
}
|
||
|
||
/// A document's working file captured for crash recovery.
|
||
#[derive(Clone, Debug)]
|
||
pub struct RecoverySnapshot {
|
||
pub doc: DocId,
|
||
pub name: String,
|
||
pub path: Option<String>,
|
||
pub bytes: Arc<Vec<u8>>,
|
||
/// The snapshot is encrypted (recovering it asks for the password again).
|
||
pub encrypted: bool,
|
||
}
|
||
|
||
/// Document Properties ▸ Security.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct SecuritySummary {
|
||
/// "AES, 256-bit" etc.
|
||
pub method: String,
|
||
/// Opened with the owner password (no restrictions apply).
|
||
pub owner: bool,
|
||
/// For pending protection: the restrictions as they will apply to others.
|
||
pub permissions: printcraft_cos::Permissions,
|
||
/// Set in this session; written by the next save.
|
||
pub pending: bool,
|
||
}
|
||
|
||
/// What printing a protected document allows (Acrobat: Printing allowed).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Printing {
|
||
None,
|
||
/// "Low Resolution (150 dpi)".
|
||
Low,
|
||
High,
|
||
}
|
||
|
||
/// What changes a protected document allows (Acrobat: Changes allowed).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Changes {
|
||
None,
|
||
/// "Inserting, deleting, and rotating pages".
|
||
Pages,
|
||
/// "Filling in form fields and signing existing signature fields".
|
||
FillSign,
|
||
/// "Commenting, filling in form fields, and signing existing signature fields".
|
||
CommentFillSign,
|
||
/// "Any except extracting pages".
|
||
AnyExceptExtract,
|
||
}
|
||
|
||
/// Password protection to apply (Protect Using Password and its Advanced options).
|
||
#[derive(Clone, PartialEq)]
|
||
pub struct Protection {
|
||
/// Required to open the document (the user password).
|
||
pub open_password: Option<String>,
|
||
/// Required to change security and lift the restrictions below (the owner password).
|
||
pub permissions_password: Option<String>,
|
||
pub printing: Printing,
|
||
pub changes: Changes,
|
||
/// "Enable copying of text, images, and other content".
|
||
pub copy: bool,
|
||
/// "Enable text access for screen reader devices".
|
||
pub accessibility: bool,
|
||
/// Compatibility level; AES-256 (Acrobat X and later) by default.
|
||
pub algorithm: printcraft_cos::Algorithm,
|
||
/// `false`: "Encrypt all document contents except metadata".
|
||
pub encrypt_metadata: bool,
|
||
}
|
||
|
||
impl std::fmt::Debug for Protection {
|
||
// Never print passwords (edits end up in logs and error messages).
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.debug_struct("Protection")
|
||
.field("open_password", &self.open_password.as_ref().map(|_| "…"))
|
||
.field("permissions_password", &self.permissions_password.as_ref().map(|_| "…"))
|
||
.field("printing", &self.printing)
|
||
.field("changes", &self.changes)
|
||
.field("copy", &self.copy)
|
||
.field("accessibility", &self.accessibility)
|
||
.field("algorithm", &self.algorithm)
|
||
.field("encrypt_metadata", &self.encrypt_metadata)
|
||
.finish()
|
||
}
|
||
}
|
||
|
||
impl Default for Protection {
|
||
/// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying.
|
||
fn default() -> Self {
|
||
Self {
|
||
open_password: None,
|
||
permissions_password: None,
|
||
printing: Printing::High,
|
||
changes: Changes::None,
|
||
copy: false,
|
||
accessibility: true,
|
||
algorithm: printcraft_cos::Algorithm::Aes256,
|
||
encrypt_metadata: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Protection {
|
||
/// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices.
|
||
pub fn permission_bits(&self) -> i32 {
|
||
let bit = |n: u32| 1i32 << (n - 1);
|
||
if self.permissions_password.is_none() {
|
||
return -1; // nothing restricted without a permissions password
|
||
}
|
||
let mut p = 0;
|
||
match self.printing {
|
||
Printing::None => {}
|
||
Printing::Low => p |= bit(3),
|
||
Printing::High => p |= bit(3) | bit(12),
|
||
}
|
||
p |= match self.changes {
|
||
Changes::None => 0,
|
||
Changes::Pages => bit(11),
|
||
Changes::FillSign => bit(9),
|
||
Changes::CommentFillSign => bit(6) | bit(9),
|
||
Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9),
|
||
};
|
||
if self.copy {
|
||
p |= bit(5) | bit(10);
|
||
}
|
||
if self.accessibility {
|
||
p |= bit(10);
|
||
}
|
||
p
|
||
}
|
||
|
||
fn validate(&self) -> Result<(), EditError> {
|
||
let bad = |m: &str| Err(EditError::Protection(m.to_string()));
|
||
match (&self.open_password, &self.permissions_password) {
|
||
(None, None) => return bad("enter a password to open the document, a permissions password, or both"),
|
||
(Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"),
|
||
_ => {}
|
||
}
|
||
if [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.is_empty()) {
|
||
return bad("passwords can't be empty");
|
||
}
|
||
if self.algorithm != printcraft_cos::Algorithm::Aes256
|
||
&& [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c)))
|
||
{
|
||
return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)");
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// Edits that can be applied to a document. Page indices are 0-based.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum Edit {
|
||
RotatePages {
|
||
pages: Vec<usize>,
|
||
degrees: i64,
|
||
},
|
||
DeletePages {
|
||
pages: Vec<usize>,
|
||
},
|
||
MovePages {
|
||
pages: Vec<usize>,
|
||
to: usize,
|
||
},
|
||
InsertBlankPage {
|
||
at: usize,
|
||
width: f64,
|
||
height: f64,
|
||
},
|
||
/// Copies of `pages` inserted after the last of them.
|
||
DuplicatePages {
|
||
pages: Vec<usize>,
|
||
},
|
||
/// Set Page Boxes / Crop: set (or reset) one box on `pages`.
|
||
SetPageBox {
|
||
pages: Vec<usize>,
|
||
which: PageBox,
|
||
spec: BoxSpec,
|
||
},
|
||
SetInfo {
|
||
key: String,
|
||
value: String,
|
||
},
|
||
/// Insert pages from another PDF (all pages when `pages` is `None`) at position `at`.
|
||
InsertPagesFrom {
|
||
name: String,
|
||
bytes: Arc<Vec<u8>>,
|
||
pages: Option<Vec<usize>>,
|
||
at: usize,
|
||
},
|
||
/// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level).
|
||
AddBookmark {
|
||
parent: Vec<usize>,
|
||
index: usize,
|
||
title: String,
|
||
page: usize,
|
||
},
|
||
RenameBookmark {
|
||
path: Vec<usize>,
|
||
title: String,
|
||
},
|
||
/// Delete a bookmark and the bookmarks under it.
|
||
DeleteBookmark {
|
||
path: Vec<usize>,
|
||
},
|
||
/// Move a bookmark to child `index` of `to_parent` (indices after removing it).
|
||
MoveBookmark {
|
||
from: Vec<usize>,
|
||
to_parent: Vec<usize>,
|
||
index: usize,
|
||
},
|
||
/// Point a bookmark at another page.
|
||
SetBookmarkPage {
|
||
path: Vec<usize>,
|
||
page: usize,
|
||
},
|
||
/// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels.
|
||
NumberPages {
|
||
from: usize,
|
||
to: usize,
|
||
style: printcraft_organize::LabelStyle,
|
||
prefix: String,
|
||
first: u32,
|
||
},
|
||
/// Add a comment (sticky note, highlight, shape, drawing, text box…).
|
||
AddAnnotation(NewAnnotation),
|
||
/// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies.
|
||
DeleteAnnotation {
|
||
page: usize,
|
||
index: usize,
|
||
},
|
||
SetAnnotationContents {
|
||
page: usize,
|
||
index: usize,
|
||
text: String,
|
||
},
|
||
ReplyToAnnotation {
|
||
page: usize,
|
||
index: usize,
|
||
text: String,
|
||
author: String,
|
||
},
|
||
/// Acrobat's "Set status" (a state reply by `author`).
|
||
SetAnnotationStatus {
|
||
page: usize,
|
||
index: usize,
|
||
state: ReviewState,
|
||
author: String,
|
||
},
|
||
MoveAnnotation {
|
||
page: usize,
|
||
index: usize,
|
||
dx: f64,
|
||
dy: f64,
|
||
},
|
||
/// Resize a rectangle, oval or text box.
|
||
ResizeAnnotation {
|
||
page: usize,
|
||
index: usize,
|
||
rect: [f64; 4],
|
||
},
|
||
StyleAnnotation {
|
||
page: usize,
|
||
index: usize,
|
||
color: Option<Rgb>,
|
||
opacity: Option<f64>,
|
||
width: Option<f64>,
|
||
},
|
||
/// Fill in a form field.
|
||
SetFieldValue {
|
||
name: String,
|
||
value: FieldValue,
|
||
},
|
||
/// Clear form: the named fields (all when `None`) back to their defaults.
|
||
ResetForm {
|
||
names: Option<Vec<String>>,
|
||
},
|
||
/// Add a header and footer (with `replace`, existing ones on those pages go first).
|
||
AddHeaderFooter {
|
||
pages: Vec<usize>,
|
||
settings: HeaderFooter,
|
||
replace: bool,
|
||
},
|
||
AddWatermark {
|
||
pages: Vec<usize>,
|
||
settings: Watermark,
|
||
replace: bool,
|
||
},
|
||
AddBackground {
|
||
pages: Vec<usize>,
|
||
settings: Background,
|
||
replace: bool,
|
||
},
|
||
/// Remove every mark of a kind from every page.
|
||
RemoveMarks {
|
||
kind: MarkKind,
|
||
},
|
||
/// Protect with passwords and permissions (written by the next save, which is a full rewrite).
|
||
Protect(Protection),
|
||
/// Remove password security (needs the owner password).
|
||
RemoveProtection,
|
||
/// Several edits applied as one undoable step (all or nothing).
|
||
Batch {
|
||
label: String,
|
||
edits: Vec<Edit>,
|
||
},
|
||
}
|
||
|
||
impl Edit {
|
||
/// Label for the Edit menu and history ("Undo Rotate pages").
|
||
pub fn label(&self) -> String {
|
||
match self {
|
||
Edit::RotatePages { pages, .. } => plural("Rotate page", pages.len()),
|
||
Edit::DeletePages { pages } => plural("Delete page", pages.len()),
|
||
Edit::MovePages { pages, .. } => plural("Move page", pages.len()),
|
||
Edit::InsertBlankPage { .. } => "Insert blank page".into(),
|
||
Edit::DuplicatePages { pages } => plural("Duplicate page", pages.len()),
|
||
Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", pages.len()),
|
||
Edit::SetPageBox { .. } => "Set page boxes".into(),
|
||
Edit::SetInfo { key, .. } => format!("Change {key}"),
|
||
Edit::InsertPagesFrom { name, .. } => format!("Insert pages from {name}"),
|
||
Edit::AddBookmark { .. } => "Add bookmark".into(),
|
||
Edit::RenameBookmark { .. } => "Rename bookmark".into(),
|
||
Edit::DeleteBookmark { .. } => "Delete bookmark".into(),
|
||
Edit::MoveBookmark { .. } => "Move bookmark".into(),
|
||
Edit::SetBookmarkPage { .. } => "Set bookmark destination".into(),
|
||
Edit::NumberPages { .. } => "Number pages".into(),
|
||
Edit::AddAnnotation(a) => format!("Add {}", annotation_noun(&a.shape)),
|
||
Edit::DeleteAnnotation { .. } => "Delete comment".into(),
|
||
Edit::SetAnnotationContents { .. } => "Edit comment".into(),
|
||
Edit::ReplyToAnnotation { .. } => "Reply".into(),
|
||
Edit::SetAnnotationStatus { state, .. } => format!("Set status {}", state.name()),
|
||
Edit::MoveAnnotation { .. } => "Move comment".into(),
|
||
Edit::ResizeAnnotation { .. } => "Resize comment".into(),
|
||
Edit::StyleAnnotation { .. } => "Change comment properties".into(),
|
||
Edit::SetFieldValue { name, .. } => format!("Fill in {name}"),
|
||
Edit::ResetForm { .. } => "Clear form".into(),
|
||
Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(),
|
||
Edit::AddHeaderFooter { .. } => "Update header & footer".into(),
|
||
Edit::AddWatermark { replace: false, .. } => "Add watermark".into(),
|
||
Edit::AddWatermark { .. } => "Update watermark".into(),
|
||
Edit::AddBackground { replace: false, .. } => "Add background".into(),
|
||
Edit::AddBackground { .. } => "Update background".into(),
|
||
Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => "Remove header & footer".into(),
|
||
Edit::RemoveMarks { kind: MarkKind::Watermark } => "Remove watermark".into(),
|
||
Edit::RemoveMarks { kind: MarkKind::Background } => "Remove background".into(),
|
||
Edit::Protect(_) => "Protect with password".into(),
|
||
Edit::RemoveProtection => "Remove security".into(),
|
||
Edit::Batch { label, .. } => label.clone(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What the Edit menu calls a new comment ("Undo Add highlight").
|
||
fn annotation_noun(s: &Shape) -> &'static str {
|
||
match s {
|
||
Shape::Note { .. } => "sticky note",
|
||
Shape::TextMarkup { kind: Markup::Highlight, .. } => "highlight",
|
||
Shape::TextMarkup { kind: Markup::Underline, .. } => "underline",
|
||
Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "strikethrough",
|
||
Shape::TextMarkup { kind: Markup::Squiggly, .. } => "squiggly underline",
|
||
Shape::Rectangle { .. } => "rectangle",
|
||
Shape::Oval { .. } => "oval",
|
||
Shape::Line { arrow: true, .. } => "arrow",
|
||
Shape::Line { .. } => "line",
|
||
Shape::Ink { .. } => "drawing",
|
||
Shape::TextBox { .. } => "text box",
|
||
Shape::Typewriter { .. } => "text",
|
||
Shape::Mark { mark: FillMark::Check, .. } => "checkmark",
|
||
Shape::Mark { mark: FillMark::Cross, .. } => "cross",
|
||
Shape::Mark { mark: FillMark::Dot, .. } => "dot",
|
||
Shape::Mark { mark: FillMark::Line, .. } => "line",
|
||
Shape::Signature { .. } => "signature",
|
||
}
|
||
}
|
||
|
||
/// Opened as owner, or nothing is restricted (no permissions password was set): security may
|
||
/// be changed, as in Acrobat.
|
||
fn unrestricted(p: &printcraft_cos::Permissions) -> bool {
|
||
const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12
|
||
p.owner || p.bits & ALL == ALL
|
||
}
|
||
|
||
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
|
||
fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), EditError> {
|
||
match edit {
|
||
Edit::RotatePages { .. }
|
||
| Edit::DeletePages { .. }
|
||
| Edit::MovePages { .. }
|
||
| Edit::InsertBlankPage { .. }
|
||
| Edit::DuplicatePages { .. }
|
||
| Edit::SetPageBox { .. }
|
||
| Edit::InsertPagesFrom { .. }
|
||
// "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22).
|
||
| Edit::AddBookmark { .. }
|
||
| Edit::RenameBookmark { .. }
|
||
| Edit::DeleteBookmark { .. }
|
||
| Edit::MoveBookmark { .. }
|
||
| Edit::SetBookmarkPage { .. }
|
||
| Edit::NumberPages { .. } => {
|
||
if p.assemble() {
|
||
Ok(())
|
||
} else {
|
||
Err(EditError::NotPermitted("page changes"))
|
||
}
|
||
}
|
||
Edit::AddAnnotation(_)
|
||
| Edit::DeleteAnnotation { .. }
|
||
| Edit::SetAnnotationContents { .. }
|
||
| Edit::ReplyToAnnotation { .. }
|
||
| Edit::SetAnnotationStatus { .. }
|
||
| Edit::MoveAnnotation { .. }
|
||
| Edit::ResizeAnnotation { .. }
|
||
| Edit::StyleAnnotation { .. } => {
|
||
if p.annotate() {
|
||
Ok(())
|
||
} else {
|
||
Err(EditError::NotPermitted("comments"))
|
||
}
|
||
}
|
||
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } => {
|
||
if p.fill_forms() {
|
||
Ok(())
|
||
} else {
|
||
Err(EditError::NotPermitted("filling in form fields"))
|
||
}
|
||
}
|
||
Edit::Protect(_) | Edit::RemoveProtection => {
|
||
if unrestricted(p) {
|
||
Ok(())
|
||
} else {
|
||
Err(EditError::NotPermitted("changing security"))
|
||
}
|
||
}
|
||
Edit::SetInfo { .. } | Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } => {
|
||
if p.modify() {
|
||
Ok(())
|
||
} else {
|
||
Err(EditError::NotPermitted("changes to the document"))
|
||
}
|
||
}
|
||
Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)),
|
||
}
|
||
}
|
||
|
||
/// Dates and unique ids stamped onto what an edit creates.
|
||
struct EditCtx {
|
||
date: Option<String>,
|
||
/// Today in local time, for date tokens.
|
||
today: (i64, u32, u32),
|
||
seed: u64,
|
||
count: u64,
|
||
}
|
||
|
||
impl EditCtx {
|
||
fn new(now: Option<i64>, salt: u64) -> Self {
|
||
let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt;
|
||
if let Some(t) = now {
|
||
seed ^= (t as u64).rotate_left(17);
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if now.is_some() {
|
||
// Real clock: mix in sub-second time so ids from two sessions don't collide.
|
||
seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32;
|
||
}
|
||
Self { date: now.map(printcraft_cos::pdf_date), today: (1970, 1, 1), seed, count: 0 }
|
||
}
|
||
|
||
/// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the
|
||
/// operating system, so no extra dependency is needed.
|
||
fn entropy(&mut self) -> [u8; 32] {
|
||
use std::hash::{BuildHasher, Hasher};
|
||
let mut out = [0u8; 32];
|
||
for (i, chunk) in out.chunks_mut(8).enumerate() {
|
||
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
||
h.write_u64(self.seed ^ i as u64);
|
||
h.write_u64(self.count);
|
||
self.count += 1;
|
||
chunk.copy_from_slice(&h.finish().to_le_bytes());
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream.
|
||
fn meta(&mut self) -> printcraft_annot::Meta {
|
||
let mut next = || {
|
||
self.count += 1;
|
||
let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15));
|
||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||
z ^ (z >> 31)
|
||
};
|
||
let (a, b) = (next(), next());
|
||
let id = format!(
|
||
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
|
||
a >> 32,
|
||
(a >> 16) & 0xffff,
|
||
a & 0xfff,
|
||
0x8000 | (b >> 48) & 0x3fff,
|
||
b & 0xffff_ffff_ffff
|
||
);
|
||
printcraft_annot::Meta { date: self.date.clone(), id }
|
||
}
|
||
}
|
||
|
||
/// Perform an edit on a working copy (the caller discards it on error).
|
||
fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> {
|
||
match edit {
|
||
Edit::RotatePages { pages, degrees } => printcraft_organize::rotate_pages(doc, pages, *degrees)?,
|
||
Edit::DeletePages { pages } => printcraft_organize::delete_pages(doc, pages)?,
|
||
Edit::MovePages { pages, to } => printcraft_organize::move_pages(doc, pages, *to)?,
|
||
Edit::InsertBlankPage { at, width, height } => {
|
||
printcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
|
||
}
|
||
Edit::SetInfo { key, value } => printcraft_organize::set_info(doc, key, value)?,
|
||
Edit::DuplicatePages { pages } => printcraft_organize::duplicate_pages(doc, pages)?,
|
||
Edit::SetPageBox { pages, which, spec } => printcraft_organize::set_page_box(doc, pages, *which, *spec)?,
|
||
Edit::InsertPagesFrom { name, bytes, pages, at } => {
|
||
let src = open_source(name, bytes)?;
|
||
let pages = match pages {
|
||
Some(p) => p.clone(),
|
||
None => (0..printcraft_organize::page_count(&src)?).collect(),
|
||
};
|
||
printcraft_organize::import_pages(doc, &src, &pages, *at)?;
|
||
}
|
||
Edit::AddBookmark { parent, index, title, page } => {
|
||
printcraft_organize::add_bookmark(doc, parent, *index, title, *page)?;
|
||
}
|
||
Edit::RenameBookmark { path, title } => printcraft_organize::rename_bookmark(doc, path, title)?,
|
||
Edit::DeleteBookmark { path } => printcraft_organize::delete_bookmark(doc, path)?,
|
||
Edit::MoveBookmark { from, to_parent, index } => {
|
||
printcraft_organize::move_bookmark(doc, from, to_parent, *index)?;
|
||
}
|
||
Edit::SetBookmarkPage { path, page } => printcraft_organize::set_bookmark_page(doc, path, *page)?,
|
||
Edit::NumberPages { from, to, style, prefix, first } => printcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?,
|
||
Edit::AddAnnotation(a) => {
|
||
printcraft_annot::add_annotation(doc, a, &cx.meta())?;
|
||
}
|
||
Edit::DeleteAnnotation { page, index } => printcraft_annot::delete_annotation(doc, *page, *index)?,
|
||
Edit::SetAnnotationContents { page, index, text } => printcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?,
|
||
Edit::ReplyToAnnotation { page, index, text, author } => {
|
||
printcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?;
|
||
}
|
||
Edit::SetAnnotationStatus { page, index, state, author } => {
|
||
printcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?;
|
||
}
|
||
Edit::MoveAnnotation { page, index, dx, dy } => printcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?,
|
||
Edit::ResizeAnnotation { page, index, rect } => printcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
|
||
Edit::StyleAnnotation { page, index, color, opacity, width } => {
|
||
printcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, &cx.meta())?;
|
||
}
|
||
Edit::SetFieldValue { name, value } => printcraft_forms::set_value(doc, name, value)?,
|
||
Edit::ResetForm { names } => {
|
||
printcraft_forms::reset(doc, names.as_deref())?;
|
||
}
|
||
Edit::AddHeaderFooter { pages, settings, replace } => {
|
||
let date = cx.today;
|
||
printcraft_edit::add_header_footer(doc, pages, settings, *replace, &printcraft_edit::Context { date })?;
|
||
}
|
||
Edit::AddWatermark { pages, settings, replace } => printcraft_edit::add_watermark(doc, pages, settings, *replace)?,
|
||
Edit::AddBackground { pages, settings, replace } => printcraft_edit::add_background(doc, pages, settings, *replace)?,
|
||
Edit::RemoveMarks { kind } => {
|
||
let n = printcraft_model::pages(doc).len();
|
||
if printcraft_edit::remove_marks(doc, &(0..n).collect::<Vec<_>>(), *kind)? == 0 {
|
||
return Err(EditError::Edit(printcraft_edit::EditError::Invalid("there is nothing to remove".into())));
|
||
}
|
||
}
|
||
Edit::Protect(p) => {
|
||
p.validate()?;
|
||
let seed = cx.entropy();
|
||
// Without a permissions password nothing is restricted, so the owner password is a
|
||
// random one nobody needs.
|
||
let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect();
|
||
let params = printcraft_cos::NewEncryption {
|
||
algorithm: p.algorithm,
|
||
user_password: p.open_password.as_deref().unwrap_or(""),
|
||
owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner),
|
||
permissions: p.permission_bits(),
|
||
encrypt_metadata: p.encrypt_metadata,
|
||
seed,
|
||
};
|
||
doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?;
|
||
}
|
||
Edit::RemoveProtection => {
|
||
if doc.output_handler().is_none() {
|
||
return Err(EditError::Protection("the document isn't protected".into()));
|
||
}
|
||
doc.remove_encryption();
|
||
}
|
||
Edit::Batch { edits, .. } => {
|
||
for e in edits {
|
||
run_edit(doc, e, cx)?;
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The comment list, read from the object graph (as `inspect` lists comments).
|
||
fn comment_list(doc: &printcraft_cos::Document) -> Vec<printcraft_render::Annotation> {
|
||
printcraft_annot::summaries(doc)
|
||
.into_iter()
|
||
.map(|s| printcraft_render::Annotation {
|
||
page: s.page,
|
||
subtype: s.subtype,
|
||
author: s.author,
|
||
contents: s.contents,
|
||
modified: s.modified.map(|m| printcraft_render::pretty_date(&m)),
|
||
name: s.name,
|
||
in_reply_to: s.in_reply_to,
|
||
rect: s.rect,
|
||
color: s.color,
|
||
index: s.index,
|
||
state: s.state,
|
||
quads: s.quads,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Seconds to add to UTC for local time (0 where unknown).
|
||
fn local_utc_offset() -> i64 {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
return i64::from(chrono::Local::now().offset().local_minus_utc());
|
||
#[cfg(target_arch = "wasm32")]
|
||
return 0;
|
||
}
|
||
|
||
/// (year, month, day) from a PDF date `D:YYYYMMDD…`.
|
||
fn parse_ymd(d: &str) -> Option<(i64, u32, u32)> {
|
||
let d = d.strip_prefix("D:").unwrap_or(d);
|
||
Some((d.get(0..4)?.parse().ok()?, d.get(4..6)?.parse().ok()?, d.get(6..8)?.parse().ok()?))
|
||
}
|
||
|
||
/// The passwords after `edit`, if it changes them.
|
||
fn keys_after(edit: &Edit) -> Option<Keys> {
|
||
match edit {
|
||
Edit::Protect(p) => {
|
||
Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) })
|
||
}
|
||
Edit::RemoveProtection => Some(Keys::default()),
|
||
Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Parse another PDF to copy pages from.
|
||
fn open_source(name: &str, bytes: &Arc<Vec<u8>>) -> Result<printcraft_cos::Document, EditError> {
|
||
match std::panic::catch_unwind(|| printcraft_cos::Document::open(bytes.clone())) {
|
||
Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => {
|
||
Err(EditError::Source(format!("{name}: its security settings don't allow copying pages")))
|
||
}
|
||
Ok(Ok(d)) => Ok(d),
|
||
Ok(Err(printcraft_cos::CosError::NeedsPassword)) => Err(EditError::Source(format!("{name}: it is password-protected"))),
|
||
Ok(Err(e)) => Err(EditError::Source(format!("{name}: {e}"))),
|
||
Err(_) => Err(EditError::Source(format!("{name}: the file could not be read"))),
|
||
}
|
||
}
|
||
|
||
fn plural(s: &str, n: usize) -> String {
|
||
if n == 1 { s.to_string() } else { format!("{s}s") }
|
||
}
|
||
|
||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||
pub enum EditError {
|
||
#[error("no such document")]
|
||
NoDocument,
|
||
#[error("this document can't be edited: {0}")]
|
||
ReadOnly(String),
|
||
#[error("the document's security settings don't allow {0}; open it with the owner password to make this change")]
|
||
NotPermitted(&'static str),
|
||
#[error("{0}")]
|
||
Organize(#[from] printcraft_organize::OrganizeError),
|
||
#[error("{0}")]
|
||
Bookmark(#[from] printcraft_organize::OutlineError),
|
||
#[error("{0}")]
|
||
Comment(#[from] printcraft_annot::AnnotError),
|
||
#[error("{0}")]
|
||
Protection(String),
|
||
#[error("{0}")]
|
||
Form(#[from] printcraft_forms::FormError),
|
||
#[error("{0}")]
|
||
Edit(#[from] printcraft_edit::EditError),
|
||
#[error("{0}")]
|
||
Create(#[from] printcraft_create::CreateError),
|
||
#[error("the edited document could not be written: {0}")]
|
||
Write(String),
|
||
#[error("the edited document could not be reopened: {0}")]
|
||
Reopen(String),
|
||
#[error("couldn't use {0}")]
|
||
Source(String),
|
||
#[error("nothing to undo")]
|
||
NothingToUndo,
|
||
#[error("nothing to redo")]
|
||
NothingToRedo,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct Session {
|
||
docs: Vec<Document>,
|
||
next_id: u64,
|
||
/// Seconds since the Unix epoch, injected so saves are deterministic in tests.
|
||
clock: Option<fn() -> i64>,
|
||
}
|
||
|
||
impl Session {
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
/// Use a fixed clock (tests) instead of the system time.
|
||
pub fn with_clock(mut self, clock: fn() -> i64) -> Self {
|
||
self.clock = Some(clock);
|
||
self
|
||
}
|
||
|
||
fn now(&self) -> Option<i64> {
|
||
if let Some(c) = self.clock {
|
||
return Some(c());
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64);
|
||
#[cfg(target_arch = "wasm32")]
|
||
return None;
|
||
}
|
||
|
||
/// Today's date in local time: (year, month, day). With an injected clock (tests) the clock
|
||
/// is taken as local time.
|
||
pub fn today(&self) -> (i64, u32, u32) {
|
||
let offset = if self.clock.is_some() { 0 } else { local_utc_offset() };
|
||
self.now().map(|t| printcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1))
|
||
}
|
||
|
||
/// Open a document from bytes. Rendering starts lazily when pages are requested.
|
||
///
|
||
/// `password` is tried as either the user or owner password when the file is encrypted.
|
||
pub fn open(&mut self, name: impl Into<String>, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
|
||
let cos = std::panic::catch_unwind(|| printcraft_cos::Document::open_with_password(bytes.clone(), password));
|
||
// The renderer authenticates on its own. It cannot use the owner password of R2–R4
|
||
// files, so give it the user password that owner authentication recovers.
|
||
let (info, render_password) = match inspect(bytes.clone(), password) {
|
||
Ok(info) => (info, password.map(str::to_owned)),
|
||
Err(OpenError::WrongPassword) => {
|
||
let user = match &cos {
|
||
Ok(Ok(d)) => d.security().and_then(|s| s.recovered_user_password()),
|
||
_ => None,
|
||
};
|
||
let user: String = user.ok_or(OpenError::WrongPassword)?.iter().map(|b| char::from(*b)).collect();
|
||
(inspect(bytes.clone(), Some(&user))?, Some(user))
|
||
}
|
||
Err(e) => return Err(e),
|
||
};
|
||
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
|
||
let renderer = RenderPool::new(bytes.clone(), render_threads(), config.clone());
|
||
let (editor, read_only_reason) = match cos {
|
||
Ok(Ok(cos)) => {
|
||
let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) };
|
||
(Some(Editor { cos, undo: Vec::new(), redo: Vec::new(), keys }), None)
|
||
}
|
||
Ok(Err(e)) => (None, Some(e.to_string())),
|
||
Err(_) => (None, Some("the document structure could not be read for editing".into())),
|
||
};
|
||
let form = editor.as_ref().map(|e| printcraft_forms::fields(&e.cos)).unwrap_or_default();
|
||
let marks = editor.as_ref().map(|e| printcraft_edit::marks_present(&e.cos)).unwrap_or_default();
|
||
self.next_id += 1;
|
||
let id = DocId(self.next_id);
|
||
self.docs.push(Document {
|
||
id,
|
||
name: name.into(),
|
||
path,
|
||
bytes,
|
||
info,
|
||
renderer,
|
||
password: render_password,
|
||
dirty: false,
|
||
generation: 0,
|
||
snapshot_generation: 0,
|
||
read_only_reason,
|
||
form: Arc::new(form),
|
||
marks,
|
||
editor,
|
||
config,
|
||
});
|
||
Ok(id)
|
||
}
|
||
|
||
fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> {
|
||
self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument)
|
||
}
|
||
|
||
/// Apply an edit. On success the previous state is undoable and the view data is refreshed.
|
||
pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> {
|
||
let now = self.now();
|
||
let today = self.today();
|
||
let doc = self.doc_mut(id)?;
|
||
let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48));
|
||
cx.today = today;
|
||
let reason = doc.read_only_reason.clone().unwrap_or_default();
|
||
let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?;
|
||
if let Some(p) = editor.cos.permissions() {
|
||
check_permission(&edit, &p)?;
|
||
}
|
||
let mut next = editor.cos.clone();
|
||
run_edit(&mut next, &edit, &mut cx)?;
|
||
let previous = std::mem::replace(&mut editor.cos, next);
|
||
let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone());
|
||
let previous_keys = std::mem::replace(&mut editor.keys, keys);
|
||
let scope = scope_of(&edit);
|
||
editor.undo.push((edit.label(), previous, previous_keys, scope));
|
||
if editor.undo.len() > MAX_UNDO {
|
||
editor.undo.remove(0);
|
||
}
|
||
editor.redo.clear();
|
||
Self::adopt_keys(doc);
|
||
if let Err(e) = Self::refresh_scoped(doc, scope) {
|
||
// Roll back: the edit produced something we cannot display.
|
||
if let Some(ed) = doc.editor.as_mut()
|
||
&& let Some((_, prev, keys, _)) = ed.undo.pop()
|
||
{
|
||
ed.cos = prev;
|
||
ed.keys = keys;
|
||
}
|
||
Self::adopt_keys(doc);
|
||
let _ = Self::refresh(doc);
|
||
return Err(e);
|
||
}
|
||
doc.dirty = true;
|
||
doc.generation += 1;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn undo(&mut self, id: DocId) -> Result<String, EditError> {
|
||
let doc = self.doc_mut(id)?;
|
||
let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?;
|
||
let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?;
|
||
let current = std::mem::replace(&mut editor.cos, prev);
|
||
let current_keys = std::mem::replace(&mut editor.keys, keys);
|
||
editor.redo.push((label.clone(), current, current_keys, scope));
|
||
Self::adopt_keys(doc);
|
||
Self::refresh_scoped(doc, scope)?;
|
||
doc.dirty = true;
|
||
doc.generation += 1;
|
||
Ok(label)
|
||
}
|
||
|
||
pub fn redo(&mut self, id: DocId) -> Result<String, EditError> {
|
||
let doc = self.doc_mut(id)?;
|
||
let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?;
|
||
let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?;
|
||
let current = std::mem::replace(&mut editor.cos, next);
|
||
let current_keys = std::mem::replace(&mut editor.keys, keys);
|
||
editor.undo.push((label.clone(), current, current_keys, scope));
|
||
Self::adopt_keys(doc);
|
||
Self::refresh_scoped(doc, scope)?;
|
||
doc.dirty = true;
|
||
doc.generation += 1;
|
||
Ok(label)
|
||
}
|
||
|
||
/// Use the current state's password for viewing (protection edits change it).
|
||
fn adopt_keys(doc: &mut Document) {
|
||
if let Some(e) = doc.editor.as_ref() {
|
||
doc.password = e.keys.render.clone();
|
||
doc.config.password = e.keys.render.as_deref().map(Arc::from);
|
||
}
|
||
}
|
||
|
||
/// Rebuild the working bytes and renderer, and the view data that `scope` may have changed.
|
||
/// Comment and form edits skip the full re-inspection (seconds on very large files): the
|
||
/// comment list and field values are re-read from the object graph instead.
|
||
fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> {
|
||
if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) {
|
||
return Self::refresh(doc);
|
||
}
|
||
let Some(editor) = doc.editor.as_ref() else { return Ok(()) };
|
||
let bytes = if editor.cos.is_modified() {
|
||
Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?)
|
||
} else {
|
||
editor.cos.bytes().clone()
|
||
};
|
||
let form = Arc::new(printcraft_forms::fields(&editor.cos));
|
||
match scope {
|
||
Scope::Comments => doc.info.annotations = comment_list(&editor.cos),
|
||
Scope::Form => {
|
||
for f in &mut doc.info.fields {
|
||
if let Some(ff) = form.iter().find(|x| x.name == f.name) {
|
||
f.value = match ff.kind {
|
||
printcraft_forms::FieldKind::CheckBox | printcraft_forms::FieldKind::Radio => {
|
||
Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into()))
|
||
}
|
||
_ if ff.value.is_empty() => None,
|
||
_ => Some(ff.value.join(", ")),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
Scope::Full => unreachable!("handled above"),
|
||
}
|
||
doc.info.file_size = bytes.len();
|
||
doc.form = form;
|
||
doc.bytes = bytes.clone();
|
||
doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone());
|
||
Ok(())
|
||
}
|
||
|
||
/// Rebuild working bytes, inspection and renderer from the current edit state.
|
||
fn refresh(doc: &mut Document) -> Result<(), EditError> {
|
||
let Some(editor) = doc.editor.as_ref() else { return Ok(()) };
|
||
let bytes = if editor.cos.is_modified() {
|
||
Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?)
|
||
} else {
|
||
editor.cos.bytes().clone()
|
||
};
|
||
let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||
// Keep the user's layer choices where the layers still exist.
|
||
let mut info = info;
|
||
for l in &mut info.layers {
|
||
if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) {
|
||
l.visible = old.visible;
|
||
}
|
||
}
|
||
doc.info = info;
|
||
doc.form = Arc::new(printcraft_forms::fields(&editor.cos));
|
||
doc.marks = printcraft_edit::marks_present(&editor.cos);
|
||
doc.bytes = bytes.clone();
|
||
doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone());
|
||
Ok(())
|
||
}
|
||
|
||
/// The bytes to write for Save: an incremental update of the file as opened/last saved,
|
||
/// with `/ModDate` stamped. Call `mark_saved` after writing them successfully.
|
||
pub fn save_bytes(&self, id: DocId) -> Result<Arc<Vec<u8>>, EditError> {
|
||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||
let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) };
|
||
if !editor.cos.is_modified() {
|
||
return Ok(editor.cos.bytes().clone());
|
||
}
|
||
let opts = SaveOptions { mod_date: self.now().map(printcraft_cos::pdf_date), ..SaveOptions::default() };
|
||
write_incremental(&editor.cos, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
|
||
}
|
||
|
||
/// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork).
|
||
pub fn save_full_bytes(&self, id: DocId) -> Result<Arc<Vec<u8>>, EditError> {
|
||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||
let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
|
||
let opts = SaveOptions { mod_date: self.now().map(printcraft_cos::pdf_date), ..SaveOptions::default() };
|
||
write_full(&editor.cos, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
|
||
}
|
||
|
||
/// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file
|
||
/// so the next save appends only newer edits. Undo history is kept.
|
||
pub fn mark_saved(&mut self, id: DocId, bytes: Arc<Vec<u8>>, path: Option<String>) -> Result<(), EditError> {
|
||
let doc = self.doc_mut(id)?;
|
||
if let Some(editor) = doc.editor.as_mut() {
|
||
// An encrypted file needs the password it is protected with now (the owner
|
||
// password when known, so saving never downgrades this session's rights).
|
||
editor.cos = printcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref())
|
||
.map_err(|e| EditError::Reopen(e.to_string()))?;
|
||
}
|
||
if let Some(p) = path {
|
||
doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());
|
||
doc.path = Some(p);
|
||
}
|
||
doc.dirty = false;
|
||
doc.generation += 1;
|
||
Self::refresh(doc)
|
||
}
|
||
|
||
/// A new blank document (Create ▸ Blank page).
|
||
pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result<Arc<Vec<u8>>, EditError> {
|
||
self.write_new(&printcraft_create::blank(width, height, pages)?)
|
||
}
|
||
|
||
/// A new document with one page per image (PNG, JPEG).
|
||
pub fn create_from_images(&self, images: &[(String, Vec<u8>)]) -> Result<Arc<Vec<u8>>, EditError> {
|
||
self.write_new(&printcraft_create::from_images(images)?)
|
||
}
|
||
|
||
/// A new document from plain text (US Letter, 11 pt Helvetica).
|
||
pub fn create_from_text(&self, title: &str, text: &str) -> Result<Arc<Vec<u8>>, EditError> {
|
||
self.write_new(&printcraft_create::from_text(title, text, printcraft_create::LETTER, 11.0)?)
|
||
}
|
||
|
||
/// Reduce File Size: identical resources merged, unused objects dropped, objects packed
|
||
/// into compressed object streams. Returns the bytes and how many objects were merged.
|
||
/// The open document is not changed (Acrobat saves the reduced copy as a new file).
|
||
pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc<Vec<u8>>, usize), EditError> {
|
||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||
let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
|
||
let mut cos = editor.cos.clone();
|
||
let all: Vec<printcraft_cos::ObjRef> = cos.object_numbers().into_iter().map(|n| printcraft_cos::ObjRef::new(n, cos.generation(n))).collect();
|
||
let merged = printcraft_organize::dedupe_resources(&mut cos, &all, false);
|
||
let opts = SaveOptions { mod_date: self.now().map(printcraft_cos::pdf_date), ..SaveOptions::default() };
|
||
let bytes = write_full(&cos, &opts).map_err(|e| EditError::Write(e.to_string()))?;
|
||
Ok((Arc::new(bytes), merged))
|
||
}
|
||
|
||
/// Combine whole files, in order, into new PDF bytes (one bookmark per file).
|
||
pub fn combine(&self, sources: &[(String, Arc<Vec<u8>>)]) -> Result<Arc<Vec<u8>>, EditError> {
|
||
let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::<Result<Vec<_>, _>>()?;
|
||
let named: Vec<(&str, &printcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect();
|
||
let out = printcraft_organize::combine(&named)?;
|
||
self.write_new(&out)
|
||
}
|
||
|
||
/// New PDF bytes containing copies of `pages` of the document (Extract Pages).
|
||
pub fn extract(&self, id: DocId, pages: &[usize]) -> Result<Arc<Vec<u8>>, EditError> {
|
||
let src = self.cos(id)?;
|
||
if src.permissions().is_some_and(|p| !p.assemble()) {
|
||
return Err(EditError::NotPermitted("extracting pages"));
|
||
}
|
||
let out = printcraft_organize::extract_pages(src, pages)?;
|
||
self.write_new(&out)
|
||
}
|
||
|
||
/// Split the document into several new PDFs.
|
||
pub fn split(&self, id: DocId, by: &printcraft_organize::SplitBy) -> Result<Vec<SplitPart>, EditError> {
|
||
let src = self.cos(id)?;
|
||
if src.permissions().is_some_and(|p| !p.assemble()) {
|
||
return Err(EditError::NotPermitted("splitting the document"));
|
||
}
|
||
let n = printcraft_organize::page_count(src)?;
|
||
printcraft_organize::split_ranges(n, by)
|
||
.into_iter()
|
||
.map(|r| {
|
||
let doc = printcraft_organize::extract_pages(src, &r.clone().collect::<Vec<_>>())?;
|
||
Ok((r.start + 1, r.end, self.write_new(&doc)?))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Open freshly created bytes (combine / extract) as a new, unsaved document.
|
||
pub fn open_new(&mut self, name: impl Into<String>, bytes: Arc<Vec<u8>>) -> Result<DocId, OpenError> {
|
||
let id = self.open(name, None, bytes, None)?;
|
||
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
|
||
d.dirty = true;
|
||
}
|
||
Ok(id)
|
||
}
|
||
|
||
fn cos(&self, id: DocId) -> Result<&printcraft_cos::Document, EditError> {
|
||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||
doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))
|
||
}
|
||
|
||
fn write_new(&self, doc: &printcraft_cos::Document) -> Result<Arc<Vec<u8>>, EditError> {
|
||
let opts = SaveOptions { mod_date: self.now().map(printcraft_cos::pdf_date), ..SaveOptions::default() };
|
||
write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
|
||
}
|
||
|
||
/// Documents with unsaved changes made since the last call: their current working file,
|
||
/// for crash recovery. Encrypted documents stay encrypted in the snapshot.
|
||
pub fn autosave_snapshots(&mut self) -> Vec<RecoverySnapshot> {
|
||
let mut out = Vec::new();
|
||
for d in &mut self.docs {
|
||
if d.dirty && d.generation != d.snapshot_generation {
|
||
d.snapshot_generation = d.generation;
|
||
out.push(RecoverySnapshot {
|
||
doc: d.id,
|
||
name: d.name.clone(),
|
||
path: d.path.clone(),
|
||
bytes: d.bytes.clone(),
|
||
encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()),
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Mark a document opened from a recovery file: it has unsaved changes and belongs at
|
||
/// `path` (where Save writes), as when the session ended.
|
||
pub fn mark_recovered(&mut self, id: DocId, path: Option<String>) {
|
||
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
|
||
d.path = path;
|
||
d.dirty = true;
|
||
d.generation += 1;
|
||
d.snapshot_generation = d.generation; // its bytes are already in the recovery store
|
||
}
|
||
}
|
||
|
||
/// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed;
|
||
/// callers must drop cached rasters and text for the document.
|
||
pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool {
|
||
let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false };
|
||
let Some(l) = doc.info.layers.get_mut(layer) else { return false };
|
||
if l.visible == visible {
|
||
return false;
|
||
}
|
||
l.visible = visible;
|
||
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
|
||
doc.config.layers = Arc::new(overrides);
|
||
doc.renderer = RenderPool::new(doc.bytes.clone(), render_threads(), doc.config.clone());
|
||
true
|
||
}
|
||
|
||
pub fn close(&mut self, id: DocId) {
|
||
self.docs.retain(|d| d.id != id);
|
||
}
|
||
|
||
pub fn get(&self, id: DocId) -> Option<&Document> {
|
||
self.docs.iter().find(|d| d.id == id)
|
||
}
|
||
|
||
pub fn docs(&self) -> &[Document] {
|
||
&self.docs
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|