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

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