diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index c3e34d90bd..1051903bae 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -18,6 +18,11 @@ use crate::{ writable, xfa, xfa_sync_datasets, }; +mod editing; +mod files; +mod save; +mod view; + #[derive(Default)] pub struct Session { pub(crate) docs: Vec, @@ -41,1550 +46,3 @@ pub struct Session { /// [`Session::with_xfa_time_scale`]. pub(crate) xfa_time_scale: Option, } - -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 - } - - /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the - /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script - /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the - /// scripts do passes a large factor, so that a busy machine does not stop them. On native - /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so - /// there the factor has no effect. - pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { - self.xfa_time_scale = Some(factor); - self - } - - /// A real clock for a host that has neither a clock nor a time zone of its own (a - /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so - /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in - /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every - /// use because the zone changes with the date (daylight saving). Unlike - /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. - pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { - self.clock = Some(clock); - self.utc_offset = Some(utc_offset); - self - } - - /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under - /// the test clock (which is already local), else the system's. - fn local_offset(&self) -> i64 { - match (self.utc_offset, self.clock) { - (Some(zone), _) => zone(), - (None, Some(_)) => 0, - (None, None) => local_utc_offset(), - } - } - - /// Seconds since the Unix epoch from the session clock (0 when unknown). - pub fn now_secs(&self) -> i64 { - self.now().unwrap_or(0) - } - - pub(crate) fn now(&self) -> Option { - if let Some(c) = self.clock { - return Some(c()); - } - #[cfg(not(target_arch = "wasm32"))] - return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); - #[cfg(target_arch = "wasm32")] - return None; - } - - /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock - /// is taken as local time. - /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). - pub fn stamp_by_line(&self, author: &str) -> String { - let offset = self.local_offset(); - let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); - let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); - let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); - const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; - let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; - format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) - } - - pub fn today(&self) -> (i64, u32, u32) { - let offset = self.local_offset(); - self.now().map(|t| pdfcraft_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. - /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named - /// " (revision n)". - pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { - let doc = self.get(id).ok_or("no such document")?; - let ends = doc.revision_ends(); - let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; - let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); - let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); - self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) - } - - pub(crate) 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 js_off = self.js_off; - let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); - let doc = self.doc_mut(id)?; - doc.edit_notices.clear(); - let name = doc.name.clone(); - let is_xfa = doc.info.xfa.is_some(); - let mut scope = scope_of(&edit); - let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); - if !js_off { - cx.xfa = doc.xfa_template.clone(); - } - cx.xfa_time_scale = xfa_time_scale; - cx.pages = doc.info.pages.len(); - cx.today = today; - let reason = doc.read_only_reason.clone().unwrap_or_default(); - let signed = doc.is_signed(); - let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; - if let Some(p) = editor.cos.permissions() { - check_permission(&edit, &p)?; - } - // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it - // in the paper's colour and draw the new text in the ink's. - let edit = match edit { - Edit::EditTextBlock { page, block, text, mut style } => { - // Only invisible text is sampled; the blocks are usually cached from opening it. - if style.cover.is_none() - && let Some(info) = doc.info.pages.get(page) - && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) - && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() - { - style.cover = Some(paper); - style.color = style.color.or(Some(ink)); - } - Edit::EditTextBlock { page, block, text, style } - } - other => other, - }; - // Nothing to bake: leave undo history and the dirty flag alone. - if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { - return Ok(()); - } - let mut next = editor.cos.clone(); - if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name)); - } - cx.renamed_fields.clear(); - cx.redaction_overlays.clear(); - // `next` is a copy: if the edit fails or crashes, the document is unchanged. - guard(|| run_edit(&mut next, &edit, &mut cx)) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - // Redaction removed what its own interpreter places under the marks. The marks were drawn - // over the page as the renderer shows it, so the renderer has the last word. - if let Edit::ApplyRedactions { pages } = &edit { - let marks: Vec = - pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - } - // Renamed fields are said, not slipped in: a repair-log note (with the caveat that - // scripts were left alone) and a count in the undo step's label. - let renamed_note = |renamed: &[(String, String)]| { - (!renamed.is_empty()).then(|| { - let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); - let more = renamed.len().saturating_sub(shown.len()); - let more = if more > 0 { format!(", and {more} more") } else { String::new() }; - format!( - "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", - renamed.len(), - if renamed.len() == 1 { "" } else { "s" }, - shown.join(", ") - ) - }) - }; - if let Some(note) = renamed_note(&cx.renamed_fields) { - next.note_repair(note); - } - // Only Insert from File renames fields; its label says so in a wording the interface - // translates (the count lives in the repair note). - let label = match (&edit, cx.renamed_fields.len()) { - (_, 0) => edit.label(), - (Edit::InsertPagesFrom { name, .. }, n) => Label::with( - if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, - name.as_str(), - ), - _ => edit.label(), - }; - // An XFA form's scripts answer the change: exit and validate scripts of the field, then - // every calculation (and a reset recalculates). They read the data, so it is brought up - // to date first. - if let Some(tpl) = cx.xfa.clone() { - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let datasets = &mut cx.xfa_datasets; - let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - cx.xfa_out.errors.extend(notes); - } - fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { - match e { - Edit::SetFieldValue { name, .. } => changed.push(name.clone()), - Edit::ResetForm { .. } => *reset = true, - Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), - _ => {} - } - } - let mut changed: Vec = Vec::new(); - let mut reset = false; - collect(&edit, &mut changed, &mut reset); - if !changed.is_empty() || reset { - let mut run = xfa::XfaRun { - page_count: cx.pages, - out: &mut cx.xfa_out, - datasets: cx.xfa_datasets, - ran_away: false, - time_scale: cx.xfa_time_scale, - }; - // Every changed field's scripts, then the calculations once (after a reset, - // only those). - let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) - .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = ran { - cx.xfa_out.errors.push(e); - } - } - } - // XFA forms keep their values in the datasets packet too, for Adobe's viewers. - let mut xfa_notes = Vec::new(); - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); - xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - // Rebuilding from stale data would discard accepted widget values. - if xfa_notes.is_empty() { - let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) - .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - if let Some(warnings) = reflow { - xfa_notes.extend(warnings); - // Layout replaces pages and widgets, even if the page count is unchanged. - // Undo/redo must rebuild these too instead of refreshing only field values. - scope = Scope::Full; - } - } - } - // Signed documents are only ever saved incrementally: an edit that needs a full rewrite - // (applying redactions, changing security, sanitizing) would invalidate the signatures. - // So does any edit to a file rebuilt from object headers: it has no chain to append to. - let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); - if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { - return Err(EditError::SignedRewrite(edit.label().to_string())); - } - // What this edit drew with `?` (a value or comment text its font can't draw, stored as - // typed): reported, never silent (AGENTS.md §4). - let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { - guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() - } else { - Vec::new() - }; - 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); - editor.undo.push((label, previous, previous_keys, scope)); - if editor.undo.len() > MAX_UNDO { - editor.undo.remove(0); - } - editor.redo.clear(); - Self::adopt_keys(doc); - let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) - .unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - // 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); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.dirty = true; - doc.generation += 1; - doc.edit_notices = notices; - if !cx.xfa_removed.is_empty() { - let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); - doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); - note_warnings(&mut doc.xfa_warnings, &removed); - note_warnings(&mut doc.info.warnings, &removed); - } - note_warnings(&mut doc.xfa_warnings, &xfa_notes); - let notes = doc.xfa_warnings.clone(); - note_warnings(&mut doc.info.warnings, ¬es); - if let Some(js) = cx.js { - doc.js_output.append(js.output); - } - doc.js_output.append(cx.xfa_out); - if cx.xfa_ran_away { - doc.xfa_template = None; - doc.xfa_scripts_off = true; - doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - Ok(()) - } - - pub fn undo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; - let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; - let current = std::mem::replace(&mut editor.cos, prev); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.redo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - if let Some(ed) = doc.editor.as_mut() - && let Some((label, current, current_keys, scope)) = ed.redo.pop() - { - let prev = std::mem::replace(&mut ed.cos, current); - let keys = std::mem::replace(&mut ed.keys, current_keys); - ed.undo.push((label, prev, keys, scope)); - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.edit_notices.clear(); - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - pub fn redo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; - let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; - let current = std::mem::replace(&mut editor.cos, next); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - if let Some(ed) = doc.editor.as_mut() - && let Some((label, current, current_keys, scope)) = ed.undo.pop() - { - let next = std::mem::replace(&mut ed.cos, current); - let keys = std::mem::replace(&mut ed.keys, current_keys); - ed.redo.push((label, next, keys, scope)); - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.edit_notices.clear(); - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - /// Unsaved when the working graph is not the last opened or saved file. Undo back to that - /// file is clean; undo past a later save is not, because the restored snapshot's own base - /// is an earlier revision. - fn sync_dirty(doc: &mut Document) { - doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); - } - - /// 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 the view data that `scope` may have changed. Metadata edits - /// keep the renderer; other scopes also replace it. - /// 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); - } - if scope == Scope::Content { - return Self::refresh_content(doc, None); - } - if scope == Scope::Links { - return Self::refresh_links(doc); - } - // Metadata edits keep the renderer, which holds the working file anyway. - if scope != Scope::Metadata { - Self::release_working(doc); - } - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - // Comment edits, and form edits (a script may change annotation properties), may change - // annotation dictionaries, invalidating cached appearances. - if matches!(scope, Scope::Comments | Scope::Form) { - editor.appearances.clear(); - } - 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() - }; - if scope == Scope::Metadata { - for (field, value) in info_fields(&mut doc.info, &editor.cos) { - *field = value; - } - doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); - doc.info.file_size = bytes.len(); - #[cfg(test)] - check_refresh_panic(); - if !doc.signatures.is_empty() { - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.signatures = Arc::new(signatures); - } - doc.bytes = bytes; - return Ok(()); - } - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - let form = Arc::new(form); - match scope { - Scope::Metadata => return Ok(()), - Scope::Comments => { - doc.info.annotations = comment_list(&editor.cos); - // Link indexes are positions in /Annots, so comment insertion/deletion can move them. - doc.links = OnceLock::new(); - } - Scope::Form => { - // PERF: keystroke path, index once. A linear find per field is O(F^2); - // forms reach 10k fields. - let mut by_name = std::collections::HashMap::with_capacity(form.len()); - for x in form.iter() { - // First match wins (do not simplify to collect: it keeps the last). - by_name.entry(x.name.as_str()).or_insert(x); - } - for f in &mut doc.info.fields { - if let Some(ff) = by_name.get(f.name.as_str()) { - f.value = match ff.kind { - pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { - Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) - } - _ if ff.value.is_empty() => None, - _ => Some(ff.value.join(", ")), - }; - } - } - } - Scope::Links => return Self::refresh_links(doc), - // Handled above; kept as a full refresh so the match stays total. - Scope::Full => return Self::refresh(doc), - // Handled above (content-only refresh); the arm keeps the match total. - Scope::Content => return Self::refresh_content(doc, None), - } - doc.info.file_size = bytes.len(); - doc.form = form; - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - Ok(()) - } - - /// Which derived lists a content-only edit can change: added content (`None`, from - /// undo/redo, re-reads both: always correct, rarely paid). - pub(crate) fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { - if let Some(Edit::Batch { edits, .. }) = edit { - // A batch of content edits (from a script, say) changes what its parts change. - return edits.iter().fold((false, false), |(added, marks), part| { - let (a, m) = Self::content_refresh_lists(Some(part)); - (added || a, marks || m) - }); - } - let touches_added = edit.is_none_or(|e| { - matches!( - e, - Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) - }); - let touches_marks = edit.is_none_or(|e| { - matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) - }); - (touches_added, touches_marks) - } - - /// Rebuild working bytes, display bytes and renderer after a content-only edit: page - /// content streams, resources and added content/marks. The page tree, annotations, form - /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on - /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, - /// from undo/redo, re-reads both: always correct, rarely paid). The cached display - /// appearances are kept: content edits leave every annotation dictionary alone. - fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() 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() - }; - // The edit must still leave a file the viewer can open; anything else rolls back above. - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; - let (touches_added, touches_marks) = Self::content_refresh_lists(edit); - if touches_added || touches_marks { - doc.marker_scan = OnceLock::new(); - } - doc.info.file_size = bytes.len(); - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - doc.fonts_stale = true; - Ok(()) - } - - /// Let go of the working file before the next one is written (gap 13). The working file is - /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the - /// working bytes, the display bytes and the renderer) while writing the new one held three - /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes - /// are the original's (already held by the editor, so free) and the renderer draws nothing. - /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh - /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes - /// from the editor, never from these. - pub(crate) fn release_working(doc: &mut Document) { - let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; - // Dropping the pool stops its workers; each lets go of the file when it exits. - doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); - doc.display = original.clone(); - doc.bytes = original; - #[cfg(test)] - check_refresh_panic(); - } - - /// After a refresh failed: draw the display bytes it left (the original, once released), - /// so the page still shows a document. - fn show_released(doc: &mut Document) { - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - - /// Rebuild working bytes, inspection and renderer from the current edit state. - pub(crate) fn refresh(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, true, true) - } - - /// Reinspect a link edit while preserving the page-content marker scan cache. - fn refresh_links(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, false, true) - } - - fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { - // A full refresh may follow structural or annotation changes: cached appearances - // may be stale, so they are rebuilt below. - if let Some(editor) = doc.editor.as_mut() { - editor.appearances.clear(); - } - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() 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; - } - } - note_warnings(&mut info.warnings, &doc.xfa_warnings); - // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP - // are filled in again after every full refresh. - for (field, value) in info_fields(&mut info, &editor.cos) { - if field.is_none() { - *field = value; - } - } - doc.info = info; - doc.fonts_stale = false; - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - doc.form = Arc::new(form); - if invalidate_markers { - doc.marker_scan = OnceLock::new(); - } - if invalidate_links { - doc.links = OnceLock::new(); - } - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - 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. - /// - /// A file whose cross-reference data had to be rebuilt is written in full even when nothing - /// was edited: its bytes as opened are the damaged file, and this save is how the repair the - /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would - /// break its signatures, so with no edits it keeps its bytes, damage and all. - pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; - let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); - if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { - return Ok(editor.saved.clone()); - } - // What the user removed again before saving (a cleared redaction mark) stays out of the file. - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; - // A restored snapshot can be based on an earlier revision. Append to the last saved - // file so that revision stays in the chain. A full rewrite (redaction, new encryption, - // a reconstructed file) still writes the current graph on its own. - let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); - let cos = writable(&editor.cos)?; - guard(|| { - if on_saved || rewrite { - write_incremental(&cos, &opts) - } else { - let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; - base.overlay_changes_from(&cos)?; - write_incremental(&base, &opts) - } - }) - .map_err(EditError::Write)? - .map(Arc::new) - .map_err(|e| EditError::Write(e.to_string())) - } - - /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). - pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if doc.is_signed() { - return Err(EditError::Signed); - } - 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(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let cos = writable(&editor.cos)?; - guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this - /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the - /// file has been replaced. Same error as `mark_saved`. - pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; - pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) - } - - /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file - /// so the next save appends only newer edits. Undo history is kept. - pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - if let Some(editor) = doc.editor.as_mut() { - // An encrypted file needs the password it is protected with now (the owner - // password when known, so saving never downgrades this session's rights). - let keys = editor.keys.clone(); - // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. - let typed = pdfcraft_forms::password_values(&editor.cos); - editor.cos = - pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; - if !typed.is_empty() && doc.info.xfa.is_some() { - let mut datasets = None; - let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); - } - editor.saved = bytes; - editor.saved_keys = keys; - editor.unsaved_file = false; - } - 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).inspect_err(|_| Self::show_released(doc)) - } - - /// Enter the permissions (owner) password of a document opened with its user password, - /// lifting its restrictions for this session (Acrobat asks for it before changing security). - /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states - /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents - /// opened by the owner need nothing. - pub fn unlock(&mut self, id: DocId, password: &str) -> 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()))?; - if editor.cos.permissions().is_none_or(|p| p.owner) { - return Ok(()); - } - editor.cos.unlock(password).map_err(|e| match e { - pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, - other => EditError::Protection(other.to_string()), - })?; - // Saving a state that keeps the security it was opened with reopens it with the same - // passwords, so it reopens as owner. - let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { - if !cos.encryption_changed() { - keys.reopen = Some(password.to_owned()); - } - }; - reopen_as_owner(&editor.cos, &mut editor.keys); - // Other states with the same restricted access are unlocked too; states opened with - // other passwords (protection changed and saved since) stay as they are. - for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { - if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { - reopen_as_owner(cos, keys); - } - } - Ok(()) - } - - /// 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.saved.clone(); - // The saved file opens with the passwords it was saved with. The current snapshot may - // be an older revision (undo past that save), so its own base is not the saved file. - let mut candidates = vec![editor.saved_keys.clone(), 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| pdfcraft_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); - Self::sync_dirty(doc); - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open - /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA - /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, - /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was - /// opened with is tried; on any failure the document is left as it was. - pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { - let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; - let doc = &self.docs[old]; - let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); - let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); - let new_id = self.open(name, path, bytes, password.as_deref())?; - let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?; - let mut doc = self.docs.remove(at); - doc.id = id; - // How the document is shown is the reader's, not the file's: reading colours and hidden - // comments stay as they were (#3443). `open` built the renderer with the defaults. - if let Some(old) = self.docs.get(old) - && (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments) - { - doc.config.reading_colors = old.config.reading_colors; - doc.config.hide_comments = old.config.hide_comments; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - // Caches keyed by the edit generation (edit lines, images, measurements) must see a new - // one; there is nothing new to autosave. - doc.generation = generation + 1; - doc.snapshot_generation = doc.generation; - // `open` pushed the new version last, so the old one is still at `old`. - *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; - Ok(()) - } - - /// The page count of another PDF (Replace Pages, Insert Pages dialogs). - pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { - Ok(pdfcraft_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>, EditError> { - self.write_new(&pdfcraft_create::blank(width, height, pages)?) - } - - /// A new document with one page per image (PNG, JPEG). - pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images(images)?) - } - - /// Create image pages at embedded resolution or a fixed dpi, without resampling. - pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) - } - - /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is - /// refused, naming the line and the character (never set as `?`). - pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) - } - - /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, - /// compare reports), which can quote any language: characters Helvetica can't draw are written - /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. - pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { - let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; - self.write_new(&doc) - } - - /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked - /// (it must open and allow copying pages) and returned as it is. - pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { - let Some(kind) = source_kind(name, bytes) else { - return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); - }; - let title = name.rsplit_once('.').map_or(name, |(s, _)| s); - // Image decoders read untrusted bytes: a panic in one must not take the app down. - let created = guard(|| match kind { - SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), - SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), - SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), - }) - .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; - Ok((kind, created?)) - } - - /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as - /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf - /// without a `%PDF-` header, which the reader may still open. - pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { - let pdf = match source_kind(name, bytes) { - Some(SourceKind::Pdf) => true, - Some(SourceKind::Image | SourceKind::Text) => false, - None => name.to_ascii_lowercase().ends_with(".pdf"), - }; - if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } - } - - /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium - /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects - /// packed into compressed object streams. Returns the bytes and how many objects were - /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). - pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { - let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; - Ok((bytes, report.merged)) - } - - /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove - /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents - /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in - /// place, without progress. - pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { - self.optimize_job(id, settings, discard)?.run(|_| true) - } - - /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text - /// are form data only. - pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => { - pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? - } - }; - // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. - let xfa = doc.info.xfa.is_some(); - if format == DataFormat::Xdp && !xfa { - return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); - } - guard(|| match format { - DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), - DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), - DataFormat::Xml | DataFormat::Xdp if xfa => { - pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) - } - other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), - }) - .map_err(EditError::Write)? - .map_err(EditError::Write) - } - - /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). - pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if !doc.allows_printing() { - return Err(EditError::NotPermitted("printing")); - } - // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages - // always go as pictures, at no more than the low resolution. - let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { - dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), - grayscale: settings.raster.is_some_and(|r| r.grayscale), - }); - // Print as image: the pages used become pictures first, then the picture document is - // imposed in their place (its page k is the k-th distinct source page used). - if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { - let mut distinct: Vec = Vec::new(); - for &p in &settings.pages { - if !distinct.contains(&p) { - distinct.push(p); - } - } - let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; - let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; - let remapped = print::Settings { - pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), - content: print::Content::Document, - raster: None, - ..settings.clone() - }; - return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); - } - let mut cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => { - pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? - } - }; - // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer - // action) has changed one, the print copy's default configuration says which are on, so a - // hidden layer stays off on paper too; untouched, the document's own configuration prints. - if !doc.config.layers.is_empty() { - set_default_layer_state(&mut cos, &doc.info.layers); - } - pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) - } - - /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); - /// only `pages` are read, the others are `false`. - pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { - let Some(doc) = self.get(id) else { return Vec::new() }; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { - Ok(c) => c, - Err(_) => return Vec::new(), - }, - }; - (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() - } - - /// Combine whole files, in order, into new PDF bytes (one bookmark per file). - pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { - let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; - let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); - let out = pdfcraft_organize::combine(&named)?; - self.write_new(&out) - } - - /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). - pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { - self.combine_unlocked(sources, &[]) - } - - /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as - /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see - /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a - /// time: a source is opened, its pages are copied into the result, and its bytes are released - /// before the next item is read. So the number of sources is not limited, and one source's - /// document is held at a time. An item's error stops the combine and is returned as it is. - /// `Ok(None)` when there are no sources. - pub fn combine_each(&self, sources: I) -> Result>>, E> - where - I: IntoIterator>, - E: From, - { - let mut combiner = pdfcraft_organize::Combiner::new(); - let mut added = false; - for source in sources { - let (title, bytes, range) = source?; - let doc = open_source(&title, &bytes)?; - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let pages = match range { - Some(r) => { - let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{title}: {e}")))?; - Some(p) - } - None => None, - }; - combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; - added = true; - } - if !added { - return Ok(None); - } - let out = combiner.finish().map_err(EditError::from)?; - Ok(Some(self.write_new(&out)?)) - } - - /// [`Self::combine_ranges`] with the password each encrypted source is opened with, by - /// position (missing or `None`: no password). The result is not encrypted. - pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result>, EditError> { - Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) - } - - /// The pre-flight over byte sources: what combining them, in order, would rename in each one - /// (whole files; page ranges are the combine's to apply). A source that cannot be opened - /// previews as nothing — its row already says why it cannot be combined. - pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { - let docs: Vec> = - sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); - let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect(); - let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); - // One preview per source, in step with it: a file that cannot be opened has none. - docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() - } - - /// [`Self::combine_unlocked`] reporting what the conflict resolution renamed. - pub fn combine_unlocked_reporting( - &self, - sources: &[CombineSource], - passwords: &[Option<&str>], - field_conflicts: pdfcraft_organize::FieldConflicts, - ) -> Result { - let docs = sources - .iter() - .enumerate() - .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) - .collect::, _>>()?; - let mut pages = Vec::with_capacity(docs.len()); - for ((name, _, range), d) in sources.iter().zip(&docs) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> = - sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect(); - let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?; - let bytes = self.write_new(&out.doc)?; - Ok(Combined { bytes, renamed_fields: out.renamed_fields }) - } - - /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one - /// file split into several runs placed apart: each file is opened and copied once, so it keeps - /// one bookmark, its links between its own pages, its fields and its attachments whole (see - /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes - /// and password; a source without a key is a file of its own. - pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { - Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) - } - - /// [`Self::combine_grouped`] reporting what the conflict resolution renamed. - pub fn combine_grouped_reporting( - &self, - sources: &[CombineSource], - groups: &[u64], - passwords: &[Option<&str>], - field_conflicts: pdfcraft_organize::FieldConflicts, - ) -> Result { - let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); - for (i, (name, bytes, _)) in sources.iter().enumerate() { - let key = groups.get(i).copied(); - let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); - let at = match known { - Some(at) => at, - None => { - docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); - keys.push(key); - docs.len() - 1 - } - }; - file.push(at); - } - let mut pages = Vec::with_capacity(sources.len()); - for ((name, _, range), at) in sources.iter().zip(&file) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let mut runs: Vec> = Vec::with_capacity(sources.len()); - for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - runs.push((*at, name.as_str(), d, p.as_deref())); - } - let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?; - let bytes = self.write_new(&out.doc)?; - Ok(Combined { bytes, renamed_fields: out.renamed_fields }) - } - - /// New PDF bytes containing copies of `pages` of the document (Extract Pages). - pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("extracting pages")); - } - let out = pdfcraft_organize::extract_pages(src, pages)?; - self.write_new(&out) - } - - /// Split the document into several new PDFs. - pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - pdfcraft_organize::split_ranges(n, by) - .into_iter() - .map(|r| { - let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; - Ok((r.start + 1, r.end, self.write_new(&doc)?)) - }) - .collect() - } - - /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in - /// order while their size (as single-page files, an over-estimate because shared fonts and - /// images count once per part) fits; a page larger than the limit is a part of its own. - pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - let mut cuts = Vec::new(); - let mut used = 0usize; - for p in 0..n { - let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); - if used > 0 && used + size > max_bytes { - cuts.push(p); - used = 0; - } - used += size; - } - self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) - } - - /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle - /// per line in user space, URI). Text that already has a link over it is skipped. - pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); - let mut out = Vec::new(); - for page in 0..doc.info.pages.len() { - let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); - let Some(text) = res.text else { continue }; - let found: std::cell::RefCell, String)>> = Default::default(); - let hits = text.find_with(|chars| { - let urls = pdfcraft_annot::links::find_urls(chars); - let ranges = urls.iter().map(|u| u.0.clone()).collect(); - *found.borrow_mut() = urls; - ranges - }); - for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { - let rects: Vec<[f64; 4]> = text - .line_rects(glyphs) - .into_iter() - .map(|v| { - let q = doc.info.pages[page].view_rect_to_quad(v); - let xs = [q[0], q[2], q[4], q[6]]; - let ys = [q[1], q[3], q[5], q[7]]; - [ - xs.iter().copied().fold(f64::MAX, f64::min), - ys.iter().copied().fold(f64::MAX, f64::min), - xs.iter().copied().fold(f64::MIN, f64::max), - ys.iter().copied().fold(f64::MIN, f64::max), - ] - }) - .filter(|r| { - !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) - }) - .collect(); - if !rects.is_empty() { - out.push((page, rects, uri)); - } - } - } - out - } - - /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. - /// The document's own structure is read for it, so every bookmark can be reached by paging. - pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { - let doc = self.get(id)?; - if let Some(editor) = doc.editor.as_ref() { - let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); - let rows = page - .bookmarks - .into_iter() - .map(|b| { - let view = b.view.map_or_else(Default::default, |(kind, numbers)| { - let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); - pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) - }); - BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold } - }) - .collect(); - return Some(BookmarkPage { rows, next: page.next, truncated: false }); - } - let all = outline_rows(&doc.info.outline); - let end = offset.saturating_add(limit); - let rows = all.iter().skip(offset).take(limit).cloned().collect(); - Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) - } - - /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). - pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let top: Vec<(Option, String)> = match doc.editor.as_ref() { - Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), - None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), - }; - let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); - out.sort_by_key(|x| x.0); - out.dedup_by_key(|x| x.0); - out - } - - /// Open freshly created bytes (combine / extract) as a new, unsaved document. - pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { - let id = self.open(name, None, bytes, None)?; - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - d.dirty = true; - if let Some(e) = d.editor.as_mut() { - e.unsaved_file = true; - } - // Unsaved work with no edit yet still needs its first recovery snapshot. - d.generation += 1; - } - Ok(id) - } - - fn cos(&self, id: DocId) -> Result<&pdfcraft_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())) - } - - /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone - /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. - fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let bytes = self.write_unchecked(doc)?; - let expected = pdfcraft_organize::page_count(doc)?; - pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; - Ok(bytes) - } - - /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. - /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and - /// Split copy the fields of the pages they take. - fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let doc = writable(doc)?; - write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Whether a changed document still needs a recovery snapshot. This does not consume it. - pub fn has_pending_autosave(&self) -> bool { - self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) - } - - /// Documents with unsaved changes made since the last call: their current working file, - /// for crash recovery. Encrypted documents stay encrypted in the snapshot. - pub fn autosave_snapshots(&mut self) -> Vec { - let mut out = Vec::new(); - for d in &mut self.docs { - if d.dirty && d.generation != d.snapshot_generation { - // The working bytes hold typed Password-field values (#3926, #3944): a recovery file - // is written from a copy without them, and none is written if that copy fails. - let bytes = - match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { - Some(e) => match writable(&e.cos) - .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) - { - Ok(b) => Arc::new(b), - Err(_) => continue, - }, - None => d.bytes.clone(), - }; - d.snapshot_generation = d.generation; - out.push(RecoverySnapshot { - doc: d.id, - name: d.name.clone(), - path: d.path.clone(), - bytes, - encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), - }); - } - } - out - } - - /// Mark a document opened from a recovery file: it has unsaved changes and belongs at - /// `path` (where Save writes), as when the session ended. - pub fn mark_recovered(&mut self, id: DocId, path: Option) { - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - d.path = path; - d.dirty = true; - if let Some(e) = d.editor.as_mut() { - e.unsaved_file = 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; - use_layer_choices(doc); - true - } - - /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each - /// layer by its optional content group; groups that aren't layers are skipped. With - /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. - /// Returns `true` if any layer changed; callers must drop cached rasters and text for the - /// document, as for [`Self::set_layer_visible`]. - pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { - return false; - } - use_layer_choices(doc); - true - } - - /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns - /// `true` if any layer changed; callers must drop cached rasters and text for the document, - /// as for [`Self::set_layer_visible`]. - pub fn reset_layers(&mut self, id: DocId) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - let mut changed = false; - for l in &mut doc.info.layers { - changed |= l.visible != l.initial; - l.visible = l.initial; - } - if changed { - use_layer_choices(doc); - } - changed - } - - /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form - /// fields and links still draw. Returns whether anything changed. - pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.hide_comments == hide { - return false; - } - doc.config.hide_comments = hide; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether - /// anything changed; callers must drop cached rasters and text for the document. - pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.reading_colors == colors { - return false; - } - doc.config.reading_colors = colors; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// The document's reading-colours setting; `None` when no such document is open. - pub fn reading_colors(&self, id: DocId) -> Option { - Some(self.get(id)?.config.reading_colors) - } - - /// How the document's pages are drawn for viewing: its password and view settings (layer - /// visibility, hidden comments, reading colours). `None` when no such document is open. - pub fn render_config(&self, id: DocId) -> Option { - Some(self.get(id)?.config.clone()) - } - - /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment - /// with its number, author, type, date, text and replies, grouped by page. - pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let text = comment_summary(&doc.name, &doc.info.annotations, sort); - self.create_report(&format!("Summary of Comments on {}", doc.name), &text) - } - - /// The certificates trusted for signing. - pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { - &self.trust.certs - } - - /// Replace the trusted certificates, revalidate every open document's signatures, - /// and rebuild its display copy and renderer to match (working file untouched, - /// generation/dirty unchanged: trust alone is not an edit). - pub fn set_trusted_certificates(&mut self, certs: Vec) { - self.trust = Arc::new(TrustStore { certs }); - for doc in &mut self.docs { - doc.trust = self.trust.clone(); - let Some(editor) = doc.editor.as_mut() else { continue }; - // Keep verdict refresh and renderer refresh together. Viewer caches are - // invalidated by the UI trust path; trust alone changes no document artwork. - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &doc.bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - } - - /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). - pub fn signing_date(&self) -> String { - let offset = self.local_offset(); - let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); - let stamp = local.trim_end_matches('Z'); - if offset == 0 { - return format!("{stamp}Z"); - } - let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; - format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) - } - - /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns - /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty - /// `opts.date` takes the session clock. - pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - // Encrypted documents are signed under their permissions (the signer checks them). - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - // A typed Password-field value is not signed into the file (#3926), as it isn't saved. - let cos = writable(&editor.cos)?; - Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) - } - - /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by - /// `tsa`. The transport lives with the caller; the engine never opens a socket. - pub fn sign_with_timestamp( - &self, - doc: DocId, - id: &pdfcraft_sign::DigitalId, - mut opts: SignOptions, - tsa: &dyn pdfcraft_sign::TimestampAuthority, - ) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - let cos = writable(&editor.cos)?; - let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); - // Checked like a save before anyone writes it (#2166). - self.check_save(doc, &signed)?; - Ok(signed) - } - - /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's - /// current state. An empty `date` takes the session clock; the transport is the caller's. - pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let date = if date.is_empty() { self.signing_date() } else { date }; - let cos = writable(&editor.cos)?; - let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); - self.check_save(doc, &stamped)?; - Ok(stamped) - } - - /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature - /// (PAdES B-LT): an incremental update that never rewrites signed bytes. - pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let cos = writable(&editor.cos)?; - let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); - self.check_save(doc, &embedded)?; - Ok(embedded) - } - - /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], - /// and the edit history before signing is dropped (signing can't be undone). - pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - self.mark_saved(id, bytes, path)?; - let doc = self.doc_mut(id)?; - if let Some(e) = doc.editor.as_mut() { - e.undo.clear(); - e.redo.clear(); - } - Ok(()) - } - - 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) - } - - /// Read document `id`'s font list again if content edits since the last full inspection may - /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. - pub fn refresh_fonts(&mut self, id: DocId) { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; - if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { - doc.info.fonts = info.fonts; - } - doc.fonts_stale = false; - } - - pub fn docs(&self) -> &[Document] { - &self.docs - } -} diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs new file mode 100644 index 0000000000..9b1a8340a5 --- /dev/null +++ b/crates/engine/src/session/editing.rs @@ -0,0 +1,521 @@ +use super::*; + +impl Session { + /// 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 js_off = self.js_off; + let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); + let doc = self.doc_mut(id)?; + doc.edit_notices.clear(); + let name = doc.name.clone(); + let is_xfa = doc.info.xfa.is_some(); + let mut scope = scope_of(&edit); + let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); + if !js_off { + cx.xfa = doc.xfa_template.clone(); + } + cx.xfa_time_scale = xfa_time_scale; + cx.pages = doc.info.pages.len(); + cx.today = today; + let reason = doc.read_only_reason.clone().unwrap_or_default(); + let signed = doc.is_signed(); + let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; + if let Some(p) = editor.cos.permissions() { + check_permission(&edit, &p)?; + } + // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it + // in the paper's colour and draw the new text in the ink's. + let edit = match edit { + Edit::EditTextBlock { page, block, text, mut style } => { + // Only invisible text is sampled; the blocks are usually cached from opening it. + if style.cover.is_none() + && let Some(info) = doc.info.pages.get(page) + && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) + && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() + { + style.cover = Some(paper); + style.color = style.color.or(Some(ink)); + } + Edit::EditTextBlock { page, block, text, style } + } + other => other, + }; + // Nothing to bake: leave undo history and the dirty flag alone. + if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { + return Ok(()); + } + let mut next = editor.cos.clone(); + if !js_off && uses_scripts(&edit) { + cx.js = Some(js::JsRunner::new(&next, &name)); + } + cx.renamed_fields.clear(); + cx.redaction_overlays.clear(); + // `next` is a copy: if the edit fails or crashes, the document is unchanged. + guard(|| run_edit(&mut next, &edit, &mut cx)) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + // Redaction removed what its own interpreter places under the marks. The marks were drawn + // over the page as the renderer shows it, so the renderer has the last word. + if let Edit::ApplyRedactions { pages } = &edit { + let marks: Vec = + pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); + guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + } + // Renamed fields are said, not slipped in: a repair-log note (with the caveat that + // scripts were left alone) and a count in the undo step's label. + let renamed_note = |renamed: &[(String, String)]| { + (!renamed.is_empty()).then(|| { + let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); + let more = renamed.len().saturating_sub(shown.len()); + let more = if more > 0 { format!(", and {more} more") } else { String::new() }; + format!( + "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", + renamed.len(), + if renamed.len() == 1 { "" } else { "s" }, + shown.join(", ") + ) + }) + }; + if let Some(note) = renamed_note(&cx.renamed_fields) { + next.note_repair(note); + } + // Only Insert from File renames fields; its label says so in a wording the interface + // translates (the count lives in the repair note). + let label = match (&edit, cx.renamed_fields.len()) { + (_, 0) => edit.label(), + (Edit::InsertPagesFrom { name, .. }, n) => Label::with( + if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, + name.as_str(), + ), + _ => edit.label(), + }; + // An XFA form's scripts answer the change: exit and validate scripts of the field, then + // every calculation (and a reset recalculates). They read the data, so it is brought up + // to date first. + if let Some(tpl) = cx.xfa.clone() { + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let datasets = &mut cx.xfa_datasets; + let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + cx.xfa_out.errors.extend(notes); + } + fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { + match e { + Edit::SetFieldValue { name, .. } => changed.push(name.clone()), + Edit::ResetForm { .. } => *reset = true, + Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), + _ => {} + } + } + let mut changed: Vec = Vec::new(); + let mut reset = false; + collect(&edit, &mut changed, &mut reset); + if !changed.is_empty() || reset { + let mut run = xfa::XfaRun { + page_count: cx.pages, + out: &mut cx.xfa_out, + datasets: cx.xfa_datasets, + ran_away: false, + time_scale: cx.xfa_time_scale, + }; + // Every changed field's scripts, then the calculations once (after a reset, + // only those). + let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) + .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = ran { + cx.xfa_out.errors.push(e); + } + } + } + // XFA forms keep their values in the datasets packet too, for Adobe's viewers. + let mut xfa_notes = Vec::new(); + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); + xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + // Rebuilding from stale data would discard accepted widget values. + if xfa_notes.is_empty() { + let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) + .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + if let Some(warnings) = reflow { + xfa_notes.extend(warnings); + // Layout replaces pages and widgets, even if the page count is unchanged. + // Undo/redo must rebuild these too instead of refreshing only field values. + scope = Scope::Full; + } + } + } + // Signed documents are only ever saved incrementally: an edit that needs a full rewrite + // (applying redactions, changing security, sanitizing) would invalidate the signatures. + // So does any edit to a file rebuilt from object headers: it has no chain to append to. + let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); + if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { + return Err(EditError::SignedRewrite(edit.label().to_string())); + } + // What this edit drew with `?` (a value or comment text its font can't draw, stored as + // typed): reported, never silent (AGENTS.md §4). + let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { + guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() + } else { + Vec::new() + }; + 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); + editor.undo.push((label, previous, previous_keys, scope)); + if editor.undo.len() > MAX_UNDO { + editor.undo.remove(0); + } + editor.redo.clear(); + Self::adopt_keys(doc); + let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) + .unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + // 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); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.dirty = true; + doc.generation += 1; + doc.edit_notices = notices; + if !cx.xfa_removed.is_empty() { + let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + note_warnings(&mut doc.xfa_warnings, &removed); + note_warnings(&mut doc.info.warnings, &removed); + } + note_warnings(&mut doc.xfa_warnings, &xfa_notes); + let notes = doc.xfa_warnings.clone(); + note_warnings(&mut doc.info.warnings, ¬es); + if let Some(js) = cx.js { + doc.js_output.append(js.output); + } + doc.js_output.append(cx.xfa_out); + if cx.xfa_ran_away { + doc.xfa_template = None; + doc.xfa_scripts_off = true; + doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + Ok(()) + } + + pub fn undo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; + let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; + let current = std::mem::replace(&mut editor.cos, prev); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.redo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.redo.pop() + { + let prev = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.undo.push((label, prev, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + pub fn redo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; + let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; + let current = std::mem::replace(&mut editor.cos, next); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.undo.pop() + { + let next = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.redo.push((label, next, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + /// Unsaved when the working graph is not the last opened or saved file. Undo back to that + /// file is clean; undo past a later save is not, because the restored snapshot's own base + /// is an earlier revision. + pub(super) fn sync_dirty(doc: &mut Document) { + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + } + + /// Use the current state's password for viewing (protection edits change it). + pub(super) 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 the view data that `scope` may have changed. Metadata edits + /// keep the renderer; other scopes also replace it. + /// 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); + } + if scope == Scope::Content { + return Self::refresh_content(doc, None); + } + if scope == Scope::Links { + return Self::refresh_links(doc); + } + // Metadata edits keep the renderer, which holds the working file anyway. + if scope != Scope::Metadata { + Self::release_working(doc); + } + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + // Comment edits, and form edits (a script may change annotation properties), may change + // annotation dictionaries, invalidating cached appearances. + if matches!(scope, Scope::Comments | Scope::Form) { + editor.appearances.clear(); + } + 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() + }; + if scope == Scope::Metadata { + for (field, value) in info_fields(&mut doc.info, &editor.cos) { + *field = value; + } + doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); + doc.info.file_size = bytes.len(); + #[cfg(test)] + check_refresh_panic(); + if !doc.signatures.is_empty() { + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.signatures = Arc::new(signatures); + } + doc.bytes = bytes; + return Ok(()); + } + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + let form = Arc::new(form); + match scope { + Scope::Metadata => return Ok(()), + Scope::Comments => { + doc.info.annotations = comment_list(&editor.cos); + // Link indexes are positions in /Annots, so comment insertion/deletion can move them. + doc.links = OnceLock::new(); + } + Scope::Form => { + // PERF: keystroke path, index once. A linear find per field is O(F^2); + // forms reach 10k fields. + let mut by_name = std::collections::HashMap::with_capacity(form.len()); + for x in form.iter() { + // First match wins (do not simplify to collect: it keeps the last). + by_name.entry(x.name.as_str()).or_insert(x); + } + for f in &mut doc.info.fields { + if let Some(ff) = by_name.get(f.name.as_str()) { + f.value = match ff.kind { + pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { + Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) + } + _ if ff.value.is_empty() => None, + _ => Some(ff.value.join(", ")), + }; + } + } + } + Scope::Links => return Self::refresh_links(doc), + // Handled above; kept as a full refresh so the match stays total. + Scope::Full => return Self::refresh(doc), + // Handled above (content-only refresh); the arm keeps the match total. + Scope::Content => return Self::refresh_content(doc, None), + } + doc.info.file_size = bytes.len(); + doc.form = form; + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.bytes = bytes; + Ok(()) + } + + /// Which derived lists a content-only edit can change: added content (`None`, from + /// undo/redo, re-reads both: always correct, rarely paid). + pub(crate) fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { + if let Some(Edit::Batch { edits, .. }) = edit { + // A batch of content edits (from a script, say) changes what its parts change. + return edits.iter().fold((false, false), |(added, marks), part| { + let (a, m) = Self::content_refresh_lists(Some(part)); + (added || a, marks || m) + }); + } + let touches_added = edit.is_none_or(|e| { + matches!( + e, + Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) + }); + let touches_marks = edit.is_none_or(|e| { + matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) + }); + (touches_added, touches_marks) + } + + /// Rebuild working bytes, display bytes and renderer after a content-only edit: page + /// content streams, resources and added content/marks. The page tree, annotations, form + /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on + /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, + /// from undo/redo, re-reads both: always correct, rarely paid). The cached display + /// appearances are kept: content edits leave every annotation dictionary alone. + fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() 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() + }; + // The edit must still leave a file the viewer can open; anything else rolls back above. + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; + let (touches_added, touches_marks) = Self::content_refresh_lists(edit); + if touches_added || touches_marks { + doc.marker_scan = OnceLock::new(); + } + doc.info.file_size = bytes.len(); + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.bytes = bytes; + doc.fonts_stale = true; + Ok(()) + } + + /// Let go of the working file before the next one is written (gap 13). The working file is + /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the + /// working bytes, the display bytes and the renderer) while writing the new one held three + /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes + /// are the original's (already held by the editor, so free) and the renderer draws nothing. + /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh + /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes + /// from the editor, never from these. + pub(crate) fn release_working(doc: &mut Document) { + let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; + // Dropping the pool stops its workers; each lets go of the file when it exits. + doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); + doc.display = original.clone(); + doc.bytes = original; + #[cfg(test)] + check_refresh_panic(); + } + + /// After a refresh failed: draw the display bytes it left (the original, once released), + /// so the page still shows a document. + pub(super) fn show_released(doc: &mut Document) { + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + + /// Rebuild working bytes, inspection and renderer from the current edit state. + pub(crate) fn refresh(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, true, true) + } + + /// Reinspect a link edit while preserving the page-content marker scan cache. + fn refresh_links(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, false, true) + } + + fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { + // A full refresh may follow structural or annotation changes: cached appearances + // may be stale, so they are rebuilt below. + if let Some(editor) = doc.editor.as_mut() { + editor.appearances.clear(); + } + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() 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; + } + } + note_warnings(&mut info.warnings, &doc.xfa_warnings); + // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP + // are filled in again after every full refresh. + for (field, value) in info_fields(&mut info, &editor.cos) { + if field.is_none() { + *field = value; + } + } + doc.info = info; + doc.fonts_stale = false; + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + doc.form = Arc::new(form); + if invalidate_markers { + doc.marker_scan = OnceLock::new(); + } + if invalidate_links { + doc.links = OnceLock::new(); + } + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.bytes = bytes; + Ok(()) + } +} diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs new file mode 100644 index 0000000000..6d4e11d390 --- /dev/null +++ b/crates/engine/src/session/files.rs @@ -0,0 +1,397 @@ +use super::*; + +impl Session { + /// The page count of another PDF (Replace Pages, Insert Pages dialogs). + pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { + Ok(pdfcraft_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>, EditError> { + self.write_new(&pdfcraft_create::blank(width, height, pages)?) + } + + /// A new document with one page per image (PNG, JPEG). + pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images(images)?) + } + + /// Create image pages at embedded resolution or a fixed dpi, without resampling. + pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) + } + + /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is + /// refused, naming the line and the character (never set as `?`). + pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) + } + + /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, + /// compare reports), which can quote any language: characters Helvetica can't draw are written + /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. + pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { + let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; + self.write_new(&doc) + } + + /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked + /// (it must open and allow copying pages) and returned as it is. + pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + let Some(kind) = source_kind(name, bytes) else { + return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); + }; + let title = name.rsplit_once('.').map_or(name, |(s, _)| s); + // Image decoders read untrusted bytes: a panic in one must not take the app down. + let created = guard(|| match kind { + SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), + SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), + SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), + }) + .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; + Ok((kind, created?)) + } + + /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as + /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf + /// without a `%PDF-` header, which the reader may still open. + pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + let pdf = match source_kind(name, bytes) { + Some(SourceKind::Pdf) => true, + Some(SourceKind::Image | SourceKind::Text) => false, + None => name.to_ascii_lowercase().ends_with(".pdf"), + }; + if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } + } + + /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium + /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects + /// packed into compressed object streams. Returns the bytes and how many objects were + /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). + pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { + let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; + Ok((bytes, report.merged)) + } + + /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove + /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents + /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in + /// place, without progress. + pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { + self.optimize_job(id, settings, discard)?.run(|_| true) + } + + /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text + /// are form data only. + pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. + let xfa = doc.info.xfa.is_some(); + if format == DataFormat::Xdp && !xfa { + return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); + } + guard(|| match format { + DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), + DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), + DataFormat::Xml | DataFormat::Xdp if xfa => { + pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) + } + other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), + }) + .map_err(EditError::Write)? + .map_err(EditError::Write) + } + + /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). + pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if !doc.allows_printing() { + return Err(EditError::NotPermitted("printing")); + } + // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages + // always go as pictures, at no more than the low resolution. + let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { + dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), + grayscale: settings.raster.is_some_and(|r| r.grayscale), + }); + // Print as image: the pages used become pictures first, then the picture document is + // imposed in their place (its page k is the k-th distinct source page used). + if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { + let mut distinct: Vec = Vec::new(); + for &p in &settings.pages { + if !distinct.contains(&p) { + distinct.push(p); + } + } + let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; + let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; + let remapped = print::Settings { + pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), + content: print::Content::Document, + raster: None, + ..settings.clone() + }; + return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); + } + let mut cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer + // action) has changed one, the print copy's default configuration says which are on, so a + // hidden layer stays off on paper too; untouched, the document's own configuration prints. + if !doc.config.layers.is_empty() { + set_default_layer_state(&mut cos, &doc.info.layers); + } + pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) + } + + /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); + /// only `pages` are read, the others are `false`. + pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { + let Some(doc) = self.get(id) else { return Vec::new() }; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { + Ok(c) => c, + Err(_) => return Vec::new(), + }, + }; + (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() + } + + /// Combine whole files, in order, into new PDF bytes (one bookmark per file). + pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { + let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; + let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); + let out = pdfcraft_organize::combine(&named)?; + self.write_new(&out) + } + + /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). + pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { + self.combine_unlocked(sources, &[]) + } + + /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as + /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see + /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a + /// time: a source is opened, its pages are copied into the result, and its bytes are released + /// before the next item is read. So the number of sources is not limited, and one source's + /// document is held at a time. An item's error stops the combine and is returned as it is. + /// `Ok(None)` when there are no sources. + pub fn combine_each(&self, sources: I) -> Result>>, E> + where + I: IntoIterator>, + E: From, + { + let mut combiner = pdfcraft_organize::Combiner::new(); + let mut added = false; + for source in sources { + let (title, bytes, range) = source?; + let doc = open_source(&title, &bytes)?; + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let pages = match range { + Some(r) => { + let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{title}: {e}")))?; + Some(p) + } + None => None, + }; + combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; + added = true; + } + if !added { + return Ok(None); + } + let out = combiner.finish().map_err(EditError::from)?; + Ok(Some(self.write_new(&out)?)) + } + + /// [`Self::combine_ranges`] with the password each encrypted source is opened with, by + /// position (missing or `None`: no password). The result is not encrypted. + pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result>, EditError> { + Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) + } + + /// The pre-flight over byte sources: what combining them, in order, would rename in each one + /// (whole files; page ranges are the combine's to apply). A source that cannot be opened + /// previews as nothing — its row already says why it cannot be combined. + pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { + let docs: Vec> = + sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); + let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect(); + let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); + // One preview per source, in step with it: a file that cannot be opened has none. + docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() + } + + /// [`Self::combine_unlocked`] reporting what the conflict resolution renamed. + pub fn combine_unlocked_reporting( + &self, + sources: &[CombineSource], + passwords: &[Option<&str>], + field_conflicts: pdfcraft_organize::FieldConflicts, + ) -> Result { + let docs = sources + .iter() + .enumerate() + .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) + .collect::, _>>()?; + let mut pages = Vec::with_capacity(docs.len()); + for ((name, _, range), d) in sources.iter().zip(&docs) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> = + sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect(); + let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?; + let bytes = self.write_new(&out.doc)?; + Ok(Combined { bytes, renamed_fields: out.renamed_fields }) + } + + /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one + /// file split into several runs placed apart: each file is opened and copied once, so it keeps + /// one bookmark, its links between its own pages, its fields and its attachments whole (see + /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes + /// and password; a source without a key is a file of its own. + pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { + Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) + } + + /// [`Self::combine_grouped`] reporting what the conflict resolution renamed. + pub fn combine_grouped_reporting( + &self, + sources: &[CombineSource], + groups: &[u64], + passwords: &[Option<&str>], + field_conflicts: pdfcraft_organize::FieldConflicts, + ) -> Result { + let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); + for (i, (name, bytes, _)) in sources.iter().enumerate() { + let key = groups.get(i).copied(); + let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); + let at = match known { + Some(at) => at, + None => { + docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); + keys.push(key); + docs.len() - 1 + } + }; + file.push(at); + } + let mut pages = Vec::with_capacity(sources.len()); + for ((name, _, range), at) in sources.iter().zip(&file) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let mut runs: Vec> = Vec::with_capacity(sources.len()); + for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + runs.push((*at, name.as_str(), d, p.as_deref())); + } + let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?; + let bytes = self.write_new(&out.doc)?; + Ok(Combined { bytes, renamed_fields: out.renamed_fields }) + } + + /// New PDF bytes containing copies of `pages` of the document (Extract Pages). + pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("extracting pages")); + } + let out = pdfcraft_organize::extract_pages(src, pages)?; + self.write_new(&out) + } + + /// Split the document into several new PDFs. + pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + pdfcraft_organize::split_ranges(n, by) + .into_iter() + .map(|r| { + let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; + Ok((r.start + 1, r.end, self.write_new(&doc)?)) + }) + .collect() + } + + /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in + /// order while their size (as single-page files, an over-estimate because shared fonts and + /// images count once per part) fits; a page larger than the limit is a part of its own. + pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + let mut cuts = Vec::new(); + let mut used = 0usize; + for p in 0..n { + let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); + if used > 0 && used + size > max_bytes { + cuts.push(p); + used = 0; + } + used += size; + } + self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) + } + + fn cos(&self, id: DocId) -> Result<&pdfcraft_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())) + } + + /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone + /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. + fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let bytes = self.write_unchecked(doc)?; + let expected = pdfcraft_organize::page_count(doc)?; + pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; + Ok(bytes) + } + + /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. + /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and + /// Split copy the fields of the pages they take. + fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let doc = writable(doc)?; + write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } +} diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs new file mode 100644 index 0000000000..23994aee37 --- /dev/null +++ b/crates/engine/src/session/save.rs @@ -0,0 +1,335 @@ +use super::*; + +impl Session { + /// 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. + /// + /// A file whose cross-reference data had to be rebuilt is written in full even when nothing + /// was edited: its bytes as opened are the damaged file, and this save is how the repair the + /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would + /// break its signatures, so with no edits it keeps its bytes, damage and all. + pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; + let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); + if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { + return Ok(editor.saved.clone()); + } + // What the user removed again before saving (a cleared redaction mark) stays out of the file. + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; + // A restored snapshot can be based on an earlier revision. Append to the last saved + // file so that revision stays in the chain. A full rewrite (redaction, new encryption, + // a reconstructed file) still writes the current graph on its own. + let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + let cos = writable(&editor.cos)?; + guard(|| { + if on_saved || rewrite { + write_incremental(&cos, &opts) + } else { + let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; + base.overlay_changes_from(&cos)?; + write_incremental(&base, &opts) + } + }) + .map_err(EditError::Write)? + .map(Arc::new) + .map_err(|e| EditError::Write(e.to_string())) + } + + /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). + pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.is_signed() { + return Err(EditError::Signed); + } + 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(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let cos = writable(&editor.cos)?; + guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } + + /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this + /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the + /// file has been replaced. Same error as `mark_saved`. + pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; + pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) + } + + /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file + /// so the next save appends only newer edits. Undo history is kept. + pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + if let Some(editor) = doc.editor.as_mut() { + // An encrypted file needs the password it is protected with now (the owner + // password when known, so saving never downgrades this session's rights). + let keys = editor.keys.clone(); + // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. + let typed = pdfcraft_forms::password_values(&editor.cos); + editor.cos = + pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; + if !typed.is_empty() && doc.info.xfa.is_some() { + let mut datasets = None; + let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); + } + editor.saved = bytes; + editor.saved_keys = keys; + editor.unsaved_file = false; + } + 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).inspect_err(|_| Self::show_released(doc)) + } + + /// Enter the permissions (owner) password of a document opened with its user password, + /// lifting its restrictions for this session (Acrobat asks for it before changing security). + /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states + /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents + /// opened by the owner need nothing. + pub fn unlock(&mut self, id: DocId, password: &str) -> 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()))?; + if editor.cos.permissions().is_none_or(|p| p.owner) { + return Ok(()); + } + editor.cos.unlock(password).map_err(|e| match e { + pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, + other => EditError::Protection(other.to_string()), + })?; + // Saving a state that keeps the security it was opened with reopens it with the same + // passwords, so it reopens as owner. + let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { + if !cos.encryption_changed() { + keys.reopen = Some(password.to_owned()); + } + }; + reopen_as_owner(&editor.cos, &mut editor.keys); + // Other states with the same restricted access are unlocked too; states opened with + // other passwords (protection changed and saved since) stay as they are. + for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { + if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { + reopen_as_owner(cos, keys); + } + } + Ok(()) + } + + /// 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.saved.clone(); + // The saved file opens with the passwords it was saved with. The current snapshot may + // be an older revision (undo past that save), so its own base is not the saved file. + let mut candidates = vec![editor.saved_keys.clone(), 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| pdfcraft_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); + Self::sync_dirty(doc); + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open + /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA + /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, + /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was + /// opened with is tried; on any failure the document is left as it was. + pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { + let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; + let doc = &self.docs[old]; + let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); + let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); + let new_id = self.open(name, path, bytes, password.as_deref())?; + let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?; + let mut doc = self.docs.remove(at); + doc.id = id; + // How the document is shown is the reader's, not the file's: reading colours and hidden + // comments stay as they were (#3443). `open` built the renderer with the defaults. + if let Some(old) = self.docs.get(old) + && (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments) + { + doc.config.reading_colors = old.config.reading_colors; + doc.config.hide_comments = old.config.hide_comments; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + // Caches keyed by the edit generation (edit lines, images, measurements) must see a new + // one; there is nothing new to autosave. + doc.generation = generation + 1; + doc.snapshot_generation = doc.generation; + // `open` pushed the new version last, so the old one is still at `old`. + *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; + Ok(()) + } + + /// Whether a changed document still needs a recovery snapshot. This does not consume it. + pub fn has_pending_autosave(&self) -> bool { + self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) + } + + /// Documents with unsaved changes made since the last call: their current working file, + /// for crash recovery. Encrypted documents stay encrypted in the snapshot. + pub fn autosave_snapshots(&mut self) -> Vec { + let mut out = Vec::new(); + for d in &mut self.docs { + if d.dirty && d.generation != d.snapshot_generation { + // The working bytes hold typed Password-field values (#3926, #3944): a recovery file + // is written from a copy without them, and none is written if that copy fails. + let bytes = + match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { + Some(e) => match writable(&e.cos) + .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) + { + Ok(b) => Arc::new(b), + Err(_) => continue, + }, + None => d.bytes.clone(), + }; + d.snapshot_generation = d.generation; + out.push(RecoverySnapshot { + doc: d.id, + name: d.name.clone(), + path: d.path.clone(), + bytes, + encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), + }); + } + } + out + } + + /// Mark a document opened from a recovery file: it has unsaved changes and belongs at + /// `path` (where Save writes), as when the session ended. + pub fn mark_recovered(&mut self, id: DocId, path: Option) { + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + d.path = path; + d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } + d.generation += 1; + d.snapshot_generation = d.generation; // its bytes are already in the recovery store + } + } + + /// The certificates trusted for signing. + pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { + &self.trust.certs + } + + /// Replace the trusted certificates, revalidate every open document's signatures, + /// and rebuild its display copy and renderer to match (working file untouched, + /// generation/dirty unchanged: trust alone is not an edit). + pub fn set_trusted_certificates(&mut self, certs: Vec) { + self.trust = Arc::new(TrustStore { certs }); + for doc in &mut self.docs { + doc.trust = self.trust.clone(); + let Some(editor) = doc.editor.as_mut() else { continue }; + // Keep verdict refresh and renderer refresh together. Viewer caches are + // invalidated by the UI trust path; trust alone changes no document artwork. + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &doc.bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + } + + /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). + pub fn signing_date(&self) -> String { + let offset = self.local_offset(); + let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); + let stamp = local.trim_end_matches('Z'); + if offset == 0 { + return format!("{stamp}Z"); + } + let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; + format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) + } + + /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns + /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty + /// `opts.date` takes the session clock. + pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + // Encrypted documents are signed under their permissions (the signer checks them). + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + // A typed Password-field value is not signed into the file (#3926), as it isn't saved. + let cos = writable(&editor.cos)?; + Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) + } + + /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by + /// `tsa`. The transport lives with the caller; the engine never opens a socket. + pub fn sign_with_timestamp( + &self, + doc: DocId, + id: &pdfcraft_sign::DigitalId, + mut opts: SignOptions, + tsa: &dyn pdfcraft_sign::TimestampAuthority, + ) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + let cos = writable(&editor.cos)?; + let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); + // Checked like a save before anyone writes it (#2166). + self.check_save(doc, &signed)?; + Ok(signed) + } + + /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's + /// current state. An empty `date` takes the session clock; the transport is the caller's. + pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + let date = if date.is_empty() { self.signing_date() } else { date }; + let cos = writable(&editor.cos)?; + let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); + self.check_save(doc, &stamped)?; + Ok(stamped) + } + + /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature + /// (PAdES B-LT): an incremental update that never rewrites signed bytes. + pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + let cos = writable(&editor.cos)?; + let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); + self.check_save(doc, &embedded)?; + Ok(embedded) + } + + /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], + /// and the edit history before signing is dropped (signing can't be undone). + pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + self.mark_saved(id, bytes, path)?; + let doc = self.doc_mut(id)?; + if let Some(e) = doc.editor.as_mut() { + e.undo.clear(); + e.redo.clear(); + } + Ok(()) + } +} diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs new file mode 100644 index 0000000000..cef539189a --- /dev/null +++ b/crates/engine/src/session/view.rs @@ -0,0 +1,304 @@ +use super::*; + +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 + } + + /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the + /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script + /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the + /// scripts do passes a large factor, so that a busy machine does not stop them. On native + /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so + /// there the factor has no effect. + pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { + self.xfa_time_scale = Some(factor); + self + } + + /// A real clock for a host that has neither a clock nor a time zone of its own (a + /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so + /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in + /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every + /// use because the zone changes with the date (daylight saving). Unlike + /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. + pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { + self.clock = Some(clock); + self.utc_offset = Some(utc_offset); + self + } + + /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under + /// the test clock (which is already local), else the system's. + pub(super) fn local_offset(&self) -> i64 { + match (self.utc_offset, self.clock) { + (Some(zone), _) => zone(), + (None, Some(_)) => 0, + (None, None) => local_utc_offset(), + } + } + + /// Seconds since the Unix epoch from the session clock (0 when unknown). + pub fn now_secs(&self) -> i64 { + self.now().unwrap_or(0) + } + + pub(crate) fn now(&self) -> Option { + if let Some(c) = self.clock { + return Some(c()); + } + #[cfg(not(target_arch = "wasm32"))] + return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); + #[cfg(target_arch = "wasm32")] + return None; + } + + /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock + /// is taken as local time. + /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). + pub fn stamp_by_line(&self, author: &str) -> String { + let offset = self.local_offset(); + let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); + let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); + let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); + const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; + let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; + format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) + } + + pub fn today(&self) -> (i64, u32, u32) { + let offset = self.local_offset(); + self.now().map(|t| pdfcraft_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. + /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named + /// " (revision n)". + pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { + let doc = self.get(id).ok_or("no such document")?; + let ends = doc.revision_ends(); + let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; + let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); + let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); + self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) + } + + pub(crate) fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { + self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) + } + + /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle + /// per line in user space, URI). Text that already has a link over it is skipped. + pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let mut out = Vec::new(); + for page in 0..doc.info.pages.len() { + let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + let Some(text) = res.text else { continue }; + let found: std::cell::RefCell, String)>> = Default::default(); + let hits = text.find_with(|chars| { + let urls = pdfcraft_annot::links::find_urls(chars); + let ranges = urls.iter().map(|u| u.0.clone()).collect(); + *found.borrow_mut() = urls; + ranges + }); + for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { + let rects: Vec<[f64; 4]> = text + .line_rects(glyphs) + .into_iter() + .map(|v| { + let q = doc.info.pages[page].view_rect_to_quad(v); + let xs = [q[0], q[2], q[4], q[6]]; + let ys = [q[1], q[3], q[5], q[7]]; + [ + xs.iter().copied().fold(f64::MAX, f64::min), + ys.iter().copied().fold(f64::MAX, f64::min), + xs.iter().copied().fold(f64::MIN, f64::max), + ys.iter().copied().fold(f64::MIN, f64::max), + ] + }) + .filter(|r| { + !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) + }) + .collect(); + if !rects.is_empty() { + out.push((page, rects, uri)); + } + } + } + out + } + + /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. + /// The document's own structure is read for it, so every bookmark can be reached by paging. + pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { + let doc = self.get(id)?; + if let Some(editor) = doc.editor.as_ref() { + let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); + let rows = page + .bookmarks + .into_iter() + .map(|b| { + let view = b.view.map_or_else(Default::default, |(kind, numbers)| { + let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); + pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) + }); + BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold } + }) + .collect(); + return Some(BookmarkPage { rows, next: page.next, truncated: false }); + } + let all = outline_rows(&doc.info.outline); + let end = offset.saturating_add(limit); + let rows = all.iter().skip(offset).take(limit).cloned().collect(); + Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) + } + + /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). + pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let top: Vec<(Option, String)> = match doc.editor.as_ref() { + Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), + None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), + }; + let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); + out.sort_by_key(|x| x.0); + out.dedup_by_key(|x| x.0); + out + } + + /// Open freshly created bytes (combine / extract) as a new, unsaved document. + pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { + let id = self.open(name, None, bytes, None)?; + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } + // Unsaved work with no edit yet still needs its first recovery snapshot. + d.generation += 1; + } + Ok(id) + } + + /// 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; + use_layer_choices(doc); + true + } + + /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each + /// layer by its optional content group; groups that aren't layers are skipped. With + /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. + /// Returns `true` if any layer changed; callers must drop cached rasters and text for the + /// document, as for [`Self::set_layer_visible`]. + pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { + return false; + } + use_layer_choices(doc); + true + } + + /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns + /// `true` if any layer changed; callers must drop cached rasters and text for the document, + /// as for [`Self::set_layer_visible`]. + pub fn reset_layers(&mut self, id: DocId) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let mut changed = false; + for l in &mut doc.info.layers { + changed |= l.visible != l.initial; + l.visible = l.initial; + } + if changed { + use_layer_choices(doc); + } + changed + } + + /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form + /// fields and links still draw. Returns whether anything changed. + pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.hide_comments == hide { + return false; + } + doc.config.hide_comments = hide; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.generation += 1; + true + } + + /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether + /// anything changed; callers must drop cached rasters and text for the document. + pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.reading_colors == colors { + return false; + } + doc.config.reading_colors = colors; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.generation += 1; + true + } + + /// The document's reading-colours setting; `None` when no such document is open. + pub fn reading_colors(&self, id: DocId) -> Option { + Some(self.get(id)?.config.reading_colors) + } + + /// How the document's pages are drawn for viewing: its password and view settings (layer + /// visibility, hidden comments, reading colours). `None` when no such document is open. + pub fn render_config(&self, id: DocId) -> Option { + Some(self.get(id)?.config.clone()) + } + + /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment + /// with its number, author, type, date, text and replies, grouped by page. + pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let text = comment_summary(&doc.name, &doc.info.annotations, sort); + self.create_report(&format!("Summary of Comments on {}", doc.name), &text) + } + + 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) + } + + /// Read document `id`'s font list again if content edits since the last full inspection may + /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. + pub fn refresh_fonts(&mut self, id: DocId) { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; + if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { + doc.info.fonts = info.fonts; + } + doc.fonts_stale = false; + } + + pub fn docs(&self) -> &[Document] { + &self.docs + } +}