//! 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>); 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, /// What the editor re-opens a saved file with: the strongest password known (owner if any). reopen: Option, } /// 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, redo: Vec, keys: Keys, } pub struct Document { pub id: DocId, pub name: String, pub path: Option, /// The working file: what Save writes and what is displayed. pub bytes: Arc>, pub info: DocInfo, pub renderer: RenderPool, /// The password the document was opened with (needed to read attachments, etc.). pub password: Option, /// `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, /// Interactive form fields of the current state (empty without a form). pub form: Arc>, /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. pub marks: Vec, editor: Option, 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 { 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 { 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 { 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 { 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, pub bytes: Arc>, /// 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, /// Required to change security and lift the restrictions below (the owner password). pub permissions_password: Option, 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, degrees: i64, }, DeletePages { pages: Vec, }, MovePages { pages: Vec, to: usize, }, InsertBlankPage { at: usize, width: f64, height: f64, }, /// Copies of `pages` inserted after the last of them. DuplicatePages { pages: Vec, }, /// Set Page Boxes / Crop: set (or reset) one box on `pages`. SetPageBox { pages: Vec, 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>, pages: Option>, at: usize, }, /// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level). AddBookmark { parent: Vec, index: usize, title: String, page: usize, }, RenameBookmark { path: Vec, title: String, }, /// Delete a bookmark and the bookmarks under it. DeleteBookmark { path: Vec, }, /// Move a bookmark to child `index` of `to_parent` (indices after removing it). MoveBookmark { from: Vec, to_parent: Vec, index: usize, }, /// Point a bookmark at another page. SetBookmarkPage { path: Vec, 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, opacity: Option, width: Option, }, /// 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>, }, /// Add a header and footer (with `replace`, existing ones on those pages go first). AddHeaderFooter { pages: Vec, settings: HeaderFooter, replace: bool, }, AddWatermark { pages: Vec, settings: Watermark, replace: bool, }, AddBackground { pages: Vec, 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, }, } 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, /// Today in local time, for date tokens. today: (i64, u32, u32), seed: u64, count: u64, } impl EditCtx { fn new(now: Option, 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::>(), *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_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 { 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>) -> Result { 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, next_id: u64, /// Seconds since the Unix epoch, injected so saves are deterministic in tests. clock: Option 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 { 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, path: Option, bytes: Arc>, password: Option<&str>) -> Result { 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 { 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 { 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>, 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>, 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>, path: Option) -> 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>, 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)]) -> Result>, 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>, 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>, 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 = 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>)]) -> Result>, EditError> { let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; 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>, 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, 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::>())?; 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, bytes: Arc>) -> Result { 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>, 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 { 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) { 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;