Files
pdfcraft/crates/engine/src/lib.rs
T

1581 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
};
pub use printcraft_forms::{
BorderStyle, Field as FormField, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, NewField, TabOrder,
Widget as FormWidget, af as form_scripts, 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, Props as CommentProps, ReviewState, Rgb, Shape, Style, rect_quad};
pub use printcraft_print as print;
pub use printcraft_redact::patterns::{PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern};
pub use printcraft_redact::sanitize::{HIDDEN, Hidden};
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 { .. }
| Edit::SetAnnotationInfo { .. } => 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>,
/// Text and images added with Edit a PDF ▸ Add content (still editable).
pub added: Vec<printcraft_edit::Added>,
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())
}
/// Printing is allowed (Table 22, bit 3).
pub fn allows_printing(&self) -> bool {
self.permissions().is_none_or(|p| p.print())
}
/// 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())
}
/// Redaction marks waiting to be applied.
pub fn redaction_marks(&self) -> usize {
self.info.annotations.iter().filter(|a| a.subtype == "Redact").count()
}
/// 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 })
}
/// Comment properties of the comment at `(page, index)` (Comment Properties dialog).
pub fn comment_props(&self, page: usize, index: usize) -> Option<printcraft_annot::Props> {
self.editor.as_ref().and_then(|e| printcraft_annot::props(&e.cos, page, index))
}
/// A form field's Appearance-tab look (borders, colours, font).
pub fn field_look(&self, name: &str) -> Option<printcraft_forms::Look> {
let e = self.editor.as_ref()?;
let f = self.form.iter().find(|f| f.name == name)?;
Some(printcraft_forms::look(&e.cos, f))
}
/// Remove Hidden Information: what each category would remove.
pub fn hidden_info(&self) -> Vec<(Hidden, usize)> {
self.editor.as_ref().map(|e| printcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default()
}
/// 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>,
},
/// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the
/// pages' links, comments and bookmarks stay.
ReplacePages {
pages: Vec<usize>,
name: String,
bytes: Arc<Vec<u8>>,
src_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>,
},
/// Comment properties ▸ General / note icon.
SetAnnotationInfo {
page: usize,
index: usize,
author: Option<String>,
subject: Option<String>,
icon: Option<NoteIcon>,
},
/// 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>>,
},
/// Prepare a form: add a field on a page (`rect` in PDF user space); `name` defaults to
/// Acrobat's next free name ("Text1", "Check Box2", …).
AddField {
page: usize,
rect: [f64; 4],
kind: NewField,
name: Option<String>,
},
/// Field Properties ▸ General / Options.
SetFieldProps {
name: String,
props: FieldProps,
},
/// Delete a field and all its widgets.
DeleteField {
name: String,
},
/// Page properties ▸ Tab order, for pages (0-based).
SetTabOrder {
pages: Vec<usize>,
order: TabOrder,
},
/// 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,
},
/// Edit a PDF ▸ Add content ▸ Text (`text.rect` in display space).
AddText {
page: usize,
text: AddedText,
},
/// Edit a PDF ▸ Add content ▸ Image: an image file placed at `rect` (display space), or at
/// its natural size centred on the page (shrunk to fit).
AddImage {
page: usize,
rect: Option<[f64; 4]>,
name: String,
bytes: Arc<Vec<u8>>,
},
/// Move, resize, retype or reformat an added item (`index` among the page's added items).
UpdateContent {
page: usize,
index: usize,
content: AddedContent,
},
DeleteContent {
page: usize,
index: usize,
},
/// Replace an added image with another file (keeping its box and transforms).
ReplaceImage {
page: usize,
index: usize,
name: String,
bytes: Arc<Vec<u8>>,
},
/// Apply redaction marks (all, or those on `pages`): remove what they cover for good.
ApplyRedactions {
pages: Option<Vec<usize>>,
},
/// Remove redaction marks without applying them.
ClearRedactions,
/// Remove Hidden Information: the chosen categories.
RemoveHidden {
which: Vec<Hidden>,
},
/// Sanitize Document: every category, then a full rewrite on save.
Sanitize,
/// Flatten comments and/or form fields on every page into page content.
Flatten {
comments: bool,
fields: bool,
},
/// 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::ReplacePages { pages, .. } => plural("Replace 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 { .. } | Edit::SetAnnotationInfo { .. } => "Change comment properties".into(),
Edit::SetFieldValue { name, .. } => format!("Fill in {name}"),
Edit::ResetForm { .. } => "Clear form".into(),
Edit::AddField { .. } => "Add field".into(),
Edit::SetFieldProps { .. } => "Change field properties".into(),
Edit::DeleteField { .. } => "Delete field".into(),
Edit::SetTabOrder { .. } => "Set tab order".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::AddText { .. } => "Add text".into(),
Edit::AddImage { .. } => "Add image".into(),
Edit::UpdateContent { .. } => "Edit content".into(),
Edit::DeleteContent { .. } => "Delete content".into(),
Edit::ReplaceImage { .. } => "Replace image".into(),
Edit::ApplyRedactions { .. } => "Apply redactions".into(),
Edit::ClearRedactions => "Remove redaction marks".into(),
Edit::RemoveHidden { .. } => "Remove hidden information".into(),
Edit::Sanitize => "Sanitize document".into(),
Edit::Flatten { comments: true, fields: false } => "Flatten comments".into(),
Edit::Flatten { comments: false, fields: true } => "Flatten form fields".into(),
Edit::Flatten { .. } => "Flatten".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",
Shape::Redact { .. } => "redaction mark",
}
}
/// 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::ReplacePages { .. }
| 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 { .. }
| Edit::SetAnnotationInfo { .. } => {
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 { .. }
| Edit::AddField { .. }
| Edit::ApplyRedactions { .. }
| Edit::AddText { .. }
| Edit::AddImage { .. }
| Edit::UpdateContent { .. }
| Edit::DeleteContent { .. }
| Edit::ReplaceImage { .. }
| Edit::ClearRedactions
| Edit::RemoveHidden { .. }
| Edit::Sanitize
| Edit::SetFieldProps { .. }
| Edit::DeleteField { .. }
| Edit::SetTabOrder { .. }
| Edit::Flatten { .. } => {
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::ReplacePages { pages, name, bytes, src_pages } => {
let src = open_source(name, bytes)?;
printcraft_organize::replace_pages(doc, pages, &src, src_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::SetAnnotationInfo { page, index, author, subject, icon } => {
printcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
}
Edit::SetFieldValue { name, value } => printcraft_forms::set_value(doc, name, value)?,
Edit::ResetForm { names } => {
printcraft_forms::reset(doc, names.as_deref())?;
}
Edit::AddField { page, rect, kind, name } => {
printcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?;
}
Edit::SetFieldProps { name, props } => {
printcraft_forms::set_props(doc, name, props)?;
}
Edit::DeleteField { name } => printcraft_forms::delete_field(doc, name)?,
Edit::SetTabOrder { pages, order } => printcraft_forms::set_tab_order(doc, pages, *order)?,
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::AddText { page, text } => {
printcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?;
}
Edit::AddImage { page, rect, name, bytes } => {
let (image, natural) = printcraft_create::image_xobject(doc, name, bytes)?;
let rect = match rect {
Some(r) => *r,
None => {
let p = printcraft_model::pages(doc).swap_remove(*page);
let (pw, ph) = p.display_size(doc);
let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0);
let (w, h) = (natural.0 * k, natural.1 * k);
[(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0]
}
};
printcraft_edit::add_content(doc, *page, &AddedContent::Image(printcraft_edit::AddedImage::new(rect, image)))?;
}
Edit::UpdateContent { page, index, content } => printcraft_edit::update_content(doc, *page, *index, content)?,
Edit::DeleteContent { page, index } => printcraft_edit::delete_content(doc, *page, *index)?,
Edit::ReplaceImage { page, index, name, bytes } => {
let item = printcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index);
let Some(AddedContent::Image(old)) = item.map(|a| a.content) else {
return Err(EditError::Edit(printcraft_edit::EditError::Invalid("that item is not an image".into())));
};
let (image, _) = printcraft_create::image_xobject(doc, name, bytes)?;
printcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(printcraft_edit::AddedImage { image, ..old }))?;
}
Edit::ApplyRedactions { pages } => {
printcraft_redact::apply(doc, pages.as_deref())?;
}
Edit::ClearRedactions => {
printcraft_redact::clear_marks(doc, None)?;
}
Edit::RemoveHidden { which } => {
printcraft_redact::sanitize::remove_hidden(doc, which)?;
}
Edit::Sanitize => {
printcraft_redact::sanitize::sanitize(doc)?;
}
Edit::Flatten { comments, fields } => {
let n = printcraft_model::pages(doc).len();
printcraft_edit::flatten(doc, &(0..n).collect::<Vec<_>>(), *comments, *fields)?;
}
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(&params).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(transparent)]
Redact(#[from] printcraft_redact::RedactError),
#[error("{0}")]
Print(String),
#[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();
let added = editor.as_ref().map(|e| printcraft_edit::list_added(&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,
added,
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.added = printcraft_edit::list_added(&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)
}
/// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is
/// cleared, as in Acrobat.
pub fn revert(&mut self, id: DocId) -> Result<(), EditError> {
let doc = self.doc_mut(id)?;
let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
let base = editor.cos.bytes().clone();
// The saved file opens with the passwords of the state that was saved: try the current
// ones, then each earlier state's.
let mut candidates = vec![editor.keys.clone()];
candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone()));
candidates.push(Keys::default());
let (cos, keys) = candidates
.into_iter()
.find_map(|k| printcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k)))
.ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?;
editor.cos = cos;
editor.keys = keys;
editor.undo.clear();
editor.redo.clear();
Self::adopt_keys(doc);
doc.dirty = false;
doc.generation += 1;
Self::refresh(doc)
}
/// The page count of another PDF (Replace Pages, Insert Pages dialogs).
pub fn page_count_of(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<usize, EditError> {
Ok(printcraft_organize::page_count(&open_source(name, bytes)?)?)
}
/// 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))
}
/// The print-ready PDF for `settings` (sheets laid out for the paper; see `printcraft-print`).
pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result<Vec<u8>, EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
if !doc.allows_printing() {
return Err(EditError::NotPermitted("printing"));
}
let cos = match doc.editor.as_ref() {
Some(e) => e.cos.clone(),
None => printcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref())
.map_err(|e| EditError::Write(e.to_string()))?,
};
printcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string()))
}
/// 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;