From cda2d019e3fe19a1229961f91dde260d4a76a599 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:37:06 -0400 Subject: [PATCH 001/246] build: pin craft-update to craft-libs main (495e597) (#4721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the update prompt fix that draws its close mark and version pill without glyphs a font may lack, so the prompt no longer takes the app down when a bundled face has no ✕ or →. Part of tracking issue storytold/craft-libs#319 🤖 Generated with Codebuff Co-authored-by: Codebuff --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 947dfb0c82..1acd1ff348 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1184,7 +1184,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index 39d11d1eaf..4d1d5e5f6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0 craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } From 83ba28680f6c780ed7ae553f25555765e4802aeb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:54:18 -0400 Subject: [PATCH 002/246] build: pin craft-update to craft-libs main (495e597) (#4728) Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down. Part of tracking issue storytold/craft-libs#319 --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1acd1ff348..fd3e1930fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1920,7 +1920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3352,7 +3352,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3913,7 +3913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5051,7 +5051,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5601,7 +5601,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5671,7 +5671,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6336,7 +6336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6558,7 +6558,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6944,7 +6944,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7670,7 +7670,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] From ecf62314160a2700d66ddd6c29def50346b07862 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:19:39 +0100 Subject: [PATCH 003/246] refactor(engine): move undo/redo state and labels to history.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/history.rs | 233 +++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 231 +--------------------------------- 2 files changed, 236 insertions(+), 228 deletions(-) create mode 100644 crates/engine/src/history.rs diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs new file mode 100644 index 0000000000..086d98a6ab --- /dev/null +++ b/crates/engine/src/history.rs @@ -0,0 +1,233 @@ +//! Undo/redo: the editing state of a document, its snapshots and their labels. + +use std::sync::Arc; + +use crate::{AppearanceCache, Edit, EditError, Shape}; + +/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. +pub(crate) const MAX_UNDO: usize = 100; + +/// The passwords that go with one state of a document (protection can change them). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Keys { + /// What the renderer and inspector open the working file with (the user password). + pub(crate) render: Option, + /// What the editor re-opens a saved file with: the strongest password known (owner if any). + pub(crate) reopen: Option, +} + +/// What an edit can change, and so what the view data must be rebuilt from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Scope { + /// Anything: re-inspect the whole document. + Full, + /// Document-information entries; page content and appearances are unchanged. + Metadata, + /// Only comments: re-read the comment list from the object graph. + Comments, + /// Only form field values (and their widget appearances). + Form, + /// Link annotations; the full inspection is refreshed, but page-content indexes stay valid. + Links, + /// Only 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 is skipped (seconds on very large files). + Content, +} + +/// Edits during which field scripts run. +pub(crate) fn uses_scripts(edit: &Edit) -> bool { + match edit { + Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, + Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), + _ => false, + } +} + +pub(crate) fn scope_of(edit: &Edit) -> Scope { + match edit { + Edit::SetInfo { .. } => Scope::Metadata, + // The Attachments list is read from the whole document. + Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, + // A file attachment also changes the Attachments list. + Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, + Edit::AddMeasurement(_) + | Edit::AddAnnotation(_) + | Edit::AddCustomStamp { .. } + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::LockAnnotation { .. } + | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } + | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } => Scope::Comments, + Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, + // Page content, resources and added content/marks only: the page tree, annotations, + // fields, links and document metadata are untouched (see `refresh_content`). + Edit::MoveObjects { .. } + | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } + | Edit::EditPageImage { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } => Scope::Content, + Edit::Batch { edits, .. } => { + let mut scopes = edits.iter().map(scope_of); + let first = scopes.next().unwrap_or(Scope::Full); + if scopes.all(|s| s == first) { first } else { Scope::Full } + } + _ => Scope::Full, + } +} + +/// One undo/redo step: its label, the document state, its passwords, and what it changed. +pub(crate) type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); + +/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). +#[derive(Clone)] +pub(crate) struct Editor { + pub(crate) cos: pdfcraft_cos::Document, + /// The file as last opened or saved. Incremental saves append to it, and Revert opens it. + /// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it. + pub(crate) saved: Arc>, + /// Passwords that open [`Self::saved`]. + pub(crate) saved_keys: Keys, + /// [`Self::saved`] was never written where the document belongs: a new document (combine, + /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever + /// undo brings back, or closing it would lose the whole document without asking. + pub(crate) unsaved_file: bool, + pub(crate) undo: Vec, + pub(crate) redo: Vec, + pub(crate) keys: Keys, + /// Display appearances built for annotations without any, reused while no annotation edit + /// runs (content edits leave every annotation dictionary alone). Cleared on any refresh + /// that may change annotations; see [`AppearanceCache`]. + pub(crate) appearances: AppearanceCache, + /// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused + /// for each widget while the objects it was drawn from are unchanged; see + /// [`pdfcraft_forms::FieldAppearanceCache`]. + pub(crate) field_appearances: pdfcraft_forms::FieldAppearanceCache, + /// The text blocks last computed for one page, reused while the document is unchanged. + pub(crate) text_blocks: TextBlocksCache, +} + +/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read +/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not +/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns +/// with undo, so a stale entry never matches, also after the document has been edited. +#[derive(Default)] +pub(crate) struct TextBlocksCache(std::sync::Mutex>); + +/// The document state, the page (0-based) and its text blocks. +type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); + +impl Clone for TextBlocksCache { + fn clone(&self) -> Self { + // A poisoned cache only loses the cached blocks. + Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone()))) + } +} + +impl Editor { + /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. + pub(crate) fn text_blocks(&self, page: usize) -> Result>, EditError> { + if let Ok(cache) = self.text_blocks.0.lock() + && let Some((state, p, blocks)) = cache.as_ref() + && *p == page + && state.matches(&self.cos) + { + return Ok(blocks.clone()); + } + let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); + if let Ok(mut cache) = self.text_blocks.0.lock() { + *cache = Some((self.cos.read_state(), page, blocks.clone())); + } + Ok(blocks) + } +} + +/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts: +/// a whole English wording, and the one value the document itself supplies for the wording's hole +/// (a file name, a field name). An interface in another language looks the wording up and says the +/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so +/// the two cannot drift apart. +#[derive(Clone, Debug, PartialEq)] +pub struct Label { + /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`]. + pub wording: &'static str, + /// What the document supplies, when the wording has a hole for it. + pub value: Option, + text: String, +} + +impl Label { + /// A wording with nothing to fill in. + pub fn of(wording: &'static str) -> Self { + Self { wording, value: None, text: wording.to_string() } + } + + /// A wording whose hole the document fills in. + pub fn with(wording: &'static str, value: impl Into) -> Self { + let mut label = Self { wording, value: Some(value.into()), text: String::new() }; + label.text = label.said(wording); + label + } + + /// The label read with this wording: the interface's translation of [`wording`](Self::wording), + /// or the English wording itself. What the document supplies goes in as it stands and is not + /// read again, so a name with braces in it stays whole. + pub fn said(&self, wording: &str) -> String { + let Some(value) = &self.value else { return wording.to_owned() }; + // `{` and `}` are ASCII, so these are char boundaries. + match (wording.find('{'), wording.find('}')) { + (Some(open), Some(close)) if open < close => { + let mut said = String::with_capacity(wording.len() + value.len()); + said.push_str(&wording[..open]); + said.push_str(value); + said.push_str(&wording[close + 1..]); + said + } + // A translation that lost its hole is said as it stands rather than losing the value. + _ => wording.to_owned(), + } + } + + /// The label as English reads it. + pub fn as_str(&self) -> &str { + &self.text + } +} + +impl std::fmt::Display for Label { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.text) + } +} + +/// The passwords after `edit`, if it changes them. +pub(crate) fn keys_after(edit: &Edit) -> Option { + match edit { + Edit::Protect(p) => { + Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) }) + } + Edit::RemoveProtection => Some(Keys::default()), + Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after), + _ => None, + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 224318f1d7..90c698dfe3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -25,6 +25,7 @@ mod copy_image; pub mod dates; pub mod export; pub mod field_table; +mod history; pub mod js; pub mod links; pub mod ocr; @@ -226,6 +227,8 @@ fn record_link_scan() { }); } +pub use history::Label; +use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; @@ -233,168 +236,10 @@ use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderC #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct DocId(pub u64); -/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. -const MAX_UNDO: usize = 100; - /// The highest resolution a document that allows only low-resolution printing (Table 22, bit 12 /// clear) is printed at. const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0; -/// The passwords that go with one state of a document (protection can change them). -#[derive(Clone, Debug, Default, PartialEq)] -struct Keys { - /// What the renderer and inspector open the working file with (the user password). - render: Option, - /// What the editor re-opens a saved file with: the strongest password known (owner if any). - reopen: Option, -} - -/// What an edit can change, and so what the view data must be rebuilt from. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Scope { - /// Anything: re-inspect the whole document. - Full, - /// Document-information entries; page content and appearances are unchanged. - Metadata, - /// Only comments: re-read the comment list from the object graph. - Comments, - /// Only form field values (and their widget appearances). - Form, - /// Link annotations; the full inspection is refreshed, but page-content indexes stay valid. - Links, - /// Only 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 is skipped (seconds on very large files). - Content, -} - -/// Edits during which field scripts run. -fn uses_scripts(edit: &Edit) -> bool { - match edit { - Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, - Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), - _ => false, - } -} - -fn scope_of(edit: &Edit) -> Scope { - match edit { - Edit::SetInfo { .. } => Scope::Metadata, - // The Attachments list is read from the whole document. - Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, - // A file attachment also changes the Attachments list. - Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, - Edit::AddMeasurement(_) - | Edit::AddAnnotation(_) - | Edit::AddCustomStamp { .. } - | Edit::DeleteAnnotation { .. } - | Edit::SetAnnotationContents { .. } - | Edit::ReplyToAnnotation { .. } - | Edit::SetAnnotationStatus { .. } - | Edit::MarkAnnotation { .. } - | Edit::LockAnnotation { .. } - | Edit::ReplaceText { .. } - | Edit::EraseInk { .. } - | Edit::MoveAnnotation { .. } - | Edit::ReshapeAnnotation { .. } - | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } - | Edit::FillAnnotation { .. } - | Edit::FontSizeAnnotation { .. } - | Edit::SetAnnotationInfo { .. } => Scope::Comments, - Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, - // Page content, resources and added content/marks only: the page tree, annotations, - // fields, links and document metadata are untouched (see `refresh_content`). - Edit::MoveObjects { .. } - | Edit::EditTextLine { .. } - | Edit::EditTextBlock { .. } - | Edit::EditPageImage { .. } - | Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - | Edit::AddHeaderFooter { .. } - | Edit::AddWatermark { .. } - | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } => Scope::Content, - Edit::Batch { edits, .. } => { - let mut scopes = edits.iter().map(scope_of); - let first = scopes.next().unwrap_or(Scope::Full); - if scopes.all(|s| s == first) { first } else { Scope::Full } - } - _ => Scope::Full, - } -} - -/// One undo/redo step: its label, the document state, its passwords, and what it changed. -type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); - -/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). -#[derive(Clone)] -struct Editor { - cos: pdfcraft_cos::Document, - /// The file as last opened or saved. Incremental saves append to it, and Revert opens it. - /// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it. - saved: Arc>, - /// Passwords that open [`Self::saved`]. - saved_keys: Keys, - /// [`Self::saved`] was never written where the document belongs: a new document (combine, - /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever - /// undo brings back, or closing it would lose the whole document without asking. - unsaved_file: bool, - undo: Vec, - redo: Vec, - keys: Keys, - /// Display appearances built for annotations without any, reused while no annotation edit - /// runs (content edits leave every annotation dictionary alone). Cleared on any refresh - /// that may change annotations; see [`AppearanceCache`]. - appearances: AppearanceCache, - /// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused - /// for each widget while the objects it was drawn from are unchanged; see - /// [`pdfcraft_forms::FieldAppearanceCache`]. - field_appearances: pdfcraft_forms::FieldAppearanceCache, - /// The text blocks last computed for one page, reused while the document is unchanged. - text_blocks: TextBlocksCache, -} - -/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read -/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not -/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns -/// with undo, so a stale entry never matches, also after the document has been edited. -#[derive(Default)] -struct TextBlocksCache(std::sync::Mutex>); - -/// The document state, the page (0-based) and its text blocks. -type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); - -impl Clone for TextBlocksCache { - fn clone(&self) -> Self { - // A poisoned cache only loses the cached blocks. - Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone()))) - } -} - -impl Editor { - /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. - fn text_blocks(&self, page: usize) -> Result>, EditError> { - if let Ok(cache) = self.text_blocks.0.lock() - && let Some((state, p, blocks)) = cache.as_ref() - && *p == page - && state.matches(&self.cos) - { - return Ok(blocks.clone()); - } - let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); - if let Ok(mut cache) = self.text_blocks.0.lock() { - *cache = Some((self.cos.read_state(), page, blocks.clone())); - } - Ok(blocks) - } -} - pub struct Document { pub id: DocId, pub name: String, @@ -1918,64 +1763,6 @@ pub enum Edit { }, } -/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts: -/// a whole English wording, and the one value the document itself supplies for the wording's hole -/// (a file name, a field name). An interface in another language looks the wording up and says the -/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so -/// the two cannot drift apart. -#[derive(Clone, Debug, PartialEq)] -pub struct Label { - /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`]. - pub wording: &'static str, - /// What the document supplies, when the wording has a hole for it. - pub value: Option, - text: String, -} - -impl Label { - /// A wording with nothing to fill in. - pub fn of(wording: &'static str) -> Self { - Self { wording, value: None, text: wording.to_string() } - } - - /// A wording whose hole the document fills in. - pub fn with(wording: &'static str, value: impl Into) -> Self { - let mut label = Self { wording, value: Some(value.into()), text: String::new() }; - label.text = label.said(wording); - label - } - - /// The label read with this wording: the interface's translation of [`wording`](Self::wording), - /// or the English wording itself. What the document supplies goes in as it stands and is not - /// read again, so a name with braces in it stays whole. - pub fn said(&self, wording: &str) -> String { - let Some(value) = &self.value else { return wording.to_owned() }; - // `{` and `}` are ASCII, so these are char boundaries. - match (wording.find('{'), wording.find('}')) { - (Some(open), Some(close)) if open < close => { - let mut said = String::with_capacity(wording.len() + value.len()); - said.push_str(&wording[..open]); - said.push_str(value); - said.push_str(&wording[close + 1..]); - said - } - // A translation that lost its hole is said as it stands rather than losing the value. - _ => wording.to_owned(), - } - } - - /// The label as English reads it. - pub fn as_str(&self) -> &str { - &self.text - } -} - -impl std::fmt::Display for Label { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.text) - } -} - /// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not /// here: its wording is only the hole, because a batch carries its name as text (see /// [`BATCH_LABELS`]). @@ -3051,18 +2838,6 @@ fn parse_ymd(d: &str) -> Option<(i64, u32, u32)> { Some((d.get(0..4)?.parse().ok()?, d.get(4..6)?.parse().ok()?, d.get(6..8)?.parse().ok()?)) } -/// The passwords after `edit`, if it changes them. -fn keys_after(edit: &Edit) -> Option { - match edit { - Edit::Protect(p) => { - Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) }) - } - Edit::RemoveProtection => Some(Keys::default()), - Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after), - _ => None, - } -} - /// The recovered user password of an R2–R4 file as text for the renderer, which tries a password /// as its UTF-8 bytes and then in PDFDocEncoding: UTF-8 bytes (as some writers store passwords /// PDFDocEncoding can't hold, such as "şifre") stay UTF-8, and other bytes are read as From 7e72b583edbfc942802b79aff453a3f8193da920 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:19:53 +0100 Subject: [PATCH 004/246] refactor(engine): move EditError and SourceProblem to error.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/error.rs | 80 ++++++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 79 +------------------------------------ 2 files changed, 82 insertions(+), 77 deletions(-) create mode 100644 crates/engine/src/error.rs diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs new file mode 100644 index 0000000000..5ee915fcdf --- /dev/null +++ b/crates/engine/src/error.rs @@ -0,0 +1,80 @@ +//! The errors editing and combining report. + +use crate::measure; + +/// Why a file can't be a source for Combine Files (or Insert / Replace pages). +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SourceProblem { + #[error("it is password-protected")] + Password, + #[error("the password is wrong")] + WrongPassword, + #[error("its security settings don't allow copying pages")] + NotPermitted, + #[error("{0}")] + Unreadable(String), +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum EditError { + #[error("no such document")] + NoDocument, + #[error("this document can't be edited: {0}")] + ReadOnly(String), + #[error("the document's security settings don't allow {0}; open it with the owner password to make this change")] + NotPermitted(&'static str), + #[error("{0}")] + Organize(#[from] pdfcraft_organize::OrganizeError), + #[error("{0}")] + Bookmark(#[from] pdfcraft_organize::OutlineError), + #[error("{0}")] + Comment(#[from] pdfcraft_annot::AnnotError), + #[error(transparent)] + Measure(#[from] measure::MeasureError), + #[error("{0}")] + Protection(String), + #[error("the password is incorrect")] + WrongPassword, + #[error("{0}")] + Form(#[from] pdfcraft_forms::FormError), + #[error(transparent)] + Redact(#[from] pdfcraft_redact::RedactError), + #[error("{0}")] + Print(String), + #[error("{0}")] + Accessibility(String), + #[error(transparent)] + Data(#[from] pdfcraft_xfdf::DataError), + #[error("{0}")] + Edit(#[from] pdfcraft_edit::EditError), + #[error("{0}")] + Create(#[from] pdfcraft_create::CreateError), + #[error("the edited document could not be written: {0}")] + Write(String), + #[error("the edited document could not be reopened: {0}")] + Reopen(String), + #[error("couldn't use {0}")] + Source(String), + #[error("nothing to undo")] + NothingToUndo, + #[error("nothing to redo")] + NothingToRedo, + #[error("{0}")] + Sign(String), + #[error("{0}")] + Optimize(String), + #[error("cancelled")] + Cancelled, + #[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")] + SignedRewrite(String), + #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] + Signed, + #[error("{0}")] + Invalid(String), +} + +impl From for EditError { + fn from(e: pdfcraft_sign::SignError) -> Self { + EditError::Sign(e.to_string()) + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 90c698dfe3..d34decd658 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod commands; pub mod compare; mod copy_image; pub mod dates; +mod error; pub mod export; pub mod field_table; mod history; @@ -227,6 +228,7 @@ fn record_link_scan() { }); } +pub use error::{EditError, SourceProblem}; pub use history::Label; use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; @@ -2889,19 +2891,6 @@ fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) } -/// Why a file can't be a source for Combine Files (or Insert / Replace pages). -#[derive(Debug, Clone, PartialEq, thiserror::Error)] -pub enum SourceProblem { - #[error("it is password-protected")] - Password, - #[error("the password is wrong")] - WrongPassword, - #[error("its security settings don't allow copying pages")] - NotPermitted, - #[error("{0}")] - Unreadable(String), -} - fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), @@ -2933,70 +2922,6 @@ fn plural(one: &'static str, many: &'static str, n: usize) -> Label { Label::of(if n == 1 { one } else { many }) } -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum EditError { - #[error("no such document")] - NoDocument, - #[error("this document can't be edited: {0}")] - ReadOnly(String), - #[error("the document's security settings don't allow {0}; open it with the owner password to make this change")] - NotPermitted(&'static str), - #[error("{0}")] - Organize(#[from] pdfcraft_organize::OrganizeError), - #[error("{0}")] - Bookmark(#[from] pdfcraft_organize::OutlineError), - #[error("{0}")] - Comment(#[from] pdfcraft_annot::AnnotError), - #[error(transparent)] - Measure(#[from] measure::MeasureError), - #[error("{0}")] - Protection(String), - #[error("the password is incorrect")] - WrongPassword, - #[error("{0}")] - Form(#[from] pdfcraft_forms::FormError), - #[error(transparent)] - Redact(#[from] pdfcraft_redact::RedactError), - #[error("{0}")] - Print(String), - #[error("{0}")] - Accessibility(String), - #[error(transparent)] - Data(#[from] pdfcraft_xfdf::DataError), - #[error("{0}")] - Edit(#[from] pdfcraft_edit::EditError), - #[error("{0}")] - Create(#[from] pdfcraft_create::CreateError), - #[error("the edited document could not be written: {0}")] - Write(String), - #[error("the edited document could not be reopened: {0}")] - Reopen(String), - #[error("couldn't use {0}")] - Source(String), - #[error("nothing to undo")] - NothingToUndo, - #[error("nothing to redo")] - NothingToRedo, - #[error("{0}")] - Sign(String), - #[error("{0}")] - Optimize(String), - #[error("cancelled")] - Cancelled, - #[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")] - SignedRewrite(String), - #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] - Signed, - #[error("{0}")] - Invalid(String), -} - -impl From for EditError { - fn from(e: pdfcraft_sign::SignError) -> Self { - EditError::Sign(e.to_string()) - } -} - #[derive(Default)] pub struct Session { docs: Vec, From 58e7e367172b08f6951cac4cf7b10010e8c708fd Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:20:51 +0100 Subject: [PATCH 005/246] refactor(engine): move the Edit enum, its labels, permission check and runner to edit.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/edit.rs | 1577 ++++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1590 +------------------------------------ 2 files changed, 1590 insertions(+), 1577 deletions(-) create mode 100644 crates/engine/src/edit.rs diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs new file mode 100644 index 0000000000..a29e4a870b --- /dev/null +++ b/crates/engine/src/edit.rs @@ -0,0 +1,1577 @@ +//! The edits a document takes: what each is called, whether its permissions allow it, and running it. + +use std::sync::Arc; + +use crate::{ + AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label, + LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState, + Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture, + image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, +}; + +/// Edits that can be applied to a document. Page indices are 0-based. +#[derive(Clone, Debug, PartialEq)] +pub enum Edit { + RotatePages { + pages: Vec, + degrees: i64, + }, + DeletePages { + pages: Vec, + }, + MovePages { + pages: Vec, + to: usize, + }, + /// `pages` reversed among the positions they occupy. + ReversePages { + pages: Vec, + }, + InsertBlankPage { + at: usize, + width: f64, + height: f64, + }, + /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after + /// the last of them when `at` is `None`. + DuplicatePages { + pages: Vec, + at: Option, + }, + /// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the + /// pages' links, comments and bookmarks stay. + ReplacePages { + pages: Vec, + name: String, + bytes: Arc>, + src_pages: Vec, + }, + /// Set Page Boxes / Crop: set (or reset) one box on `pages`. + SetPageBox { + pages: Vec, + which: PageBox, + spec: BoxSpec, + }, + /// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4), + /// Organize ▸ Page Transitions. + SetTransition { + pages: Vec, + spec: TransitionSpec, + }, + SetInfo { + key: String, + value: String, + }, + /// Insert pages from another PDF (all pages when `pages` is `None`) at position `at`. + InsertPagesFrom { + name: String, + bytes: Arc>, + pages: Option>, + at: usize, + /// How a form field whose name the document already uses is kept separate. + field_conflicts: pdfcraft_organize::FieldConflicts, + }, + /// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level). + /// It goes to the user-space point (`left`, `top`) of the page at the reader's zoom, and + /// either may be `None` to keep the reader's position that way (both `None` is the page top). + /// A coordinate that is not finite fails the edit. + AddBookmark { + parent: Vec, + index: usize, + title: String, + page: usize, + left: Option, + top: Option, + }, + RenameBookmark { + path: Vec, + title: String, + }, + /// Delete a bookmark and the bookmarks under it. + DeleteBookmark { + path: Vec, + }, + /// Move a bookmark to child `index` of `to_parent` (indices after removing it). + MoveBookmark { + from: Vec, + to_parent: Vec, + index: usize, + }, + /// Point a bookmark at another page. + SetBookmarkPage { + path: Vec, + page: usize, + }, + /// Colour a bookmark's title (`/C`); `None` or black is the default black. + SetBookmarkColor { + path: Vec, + color: Option<[f64; 3]>, + }, + /// Make a bookmark's title bold or italic or not (`/F`); `None` keeps that one. + SetBookmarkStyle { + path: Vec, + bold: Option, + italic: Option, + }, + /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first + /// "Untitled" bookmark (New Bookmarks from Structure). + BookmarksFromStructure, + /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. + NumberPages { + from: usize, + to: usize, + style: pdfcraft_organize::LabelStyle, + prefix: String, + first: u32, + }, + /// Add a calibrated distance, perimeter or area annotation. + AddMeasurement(measure::NewMeasurement), + /// Store a drawing scale for a rectangular viewport (PDF user space). + SetMeasurementScale { + page: usize, + bbox: [f64; 4], + name: String, + scale: measure::Scale, + }, + /// Add a comment (sticky note, highlight, shape, drawing, text box…). + AddAnnotation(NewAnnotation), + /// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size + /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). + /// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape, + /// and centred in it (#4077). + AddCustomStamp { + page: usize, + rect: [f64; 4], + name: String, + file: MarkFile, + author: String, + }, + /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. + DeleteAnnotation { + page: usize, + index: usize, + }, + SetAnnotationContents { + page: usize, + index: usize, + text: String, + }, + ReplyToAnnotation { + page: usize, + index: usize, + text: String, + author: String, + }, + /// Acrobat's "Set status" (a state reply by `author`). + SetAnnotationStatus { + page: usize, + index: usize, + state: ReviewState, + author: String, + }, + /// The Eraser: rub out the parts of drawing `(page, index)` near `path` (user space). + EraseInk { + page: usize, + index: usize, + path: Vec<[f64; 2]>, + radius: f64, + }, + /// Replace Text: strike out `quads` and add a grouped caret holding `text`. + ReplaceText { + page: usize, + quads: Vec<[f64; 8]>, + text: String, + author: String, + strike: Style, + caret: Style, + }, + /// Acrobat's "Mark with checkmark" (a hidden `/StateModel /Marked` reply by `author`). + MarkAnnotation { + page: usize, + index: usize, + marked: bool, + author: String, + }, + /// Properties ▸ Locked. + LockAnnotation { + page: usize, + index: usize, + locked: bool, + }, + MoveAnnotation { + page: usize, + index: usize, + dx: f64, + dy: f64, + }, + /// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's + /// two ends, the others' corners). + ReshapeAnnotation { + page: usize, + index: usize, + points: Vec<[f64; 2]>, + }, + /// Resize a rectangle, oval or text box. + ResizeAnnotation { + page: usize, + index: usize, + rect: [f64; 4], + }, + StyleAnnotation { + page: usize, + index: usize, + color: Option, + opacity: Option, + width: Option, + /// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged. + endings: Option>, + }, + /// Fill a rectangle, oval or polygon comment, or remove its fill (`None`). + FillAnnotation { + page: usize, + index: usize, + fill: Option, + }, + /// Change the font size (pt) of a text box, typewriter text or callout. + FontSizeAnnotation { + page: usize, + index: usize, + size: f64, + }, + /// Comment properties ▸ General / note icon. + SetAnnotationInfo { + page: usize, + index: usize, + author: Option, + subject: Option, + icon: Option, + }, + /// Fill in a form field. + SetFieldValue { + name: String, + value: FieldValue, + }, + /// Clear form: the named fields (all when `None`) back to their defaults. + ResetForm { + names: Option>, + }, + /// Prepare a form: add a field on a page (`rect` in PDF user space); `name` defaults to + /// Acrobat's next free name ("Text1", "Check Box2", …). + AddField { + page: usize, + rect: [f64; 4], + kind: NewField, + name: Option, + }, + /// Field Properties ▸ General / Options. + SetFieldProps { + name: String, + props: Box, + }, + /// An image field (or any button): show this image file (PNG, JPEG, TIFF, GIF, BMP). + SetFieldImage { + name: String, + image: Arc>, + }, + /// Duplicate a field onto other pages (same field, shared value). + DuplicateField { + name: String, + pages: Vec, + }, + DeleteField { + name: String, + }, + /// Page properties ▸ Tab order, for pages (0-based). + SetTabOrder { + pages: Vec, + order: TabOrder, + }, + /// Document Properties ▸ Initial View (and the language and binding). + SetInitialView(Box), + /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under + /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. + AddAttachment { + name: String, + bytes: Vec, + description: Option, + mime: Option, + }, + /// Remove the document-level attachment whose name-tree key is `key`. + DeleteAttachment { + key: String, + }, + /// Build a tagged structure tree for an untagged, text-only document. + AutoTagText, + /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is + /// the figure element's object number (from `Document::figures`). + SetAltText { + figure: u32, + alt: Option, + }, + /// Add `/Form` tags and object references for all widgets in an untagged AcroForm. + AutoTagFormFields, + /// Change one property on a reachable indirect structure tag. + SetTagProperty { + object: pdfcraft_cos::ObjRef, + property: a11y::TagProperty, + value: Option, + }, + /// Move a structure tag before or after a direct sibling in reading order. + MoveTag { + object: pdfcraft_cos::ObjRef, + anchor: pdfcraft_cos::ObjRef, + placement: a11y::TagPlacement, + }, + /// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact. + MarkDecorative { + figure: u32, + }, + /// Tags panel ▸ Mark as artifact: retire a leaf tag and mark its content as an artifact. + MarkTagArtifact { + object: pdfcraft_cos::ObjRef, + }, + /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level + /// script `name`. + SetDocumentScript { + name: String, + script: Option, + }, + /// Field Properties ▸ a custom script: set or remove field `name`'s JavaScript for `event` + /// (keystroke, format, validate, calculate or mouse_up). + SetFieldScript { + name: String, + event: String, + script: Option, + }, + /// Standards ▸ Save as PDF/A: fix what can be fixed for `level` (metadata, output intent, + /// forbidden actions, annotation flags, …). + ConvertPdfA { + level: pdfcraft_preflight::Level, + }, + /// Remove what identifies the document as meeting `standard` (PDF/A or PDF/X). + RemoveStandardId { + standard: Standard, + }, + /// What a script (button or console) changed in form fields: values, read-only, required + /// and visibility. + ApplyScriptChanges { + changes: Vec, + }, + /// An XFA form's scripted event (a button's `click`, say) on the object at `som`: what the + /// script changes (values, rows, visibility) is applied and the form laid out again. + XfaEvent { + som: String, + activity: String, + }, + /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the + /// image (from [`ocr::OcrJob::run`]). + AddOcrText { + page: usize, + words: Vec, + }, + /// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on + /// `page`, in its own font when it can show it, else in Helvetica. + EditTextLine { + page: usize, + line: usize, + text: String, + }, + /// Edit a PDF ▸ an existing image on `page` (index from `Document::page_images`). + EditPageImage { + page: usize, + index: usize, + change: ImageEdit, + }, + /// Move mixed existing and added objects together. References resolve before any change; + /// offset is a user-space vector. No retyping, reflow or font substitution is involved. + MoveObjects { + page: usize, + objects: Vec, + offset: [f64; 2], + }, + /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), + /// rewrapped to the box's width. + EditTextBlock { + page: usize, + block: usize, + text: String, + /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. + style: pdfcraft_edit::BlockStyle, + }, + /// An explicit open-licensed full font program, completing subsets or selected by a user. + RegisterTextFont { + page: usize, + bytes: Arc>, + }, + /// Order tabs manually: move a field one place earlier or later on its page. + MoveInTabOrder { + name: String, + earlier: bool, + }, + /// Set the calculation order: these calculated fields first, in this order, then the others + /// in their current order (see [`Document::calculation_order`]). The form recalculates. + SetCalculationOrder { + names: Vec, + }, + /// Add a header and footer (with `replace`, existing ones on those pages go first). + AddHeaderFooter { + pages: Vec, + settings: HeaderFooter, + replace: bool, + }, + /// `file`: a picture from a file (an image, or a page of a PDF) instead of text. + AddWatermark { + pages: Vec, + settings: Watermark, + replace: bool, + file: Option, + }, + /// `file`: a picture from a file instead of a colour. + AddBackground { + pages: Vec, + settings: Background, + replace: bool, + file: Option, + }, + /// Remove every mark of a kind from every page. + RemoveMarks { + kind: MarkKind, + }, + /// Edit a PDF ▸ Add content ▸ Text (`text.rect` in display space). + AddText { + page: usize, + text: AddedText, + }, + /// Edit a PDF ▸ Add content ▸ Image: an image file placed at `rect` (display space), or at + /// its natural size centred on the page (shrunk to fit). + AddImage { + page: usize, + rect: Option<[f64; 4]>, + name: String, + bytes: Arc>, + }, + /// Move, resize, retype or reformat an added item (`index` among the page's added items). + UpdateContent { + page: usize, + index: usize, + content: AddedContent, + }, + DeleteContent { + page: usize, + index: usize, + }, + /// Replace an added image with another file (keeping its box and transforms). + ReplaceImage { + page: usize, + index: usize, + name: String, + bytes: Arc>, + }, + /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its + /// style, an image in its box (display space) with its turns, flips and crop. An image's + /// picture is `file` (from [`Document::added_image_file`]): the copied image object is reused + /// while it is still that picture in this document, else one an added image here already draws + /// it from, else the file is embedded. + PasteContent { + page: usize, + content: AddedContent, + file: Option<(String, Arc>)>, + }, + /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. + ApplyRedactions { + pages: Option>, + }, + /// Remove redaction marks without applying them. + ClearRedactions, + /// Edit a PDF ▸ Link: a new link over `rect` (user space). + AddLink { + page: usize, + rect: [f64; 4], + action: LinkAction, + style: LinkStyle, + }, + /// Link Properties (`index` in the page's `/Annots`). + SetLink { + page: usize, + index: usize, + rect: Option<[f64; 4]>, + action: Option, + style: Option, + }, + DeleteLink { + page: usize, + index: usize, + }, + /// Remove all links (on `pages`, or everywhere). + RemoveLinks { + pages: Option>, + }, + /// Create links from URLs in the text: (page, line rects in user space, URI). + AddLinks { + links: Vec<(usize, Vec<[f64; 4]>, String)>, + style: LinkStyle, + }, + /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text). + ImportData { + name: String, + bytes: Arc>, + }, + /// Remove Hidden Information: the chosen categories. + RemoveHidden { + which: Vec, + }, + /// Sanitize Document: every category, then a full rewrite on save. + Sanitize, + /// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and + /// PdfCraft's private keys go, the pages as laid out and the fields with their values + /// stay, so every viewer shows the same form and none lays it out again. Its scripts no + /// longer run. + XfaToStatic, + /// Flatten comments and/or form fields on every page into page content. + Flatten { + comments: bool, + fields: bool, + }, + /// Bake Fill & Sign text, marks and signatures into the page. Other comments and fields stay. + /// A no-op (no undo step) when the document has none. + FlattenFillSign, + /// Protect with passwords and permissions (written by the next save, which is a full rewrite). + Protect(Protection), + /// Remove password security (needs the owner password). + RemoveProtection, + /// Several edits applied as one undoable step (all or nothing). + Batch { + label: String, + edits: Vec, + }, +} + +/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not +/// here: its wording is only the hole, because a batch carries its name as text (see +/// [`BATCH_LABELS`]). +pub const EDIT_LABELS: &[&str] = &[ + "Rotate page", + "Change tag properties", + "Rotate pages", + "Delete page", + "Delete pages", + "Move page", + "Move pages", + "Reverse pages", + "Insert blank page", + "Duplicate page", + "Duplicate pages", + "Replace page", + "Replace pages", + "Crop page", + "Crop pages", + "Set page boxes", + "Set page transitions", + "Change Title", + "Change Author", + "Change Subject", + "Change Keywords", + "Change {key}", + "Insert pages from {name}", + "Insert pages from {name} — 1 field renamed", + "Insert pages from {name} — fields renamed", + "Attach {name}", + "Delete attachment {key}", + "Add bookmark", + "Rename bookmark", + "Delete bookmark", + "Move bookmark", + "Set bookmark destination", + "Colour", + "Style", + "New bookmarks from structure", + "Number pages", + "Measure distance", + "Measure perimeter", + "Measure area", + "Set measurement scale", + "Add sticky note", + "Add highlight", + "Add underline", + "Add strikethrough", + "Add squiggly underline", + "Add rectangle", + "Add oval", + "Add arrow", + "Add line", + "Add drawing", + "Add text box", + "Add checkmark", + "Add cross", + "Add dot", + "Add signature", + "Add redaction mark", + "Add cloud", + "Add polygon", + "Add connected lines", + "Add callout", + "Add inserted text", + "Add file attachment", + "Add stamp", + "Delete comment", + "Edit comment", + "Reply", + "Set status None", + "Set status Accepted", + "Set status Rejected", + "Set status Cancelled", + "Set status Completed", + "Replace text", + "Erase", + "Mark with checkmark", + "Remove checkmark", + "Lock comment", + "Unlock comment", + "Move comment", + "Reshape comment", + "Resize comment", + "Change comment properties", + "Fill in {name}", + "Set the image of {name}", + "Clear form", + "Add field", + "Change field properties", + "Delete field", + "Duplicate field", + "Set tab order", + "Set calculation order", + "Change initial view", + "Set alternate text", + "Mark figure as decorative", + "Recognize text", + "Run JavaScript", + "Run form script", + "Save as {level}", + "Remove {standard} identification", + "Edit script of {name}", + "Delete document JavaScript", + "Edit document JavaScript", + "Edit text", + "Load document font", + "Move object", + "Move objects", + "Move image", + "Rotate image", + "Flip image", + "Replace image", + "Delete image", + "Add header & footer", + "Update header & footer", + "Add watermark", + "Update watermark", + "Add background", + "Update background", + "Remove header & footer", + "Remove watermark", + "Remove background", + "Add text", + "Add image", + "Edit content", + "Delete content", + "Paste", + "Apply redactions", + "Remove redaction marks", + "Remove hidden information", + "Import {name}", + "Add link", + "Change link properties", + "Delete link", + "Remove all links", + "Create link", + "Create links", + "Sanitize document", + "Flatten comments", + "Flatten form fields", + "Flatten", + "Flatten Fill & Sign", + "Protect with password", + "Remove security", +]; + +/// The names of the batches built below L7, which an interface shows in the same places as +/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives +/// already worded in the interface's language. +pub const BATCH_LABELS: &[&str] = + &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; + +impl Edit { + /// Label for the Edit menu and history ("Undo Rotate pages"). + pub fn label(&self) -> Label { + match self { + Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), + Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), + Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), + Edit::ReversePages { .. } => Label::of("Reverse pages"), + Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), + Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), + Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), + Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), + Edit::SetPageBox { .. } => Label::of("Set page boxes"), + Edit::SetTransition { .. } => Label::of("Set page transitions"), + // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can + // be said in another language; any other key is the document's and goes in as it stands. + Edit::SetInfo { key, .. } => match key.as_str() { + "Title" => Label::of("Change Title"), + "Author" => Label::of("Change Author"), + "Subject" => Label::of("Change Subject"), + "Keywords" => Label::of("Change Keywords"), + _ => Label::with("Change {key}", key.as_str()), + }, + Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), + Edit::AddBookmark { .. } => Label::of("Add bookmark"), + Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), + Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), + Edit::MoveBookmark { .. } => Label::of("Move bookmark"), + Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), + // The bookmark Style and Colour menus' own names, already said in every language. + Edit::SetBookmarkColor { .. } => Label::of("Colour"), + Edit::SetBookmarkStyle { .. } => Label::of("Style"), + Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), + Edit::NumberPages { .. } => Label::of("Number pages"), + // What was measured is part of the wording too, for the same reason. + Edit::AddMeasurement(m) => Label::of(match m.kind { + pdfcraft_measure::Kind::Distance => "Measure distance", + pdfcraft_measure::Kind::Perimeter => "Measure perimeter", + pdfcraft_measure::Kind::Area => "Measure area", + }), + Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), + Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), + Edit::AddCustomStamp { .. } => Label::of("Add stamp"), + Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), + Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), + Edit::ReplyToAnnotation { .. } => Label::of("Reply"), + // The state's name is part of the wording: a language may need to agree it with the rest. + Edit::SetAnnotationStatus { state, .. } => Label::of(match state { + ReviewState::None => "Set status None", + ReviewState::Accepted => "Set status Accepted", + ReviewState::Rejected => "Set status Rejected", + ReviewState::Cancelled => "Set status Cancelled", + ReviewState::Completed => "Set status Completed", + }), + Edit::ReplaceText { .. } => Label::of("Replace text"), + Edit::EraseInk { .. } => Label::of("Erase"), + Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), + Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), + Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), + Edit::LockAnnotation { .. } => Label::of("Unlock comment"), + Edit::MoveAnnotation { .. } => Label::of("Move comment"), + Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), + Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), + Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { + Label::of("Change comment properties") + } + Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), + Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), + Edit::ResetForm { .. } => Label::of("Clear form"), + Edit::AddField { .. } => Label::of("Add field"), + Edit::SetFieldProps { .. } => Label::of("Change field properties"), + Edit::DeleteField { .. } => Label::of("Delete field"), + Edit::DuplicateField { .. } => Label::of("Duplicate field"), + Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), + Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), + Edit::SetInitialView(_) => Label::of("Change initial view"), + Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), + Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), + Edit::AutoTagText => Label::of("Automatically tag text"), + Edit::SetAltText { .. } => Label::of("Set alternate text"), + Edit::AutoTagFormFields => Label::of("Tag form fields"), + Edit::SetTagProperty { .. } => Label::of("Change tag properties"), + Edit::MoveTag { .. } => Label::of("Move object"), + Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), + Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), + Edit::AddOcrText { .. } => Label::of("Recognize text"), + Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), + Edit::XfaEvent { .. } => Label::of("Run form script"), + // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. + Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), + Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), + Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), + Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), + Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), + Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), + Edit::RegisterTextFont { .. } => Label::of("Load document font"), + Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), + Edit::EditPageImage { change, .. } => Label::of(match change { + ImageEdit::Move(_) => "Move image", + ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", + ImageEdit::Crop(_) => "Crop image", + ImageEdit::Flip { .. } => "Flip image", + ImageEdit::Replace { .. } => "Replace image", + ImageEdit::Delete => "Delete image", + }), + Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), + Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), + Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), + Edit::AddWatermark { .. } => Label::of("Update watermark"), + Edit::AddBackground { replace: false, .. } => Label::of("Add background"), + Edit::AddBackground { .. } => Label::of("Update background"), + Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), + Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), + Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), + Edit::AddText { .. } => Label::of("Add text"), + Edit::AddImage { .. } => Label::of("Add image"), + Edit::UpdateContent { .. } => Label::of("Edit content"), + Edit::DeleteContent { .. } => Label::of("Delete content"), + Edit::ReplaceImage { .. } => Label::of("Replace image"), + Edit::PasteContent { .. } => Label::of("Paste"), + Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), + Edit::ClearRedactions => Label::of("Remove redaction marks"), + Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), + Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), + Edit::AddLink { .. } => Label::of("Add link"), + Edit::SetLink { .. } => Label::of("Change link properties"), + Edit::DeleteLink { .. } => Label::of("Delete link"), + Edit::RemoveLinks { .. } => Label::of("Remove all links"), + Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), + Edit::Sanitize => Label::of("Sanitize document"), + Edit::XfaToStatic => Label::of("Convert to a static form"), + Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), + Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), + Edit::Flatten { .. } => Label::of("Flatten"), + Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), + Edit::Protect(_) => Label::of("Protect with password"), + Edit::RemoveProtection => Label::of("Remove security"), + // A batch carries its own name, already worded by whoever built it. + // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). + Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), + } + } +} + +/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, +/// so a language can give the noun the article and the gender its own grammar asks for. +fn add_annotation(s: &Shape) -> &'static str { + match s { + Shape::Note { .. } => "Add sticky note", + Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", + Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", + Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", + Shape::Rectangle { .. } => "Add rectangle", + Shape::Oval { .. } => "Add oval", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", + Shape::Line { .. } => "Add line", + Shape::Ink { .. } => "Add drawing", + Shape::TextBox { .. } => "Add text box", + Shape::Typewriter { .. } => "Add text", + Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", + Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", + Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", + Shape::Mark { mark: FillMark::Line, .. } => "Add line", + Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", + Shape::Redact { .. } => "Add redaction mark", + Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", + Shape::Polygon { cloud: true, .. } => "Add cloud", + Shape::Polygon { .. } => "Add polygon", + Shape::PolyLine { .. } => "Add connected lines", + Shape::Callout { .. } => "Add callout", + Shape::Caret { .. } => "Add inserted text", + Shape::Attachment { .. } => "Add file attachment", + } +} + +/// Whether the opening password allows an edit (§7.6.4.2, Table 22). +pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { + match edit { + Edit::RotatePages { .. } + | Edit::DeletePages { .. } + | Edit::MovePages { .. } + | Edit::ReversePages { .. } + | Edit::InsertBlankPage { .. } + | Edit::DuplicatePages { .. } + | Edit::ReplacePages { .. } + | Edit::SetPageBox { .. } + | Edit::SetTransition { .. } + | Edit::InsertPagesFrom { .. } + // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). + | Edit::AddBookmark { .. } + | Edit::RenameBookmark { .. } + | Edit::DeleteBookmark { .. } + | Edit::MoveBookmark { .. } + | Edit::SetBookmarkPage { .. } + | Edit::SetBookmarkColor { .. } + | Edit::SetBookmarkStyle { .. } + | Edit::BookmarksFromStructure + | Edit::NumberPages { .. } => { + if p.assemble() { + Ok(()) + } else { + Err(EditError::NotPermitted("page changes")) + } + } + Edit::AddMeasurement(_) + | Edit::AddAnnotation(_) + | Edit::AddCustomStamp { .. } + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } + | Edit::LockAnnotation { .. } + | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } + | Edit::SetMeasurementScale { .. } => { + if p.annotate() { + Ok(()) + } else { + Err(EditError::NotPermitted("comments")) + } + } + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { + if p.fill_forms() { + Ok(()) + } else { + Err(EditError::NotPermitted("filling in form fields")) + } + } + Edit::Protect(_) | Edit::RemoveProtection => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("changing security")) + } + } + // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted + // document it is a change of security (#3921). + Edit::ConvertPdfA { .. } => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) + } + } + Edit::SetInfo { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } + | Edit::AddField { .. } + | Edit::ApplyRedactions { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::MoveObjects { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::ClearRedactions + | Edit::RemoveHidden { .. } + | Edit::ImportData { .. } + | Edit::AddLink { .. } + | Edit::SetLink { .. } + | Edit::DeleteLink { .. } + | Edit::RemoveLinks { .. } + | Edit::AddLinks { .. } + | Edit::Sanitize + | Edit::XfaToStatic + | Edit::SetFieldProps { .. } + | Edit::DeleteField { .. } + | Edit::DuplicateField { .. } + | Edit::SetTabOrder { .. } + | Edit::MoveInTabOrder { .. } + | Edit::SetCalculationOrder { .. } + | Edit::SetInitialView(_) + | Edit::AddAttachment { .. } + | Edit::DeleteAttachment { .. } + | Edit::AutoTagText + | Edit::SetAltText { .. } + | Edit::AutoTagFormFields + | Edit::SetTagProperty { .. } + | Edit::MoveTag { .. } + | Edit::MarkDecorative { .. } + | Edit::MarkTagArtifact { .. } + | Edit::AddOcrText { .. } + | Edit::SetDocumentScript { .. } + | Edit::SetFieldScript { .. } + | Edit::RemoveStandardId { .. } + | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } + | Edit::RegisterTextFont { .. } + | Edit::EditPageImage { .. } + | Edit::Flatten { .. } + | Edit::FlattenFillSign => { + if p.modify() { + Ok(()) + } else { + Err(EditError::NotPermitted("changes to the document")) + } + } + Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), + } +} + +/// Dates and unique ids stamped onto what an edit creates. +pub(crate) struct EditCtx { + /// Field scripts run (`None`: JavaScript is off) and what they produced. + pub(crate) js: Option, + /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what + /// its scripts produced. + pub(crate) xfa: Option>, + pub(crate) xfa_out: js::JsOutput, + /// The datasets stream this edit wrote (later writes in the edit replace it in place). + pub(crate) xfa_datasets: Option, + /// A form script ran too long and was abandoned: the document's scripts go off. + pub(crate) xfa_ran_away: bool, + /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). + pub(crate) xfa_time_scale: u32, + /// The edit took the XFA out of the form: what went with it, for the document's warnings. + pub(crate) xfa_removed: Vec, + /// Pages in the document, for `xfa.layout.pageCount()`. + pub(crate) pages: usize, + pub(crate) date: Option, + /// Today in local time, for date tokens. + pub(crate) today: (i64, u32, u32), + pub(crate) seed: u64, + pub(crate) count: u64, + /// Fields the edit renamed to keep imported names separate (old → new), for the repair + /// log and the undo label. + pub(crate) renamed_fields: Vec<(String, String)>, + /// The overlay streams the edit's applied redactions added, which alone the renderer's check + /// leaves out. + pub(crate) redaction_overlays: Vec, +} + +impl EditCtx { + pub(crate) fn new(now: Option, salt: u64) -> Self { + let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; + if let Some(t) = now { + seed ^= (t as u64).rotate_left(17); + } + #[cfg(not(target_arch = "wasm32"))] + if now.is_some() { + // Real clock: mix in sub-second time so ids from two sessions don't collide. + seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; + } + Self { + js: None, + xfa: None, + xfa_out: Default::default(), + xfa_datasets: None, + xfa_ran_away: false, + xfa_time_scale: 1, + xfa_removed: Vec::new(), + pages: 0, + date: now.map(pdfcraft_cos::pdf_date), + today: (1970, 1, 1), + seed, + count: 0, + renamed_fields: Vec::new(), + redaction_overlays: Vec::new(), + } + } + + /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the + /// operating system, so no extra dependency is needed. + fn entropy(&mut self) -> [u8; 32] { + use std::hash::{BuildHasher, Hasher}; + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(self.seed ^ i as u64); + h.write_u64(self.count); + self.count += 1; + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + out + } + + /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. + fn meta(&mut self) -> pdfcraft_annot::Meta { + let mut next = || { + self.count += 1; + let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + let (a, b) = (next(), next()); + let id = format!( + "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", + a >> 32, + (a >> 16) & 0xffff, + a & 0xfff, + 0x8000 | (b >> 48) & 0x3fff, + b & 0xffff_ffff_ffff + ); + pdfcraft_annot::Meta { date: self.date.clone(), id } + } +} + +/// Perform an edit on a working copy (the caller discards it on error). +pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { + match edit { + Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, + Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, + Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, + Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, + Edit::InsertBlankPage { at, width, height } => { + pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; + } + Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, + Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, + Edit::ReplacePages { pages, name, bytes, src_pages } => { + let src = open_source(name, bytes)?; + pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; + } + Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, + Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, + Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { + let src = open_source(name, bytes)?; + let pages = match pages { + Some(p) => p.clone(), + None => (0..pdfcraft_organize::page_count(&src)?).collect(), + }; + // The file's bookmarks come along under one named after it, as in Combine Files. + let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); + let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; + cx.renamed_fields = outcome.renamed_fields; + } + Edit::AddBookmark { parent, index, title, page, left, top } => { + pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; + } + Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?, + Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?, + Edit::MoveBookmark { from, to_parent, index } => { + pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; + } + Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, + Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, + Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, + Edit::BookmarksFromStructure => { + let entries: Vec<_> = pdfcraft_a11y::headings(doc) + .into_iter() + .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) + .collect(); + pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; + } + Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, + Edit::AddMeasurement(m) => { + measure::add(doc, m, &cx.meta())?; + } + Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, + Edit::AddAnnotation(a) => { + pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; + } + Edit::AddCustomStamp { page, rect, name, file, author } => { + let src = mark_source(doc, file)?; + let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + let natural = rect[2] == rect[0] && rect[3] == rect[1]; + let rotation = pdfcraft_organize::page_rotation(doc, *page)?; + // The picture as displayed: a quarter turn swaps its sides in user space. + let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; + let rect = if natural { + let k = (200.0 / sw.max(sh)).min(1.0); + let (w, h) = (dw * k, dh * k); + [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] + } else { + fit_picture(*rect, dw, dh) + }; + let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; + let style = pdfcraft_annot::Style::default_for(&shape); + let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; + let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; + pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; + } + Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, + Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, + Edit::ReplyToAnnotation { page, index, text, author } => { + pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; + } + Edit::SetAnnotationStatus { page, index, state, author } => { + pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; + } + Edit::EraseInk { page, index, path, radius } => { + pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; + } + Edit::AddAttachment { name, bytes, description, mime } => { + pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; + } + Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, + Edit::ReplaceText { page, quads, text, author, strike, caret } => { + // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by + // the strikeout's `/NM` (#2666). + let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); + pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; + } + Edit::MarkAnnotation { page, index, marked, author } => { + pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; + } + Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, + Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, + Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, + Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, + Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { + pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; + } + Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, + Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, + Edit::SetAnnotationInfo { page, index, author, subject, icon } => { + pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; + } + Edit::SetFieldValue { name, value } => { + let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { + Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), + None => pdfcraft_forms::set_value(doc, name, value), + }; + match set(doc, value) { + Ok(()) => {} + // A date the field's format doesn't read may be typed by another of an XFA date + // field's patterns: it is kept in the first. Only a format refusal is tried + // again: it comes before any of the field's scripts run, so they run once. + Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { + Some(typed) => set(doc, &typed)?, + None => return Err(refused.into()), + }, + Err(refused) => return Err(refused.into()), + } + } + Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { + Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, + None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, + }, + Edit::XfaEvent { som, activity } => { + let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; + 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 }; + let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + ran.map_err(EditError::Invalid)?; + } + Edit::SetFieldImage { name, image } => { + let (img, _) = embed_image(doc, name, image)?; + let px = match &*doc.get(img) { + pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), + _ => (1, 1), + }; + // An XFA image field keeps the picture in the data too (as Designer saves it), and a + // laid-out form shows it from there: so only pictures the layout can show, and only + // for image fields (a laid-out one carries its SOM path; a static form's field name + // is its path). + let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); + let image_field = is_xfa + && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); + if image_field { + let Some(content_type) = image_content_type(image) else { + return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); + }; + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); + let path = match cx.xfa.as_deref() { + Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), + None => pdfcraft_xfa::som_to_path(&som), + }; + let r = + pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; + if !r.written && r.warnings.is_empty() { + return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); + } + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + xfa_after_data_change(doc, cx, false)?; + } else { + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + } + } + Edit::ResetForm { names } => { + pdfcraft_forms::reset(doc, names.as_deref())?; + } + Edit::AddField { page, rect, kind, name } => { + pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; + } + Edit::SetFieldProps { name, props } => { + pdfcraft_forms::set_props(doc, name, props)?; + } + Edit::DuplicateField { name, pages } => { + if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { + return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); + } + } + Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, + Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, + Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, + // As after a script edit: the values the new order gives show at once. + Edit::SetCalculationOrder { names } => { + pdfcraft_forms::set_calculation_order(doc, names)?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, + Edit::AutoTagText => { + a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::SetAltText { figure, alt } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AutoTagFormFields => { + a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; + } + Edit::SetTagProperty { object, property, value } => { + a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MoveTag { object, anchor, placement } => { + a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::EditTextLine { page, line, text } => { + pdfcraft_edit::replace_line(doc, *page, *line, text)?; + } + Edit::SetFieldScript { name, event, script } => { + pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + Edit::ConvertPdfA { level } => { + pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; + } + Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, + Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, + Edit::AddOcrText { page, words } => { + pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; + } + Edit::EditPageImage { page, index, change } => { + let img = pdfcraft_edit::page_images(doc, *page)? + .into_iter() + .nth(*index) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; + let c = match change { + ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), + ImageEdit::Rotate(q) => { + pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) + } + ImageEdit::RotateBy(degrees) => { + let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; + pdfcraft_edit::ImageChange::Transform(t) + } + ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( + img.visible_rect.unwrap_or(img.rect), + 0, + *horizontal, + !*horizontal, + )), + ImageEdit::Replace { name, bytes } => { + if img.is_form { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( + "grouped Form artwork cannot be replaced with a bitmap".into(), + ))); + } + pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) + } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), + ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + }; + pdfcraft_edit::change_image(doc, *page, *index, &c)?; + } + Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, + Edit::EditTextBlock { page, block, text, style } => { + pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; + } + Edit::RegisterTextFont { page, bytes } => { + pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; + } + Edit::MarkDecorative { figure } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MarkTagArtifact { object } => { + a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AddHeaderFooter { pages, settings, replace } => { + let date = cx.today; + pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; + } + Edit::AddWatermark { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? + } + Edit::AddBackground { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_background(doc, pages, &s, *replace)? + } + Edit::RemoveMarks { kind } => { + let n = pdfcraft_model::pages(doc).len(); + if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); + } + } + Edit::AddText { page, text } => { + pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; + } + Edit::AddImage { page, rect, name, bytes } => { + let (image, natural) = embed_image(doc, name, bytes)?; + let rect = match rect { + Some(r) => *r, + None => { + let p = pdfcraft_model::pages(doc).swap_remove(*page); + let (pw, ph) = p.display_size(doc); + let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); + let (w, h) = (natural.0 * k, natural.1 * k); + [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] + } + }; + pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; + } + Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, + Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, + Edit::ReplaceImage { page, index, name, bytes } => { + let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); + let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); + }; + let (image, _) = embed_image(doc, name, bytes)?; + pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; + } + Edit::PasteContent { page, content, file } => { + let content = match content { + AddedContent::Text(_) => content.clone(), + AddedContent::Image(img) => { + let Some((name, bytes)) = file else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); + }; + // The same picture in this document (copied here, pasted here before, or an + // identical file): drawn again from the one image object rather than stored twice. + let image = if copy_image::is_picture(doc, img.image, bytes) { + img.image + } else { + match copy_image::added_picture(doc, bytes) { + Some(image) => image, + None => embed_image(doc, name, bytes)?.0, + } + }; + AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) + } + }; + pdfcraft_edit::add_content(doc, *page, &content)?; + } + Edit::ApplyRedactions { pages } => { + cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; + } + Edit::ClearRedactions => { + pdfcraft_redact::clear_marks(doc, None)?; + } + Edit::AddLink { page, rect, action, style } => { + pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; + } + Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, + Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, + Edit::RemoveLinks { pages } => { + if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); + } + } + Edit::AddLinks { links, style } => { + if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); + } + } + Edit::ImportData { bytes, .. } => { + // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; + // XFDF, FDF, Acrobat's XML and tables go the usual way. + let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); + match text { + Some(Ok(text)) if is_xfa_data_file(&text) => { + let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); + let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) + .map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); + xfa_after_data_change(doc, cx, true)?; + } + Some(Err(why)) => return Err(EditError::Invalid(why)), + _ => { + pdfcraft_xfdf::import(doc, bytes)?; + } + } + } + Edit::RemoveHidden { which } => { + pdfcraft_redact::sanitize::remove_hidden(doc, which)?; + } + Edit::Sanitize => { + pdfcraft_redact::sanitize::sanitize(doc)?; + } + Edit::Flatten { comments, fields } => { + // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers + // would lay the form out again from its template over the baked pages. A dynamic + // form that was never laid out keeps its XFA: without it only placeholders remain. + let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; + match shape { + Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, + Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), + None => {} + } + } + Edit::XfaToStatic => { + let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { + return Err(EditError::Invalid("this document is not an XFA form".into())); + }; + if !shape.strippable() { + return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); + } + strip_xfa(doc, cx)?; + } + Edit::FlattenFillSign => { + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; + } + Edit::Protect(p) => { + p.validate()?; + let seed = cx.entropy(); + // Without a permissions password nothing is restricted, so the owner password is a + // random one nobody needs. + let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); + let params = pdfcraft_cos::NewEncryption { + algorithm: p.algorithm, + user_password: p.open_password.as_deref().unwrap_or(""), + owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), + permissions: p.permission_bits(), + encrypt_metadata: p.encrypt_metadata, + seed, + }; + doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; + } + Edit::RemoveProtection => { + if doc.output_handler().is_none() { + return Err(EditError::Protection("the document isn't protected".into())); + } + doc.remove_encryption(); + } + Edit::Batch { edits, .. } => { + for e in edits { + run_edit(doc, e, cx)?; + } + } + } + Ok(()) +} + +/// The singular or the plural wording, by count. Both are written out: a language forms its plural +/// its own way, and `one` and `many` may differ in more than an "s". +fn plural(one: &'static str, many: &'static str, n: usize) -> Label { + Label::of(if n == 1 { one } else { many }) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d34decd658..0926c3ed28 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod commands; pub mod compare; mod copy_image; pub mod dates; +mod edit; mod error; pub mod export; pub mod field_table; @@ -228,6 +229,8 @@ fn record_link_scan() { }); } +pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; +use edit::{EditCtx, check_permission, run_edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; @@ -1228,1574 +1231,13 @@ impl Protection { } } -/// Edits that can be applied to a document. Page indices are 0-based. -#[derive(Clone, Debug, PartialEq)] -pub enum Edit { - RotatePages { - pages: Vec, - degrees: i64, - }, - DeletePages { - pages: Vec, - }, - MovePages { - pages: Vec, - to: usize, - }, - /// `pages` reversed among the positions they occupy. - ReversePages { - pages: Vec, - }, - InsertBlankPage { - at: usize, - width: f64, - height: f64, - }, - /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after - /// the last of them when `at` is `None`. - DuplicatePages { - pages: Vec, - at: Option, - }, - /// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the - /// pages' links, comments and bookmarks stay. - ReplacePages { - pages: Vec, - name: String, - bytes: Arc>, - src_pages: Vec, - }, - /// Set Page Boxes / Crop: set (or reset) one box on `pages`. - SetPageBox { - pages: Vec, - which: PageBox, - spec: BoxSpec, - }, - /// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4), - /// Organize ▸ Page Transitions. - SetTransition { - pages: Vec, - spec: TransitionSpec, - }, - SetInfo { - key: String, - value: String, - }, - /// Insert pages from another PDF (all pages when `pages` is `None`) at position `at`. - InsertPagesFrom { - name: String, - bytes: Arc>, - pages: Option>, - at: usize, - /// How a form field whose name the document already uses is kept separate. - field_conflicts: pdfcraft_organize::FieldConflicts, - }, - /// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level). - /// It goes to the user-space point (`left`, `top`) of the page at the reader's zoom, and - /// either may be `None` to keep the reader's position that way (both `None` is the page top). - /// A coordinate that is not finite fails the edit. - AddBookmark { - parent: Vec, - index: usize, - title: String, - page: usize, - left: Option, - top: Option, - }, - RenameBookmark { - path: Vec, - title: String, - }, - /// Delete a bookmark and the bookmarks under it. - DeleteBookmark { - path: Vec, - }, - /// Move a bookmark to child `index` of `to_parent` (indices after removing it). - MoveBookmark { - from: Vec, - to_parent: Vec, - index: usize, - }, - /// Point a bookmark at another page. - SetBookmarkPage { - path: Vec, - page: usize, - }, - /// Colour a bookmark's title (`/C`); `None` or black is the default black. - SetBookmarkColor { - path: Vec, - color: Option<[f64; 3]>, - }, - /// Make a bookmark's title bold or italic or not (`/F`); `None` keeps that one. - SetBookmarkStyle { - path: Vec, - bold: Option, - italic: Option, - }, - /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first - /// "Untitled" bookmark (New Bookmarks from Structure). - BookmarksFromStructure, - /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. - NumberPages { - from: usize, - to: usize, - style: pdfcraft_organize::LabelStyle, - prefix: String, - first: u32, - }, - /// Add a calibrated distance, perimeter or area annotation. - AddMeasurement(measure::NewMeasurement), - /// Store a drawing scale for a rectangular viewport (PDF user space). - SetMeasurementScale { - page: usize, - bbox: [f64; 4], - name: String, - scale: measure::Scale, - }, - /// Add a comment (sticky note, highlight, shape, drawing, text box…). - AddAnnotation(NewAnnotation), - /// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size - /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). - /// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape, - /// and centred in it (#4077). - AddCustomStamp { - page: usize, - rect: [f64; 4], - name: String, - file: MarkFile, - author: String, - }, - /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. - DeleteAnnotation { - page: usize, - index: usize, - }, - SetAnnotationContents { - page: usize, - index: usize, - text: String, - }, - ReplyToAnnotation { - page: usize, - index: usize, - text: String, - author: String, - }, - /// Acrobat's "Set status" (a state reply by `author`). - SetAnnotationStatus { - page: usize, - index: usize, - state: ReviewState, - author: String, - }, - /// The Eraser: rub out the parts of drawing `(page, index)` near `path` (user space). - EraseInk { - page: usize, - index: usize, - path: Vec<[f64; 2]>, - radius: f64, - }, - /// Replace Text: strike out `quads` and add a grouped caret holding `text`. - ReplaceText { - page: usize, - quads: Vec<[f64; 8]>, - text: String, - author: String, - strike: Style, - caret: Style, - }, - /// Acrobat's "Mark with checkmark" (a hidden `/StateModel /Marked` reply by `author`). - MarkAnnotation { - page: usize, - index: usize, - marked: bool, - author: String, - }, - /// Properties ▸ Locked. - LockAnnotation { - page: usize, - index: usize, - locked: bool, - }, - MoveAnnotation { - page: usize, - index: usize, - dx: f64, - dy: f64, - }, - /// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's - /// two ends, the others' corners). - ReshapeAnnotation { - page: usize, - index: usize, - points: Vec<[f64; 2]>, - }, - /// Resize a rectangle, oval or text box. - ResizeAnnotation { - page: usize, - index: usize, - rect: [f64; 4], - }, - StyleAnnotation { - page: usize, - index: usize, - color: Option, - opacity: Option, - width: Option, - /// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged. - endings: Option>, - }, - /// Fill a rectangle, oval or polygon comment, or remove its fill (`None`). - FillAnnotation { - page: usize, - index: usize, - fill: Option, - }, - /// Change the font size (pt) of a text box, typewriter text or callout. - FontSizeAnnotation { - page: usize, - index: usize, - size: f64, - }, - /// Comment properties ▸ General / note icon. - SetAnnotationInfo { - page: usize, - index: usize, - author: Option, - subject: Option, - icon: Option, - }, - /// Fill in a form field. - SetFieldValue { - name: String, - value: FieldValue, - }, - /// Clear form: the named fields (all when `None`) back to their defaults. - ResetForm { - names: Option>, - }, - /// Prepare a form: add a field on a page (`rect` in PDF user space); `name` defaults to - /// Acrobat's next free name ("Text1", "Check Box2", …). - AddField { - page: usize, - rect: [f64; 4], - kind: NewField, - name: Option, - }, - /// Field Properties ▸ General / Options. - SetFieldProps { - name: String, - props: Box, - }, - /// An image field (or any button): show this image file (PNG, JPEG, TIFF, GIF, BMP). - SetFieldImage { - name: String, - image: Arc>, - }, - /// Duplicate a field onto other pages (same field, shared value). - DuplicateField { - name: String, - pages: Vec, - }, - DeleteField { - name: String, - }, - /// Page properties ▸ Tab order, for pages (0-based). - SetTabOrder { - pages: Vec, - order: TabOrder, - }, - /// Document Properties ▸ Initial View (and the language and binding). - SetInitialView(Box), - /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under - /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. - AddAttachment { - name: String, - bytes: Vec, - description: Option, - mime: Option, - }, - /// Remove the document-level attachment whose name-tree key is `key`. - DeleteAttachment { - key: String, - }, - /// Build a tagged structure tree for an untagged, text-only document. - AutoTagText, - /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is - /// the figure element's object number (from `Document::figures`). - SetAltText { - figure: u32, - alt: Option, - }, - /// Add `/Form` tags and object references for all widgets in an untagged AcroForm. - AutoTagFormFields, - /// Change one property on a reachable indirect structure tag. - SetTagProperty { - object: pdfcraft_cos::ObjRef, - property: a11y::TagProperty, - value: Option, - }, - /// Move a structure tag before or after a direct sibling in reading order. - MoveTag { - object: pdfcraft_cos::ObjRef, - anchor: pdfcraft_cos::ObjRef, - placement: a11y::TagPlacement, - }, - /// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact. - MarkDecorative { - figure: u32, - }, - /// Tags panel ▸ Mark as artifact: retire a leaf tag and mark its content as an artifact. - MarkTagArtifact { - object: pdfcraft_cos::ObjRef, - }, - /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level - /// script `name`. - SetDocumentScript { - name: String, - script: Option, - }, - /// Field Properties ▸ a custom script: set or remove field `name`'s JavaScript for `event` - /// (keystroke, format, validate, calculate or mouse_up). - SetFieldScript { - name: String, - event: String, - script: Option, - }, - /// Standards ▸ Save as PDF/A: fix what can be fixed for `level` (metadata, output intent, - /// forbidden actions, annotation flags, …). - ConvertPdfA { - level: pdfcraft_preflight::Level, - }, - /// Remove what identifies the document as meeting `standard` (PDF/A or PDF/X). - RemoveStandardId { - standard: Standard, - }, - /// What a script (button or console) changed in form fields: values, read-only, required - /// and visibility. - ApplyScriptChanges { - changes: Vec, - }, - /// An XFA form's scripted event (a button's `click`, say) on the object at `som`: what the - /// script changes (values, rows, visibility) is applied and the form laid out again. - XfaEvent { - som: String, - activity: String, - }, - /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the - /// image (from [`ocr::OcrJob::run`]). - AddOcrText { - page: usize, - words: Vec, - }, - /// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on - /// `page`, in its own font when it can show it, else in Helvetica. - EditTextLine { - page: usize, - line: usize, - text: String, - }, - /// Edit a PDF ▸ an existing image on `page` (index from `Document::page_images`). - EditPageImage { - page: usize, - index: usize, - change: ImageEdit, - }, - /// Move mixed existing and added objects together. References resolve before any change; - /// offset is a user-space vector. No retyping, reflow or font substitution is involved. - MoveObjects { - page: usize, - objects: Vec, - offset: [f64; 2], - }, - /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), - /// rewrapped to the box's width. - EditTextBlock { - page: usize, - block: usize, - text: String, - /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. - style: pdfcraft_edit::BlockStyle, - }, - /// An explicit open-licensed full font program, completing subsets or selected by a user. - RegisterTextFont { - page: usize, - bytes: Arc>, - }, - /// Order tabs manually: move a field one place earlier or later on its page. - MoveInTabOrder { - name: String, - earlier: bool, - }, - /// Set the calculation order: these calculated fields first, in this order, then the others - /// in their current order (see [`Document::calculation_order`]). The form recalculates. - SetCalculationOrder { - names: Vec, - }, - /// Add a header and footer (with `replace`, existing ones on those pages go first). - AddHeaderFooter { - pages: Vec, - settings: HeaderFooter, - replace: bool, - }, - /// `file`: a picture from a file (an image, or a page of a PDF) instead of text. - AddWatermark { - pages: Vec, - settings: Watermark, - replace: bool, - file: Option, - }, - /// `file`: a picture from a file instead of a colour. - AddBackground { - pages: Vec, - settings: Background, - replace: bool, - file: Option, - }, - /// Remove every mark of a kind from every page. - RemoveMarks { - kind: MarkKind, - }, - /// Edit a PDF ▸ Add content ▸ Text (`text.rect` in display space). - AddText { - page: usize, - text: AddedText, - }, - /// Edit a PDF ▸ Add content ▸ Image: an image file placed at `rect` (display space), or at - /// its natural size centred on the page (shrunk to fit). - AddImage { - page: usize, - rect: Option<[f64; 4]>, - name: String, - bytes: Arc>, - }, - /// Move, resize, retype or reformat an added item (`index` among the page's added items). - UpdateContent { - page: usize, - index: usize, - content: AddedContent, - }, - DeleteContent { - page: usize, - index: usize, - }, - /// Replace an added image with another file (keeping its box and transforms). - ReplaceImage { - page: usize, - index: usize, - name: String, - bytes: Arc>, - }, - /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its - /// style, an image in its box (display space) with its turns, flips and crop. An image's - /// picture is `file` (from [`Document::added_image_file`]): the copied image object is reused - /// while it is still that picture in this document, else one an added image here already draws - /// it from, else the file is embedded. - PasteContent { - page: usize, - content: AddedContent, - file: Option<(String, Arc>)>, - }, - /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. - ApplyRedactions { - pages: Option>, - }, - /// Remove redaction marks without applying them. - ClearRedactions, - /// Edit a PDF ▸ Link: a new link over `rect` (user space). - AddLink { - page: usize, - rect: [f64; 4], - action: LinkAction, - style: LinkStyle, - }, - /// Link Properties (`index` in the page's `/Annots`). - SetLink { - page: usize, - index: usize, - rect: Option<[f64; 4]>, - action: Option, - style: Option, - }, - DeleteLink { - page: usize, - index: usize, - }, - /// Remove all links (on `pages`, or everywhere). - RemoveLinks { - pages: Option>, - }, - /// Create links from URLs in the text: (page, line rects in user space, URI). - AddLinks { - links: Vec<(usize, Vec<[f64; 4]>, String)>, - style: LinkStyle, - }, - /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text). - ImportData { - name: String, - bytes: Arc>, - }, - /// Remove Hidden Information: the chosen categories. - RemoveHidden { - which: Vec, - }, - /// Sanitize Document: every category, then a full rewrite on save. - Sanitize, - /// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and - /// PdfCraft's private keys go, the pages as laid out and the fields with their values - /// stay, so every viewer shows the same form and none lays it out again. Its scripts no - /// longer run. - XfaToStatic, - /// Flatten comments and/or form fields on every page into page content. - Flatten { - comments: bool, - fields: bool, - }, - /// Bake Fill & Sign text, marks and signatures into the page. Other comments and fields stay. - /// A no-op (no undo step) when the document has none. - FlattenFillSign, - /// Protect with passwords and permissions (written by the next save, which is a full rewrite). - Protect(Protection), - /// Remove password security (needs the owner password). - RemoveProtection, - /// Several edits applied as one undoable step (all or nothing). - Batch { - label: String, - edits: Vec, - }, -} - -/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not -/// here: its wording is only the hole, because a batch carries its name as text (see -/// [`BATCH_LABELS`]). -pub const EDIT_LABELS: &[&str] = &[ - "Rotate page", - "Change tag properties", - "Rotate pages", - "Delete page", - "Delete pages", - "Move page", - "Move pages", - "Reverse pages", - "Insert blank page", - "Duplicate page", - "Duplicate pages", - "Replace page", - "Replace pages", - "Crop page", - "Crop pages", - "Set page boxes", - "Set page transitions", - "Change Title", - "Change Author", - "Change Subject", - "Change Keywords", - "Change {key}", - "Insert pages from {name}", - "Insert pages from {name} — 1 field renamed", - "Insert pages from {name} — fields renamed", - "Attach {name}", - "Delete attachment {key}", - "Add bookmark", - "Rename bookmark", - "Delete bookmark", - "Move bookmark", - "Set bookmark destination", - "Colour", - "Style", - "New bookmarks from structure", - "Number pages", - "Measure distance", - "Measure perimeter", - "Measure area", - "Set measurement scale", - "Add sticky note", - "Add highlight", - "Add underline", - "Add strikethrough", - "Add squiggly underline", - "Add rectangle", - "Add oval", - "Add arrow", - "Add line", - "Add drawing", - "Add text box", - "Add checkmark", - "Add cross", - "Add dot", - "Add signature", - "Add redaction mark", - "Add cloud", - "Add polygon", - "Add connected lines", - "Add callout", - "Add inserted text", - "Add file attachment", - "Add stamp", - "Delete comment", - "Edit comment", - "Reply", - "Set status None", - "Set status Accepted", - "Set status Rejected", - "Set status Cancelled", - "Set status Completed", - "Replace text", - "Erase", - "Mark with checkmark", - "Remove checkmark", - "Lock comment", - "Unlock comment", - "Move comment", - "Reshape comment", - "Resize comment", - "Change comment properties", - "Fill in {name}", - "Set the image of {name}", - "Clear form", - "Add field", - "Change field properties", - "Delete field", - "Duplicate field", - "Set tab order", - "Set calculation order", - "Change initial view", - "Set alternate text", - "Mark figure as decorative", - "Recognize text", - "Run JavaScript", - "Run form script", - "Save as {level}", - "Remove {standard} identification", - "Edit script of {name}", - "Delete document JavaScript", - "Edit document JavaScript", - "Edit text", - "Load document font", - "Move object", - "Move objects", - "Move image", - "Rotate image", - "Flip image", - "Replace image", - "Delete image", - "Add header & footer", - "Update header & footer", - "Add watermark", - "Update watermark", - "Add background", - "Update background", - "Remove header & footer", - "Remove watermark", - "Remove background", - "Add text", - "Add image", - "Edit content", - "Delete content", - "Paste", - "Apply redactions", - "Remove redaction marks", - "Remove hidden information", - "Import {name}", - "Add link", - "Change link properties", - "Delete link", - "Remove all links", - "Create link", - "Create links", - "Sanitize document", - "Flatten comments", - "Flatten form fields", - "Flatten", - "Flatten Fill & Sign", - "Protect with password", - "Remove security", -]; - -/// The names of the batches built below L7, which an interface shows in the same places as -/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives -/// already worded in the interface's language. -pub const BATCH_LABELS: &[&str] = - &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; - -impl Edit { - /// Label for the Edit menu and history ("Undo Rotate pages"). - pub fn label(&self) -> Label { - match self { - Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), - Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), - Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), - Edit::ReversePages { .. } => Label::of("Reverse pages"), - Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), - Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), - Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), - Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), - Edit::SetPageBox { .. } => Label::of("Set page boxes"), - Edit::SetTransition { .. } => Label::of("Set page transitions"), - // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can - // be said in another language; any other key is the document's and goes in as it stands. - Edit::SetInfo { key, .. } => match key.as_str() { - "Title" => Label::of("Change Title"), - "Author" => Label::of("Change Author"), - "Subject" => Label::of("Change Subject"), - "Keywords" => Label::of("Change Keywords"), - _ => Label::with("Change {key}", key.as_str()), - }, - Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), - Edit::AddBookmark { .. } => Label::of("Add bookmark"), - Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), - Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), - Edit::MoveBookmark { .. } => Label::of("Move bookmark"), - Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), - // The bookmark Style and Colour menus' own names, already said in every language. - Edit::SetBookmarkColor { .. } => Label::of("Colour"), - Edit::SetBookmarkStyle { .. } => Label::of("Style"), - Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), - Edit::NumberPages { .. } => Label::of("Number pages"), - // What was measured is part of the wording too, for the same reason. - Edit::AddMeasurement(m) => Label::of(match m.kind { - pdfcraft_measure::Kind::Distance => "Measure distance", - pdfcraft_measure::Kind::Perimeter => "Measure perimeter", - pdfcraft_measure::Kind::Area => "Measure area", - }), - Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), - Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), - Edit::AddCustomStamp { .. } => Label::of("Add stamp"), - Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), - Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), - Edit::ReplyToAnnotation { .. } => Label::of("Reply"), - // The state's name is part of the wording: a language may need to agree it with the rest. - Edit::SetAnnotationStatus { state, .. } => Label::of(match state { - ReviewState::None => "Set status None", - ReviewState::Accepted => "Set status Accepted", - ReviewState::Rejected => "Set status Rejected", - ReviewState::Cancelled => "Set status Cancelled", - ReviewState::Completed => "Set status Completed", - }), - Edit::ReplaceText { .. } => Label::of("Replace text"), - Edit::EraseInk { .. } => Label::of("Erase"), - Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), - Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), - Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), - Edit::LockAnnotation { .. } => Label::of("Unlock comment"), - Edit::MoveAnnotation { .. } => Label::of("Move comment"), - Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), - Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), - Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { - Label::of("Change comment properties") - } - Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), - Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), - Edit::ResetForm { .. } => Label::of("Clear form"), - Edit::AddField { .. } => Label::of("Add field"), - Edit::SetFieldProps { .. } => Label::of("Change field properties"), - Edit::DeleteField { .. } => Label::of("Delete field"), - Edit::DuplicateField { .. } => Label::of("Duplicate field"), - Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), - Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), - Edit::SetInitialView(_) => Label::of("Change initial view"), - Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), - Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), - Edit::AutoTagText => Label::of("Automatically tag text"), - Edit::SetAltText { .. } => Label::of("Set alternate text"), - Edit::AutoTagFormFields => Label::of("Tag form fields"), - Edit::SetTagProperty { .. } => Label::of("Change tag properties"), - Edit::MoveTag { .. } => Label::of("Move object"), - Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), - Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), - Edit::AddOcrText { .. } => Label::of("Recognize text"), - Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), - Edit::XfaEvent { .. } => Label::of("Run form script"), - // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. - Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), - Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), - Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), - Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), - Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), - Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), - Edit::RegisterTextFont { .. } => Label::of("Load document font"), - Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), - Edit::EditPageImage { change, .. } => Label::of(match change { - ImageEdit::Move(_) => "Move image", - ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", - ImageEdit::Crop(_) => "Crop image", - ImageEdit::Flip { .. } => "Flip image", - ImageEdit::Replace { .. } => "Replace image", - ImageEdit::Delete => "Delete image", - }), - Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), - Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), - Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), - Edit::AddWatermark { .. } => Label::of("Update watermark"), - Edit::AddBackground { replace: false, .. } => Label::of("Add background"), - Edit::AddBackground { .. } => Label::of("Update background"), - Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), - Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), - Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), - Edit::AddText { .. } => Label::of("Add text"), - Edit::AddImage { .. } => Label::of("Add image"), - Edit::UpdateContent { .. } => Label::of("Edit content"), - Edit::DeleteContent { .. } => Label::of("Delete content"), - Edit::ReplaceImage { .. } => Label::of("Replace image"), - Edit::PasteContent { .. } => Label::of("Paste"), - Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), - Edit::ClearRedactions => Label::of("Remove redaction marks"), - Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), - Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), - Edit::AddLink { .. } => Label::of("Add link"), - Edit::SetLink { .. } => Label::of("Change link properties"), - Edit::DeleteLink { .. } => Label::of("Delete link"), - Edit::RemoveLinks { .. } => Label::of("Remove all links"), - Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), - Edit::Sanitize => Label::of("Sanitize document"), - Edit::XfaToStatic => Label::of("Convert to a static form"), - Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), - Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), - Edit::Flatten { .. } => Label::of("Flatten"), - Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), - Edit::Protect(_) => Label::of("Protect with password"), - Edit::RemoveProtection => Label::of("Remove security"), - // A batch carries its own name, already worded by whoever built it. - // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). - Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), - } - } -} - -/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, -/// so a language can give the noun the article and the gender its own grammar asks for. -fn add_annotation(s: &Shape) -> &'static str { - match s { - Shape::Note { .. } => "Add sticky note", - Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", - Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", - Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", - Shape::Rectangle { .. } => "Add rectangle", - Shape::Oval { .. } => "Add oval", - Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", - Shape::Line { .. } => "Add line", - Shape::Ink { .. } => "Add drawing", - Shape::TextBox { .. } => "Add text box", - Shape::Typewriter { .. } => "Add text", - Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", - Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", - Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", - Shape::Mark { mark: FillMark::Line, .. } => "Add line", - Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", - Shape::Redact { .. } => "Add redaction mark", - Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", - Shape::Polygon { cloud: true, .. } => "Add cloud", - Shape::Polygon { .. } => "Add polygon", - Shape::PolyLine { .. } => "Add connected lines", - Shape::Callout { .. } => "Add callout", - Shape::Caret { .. } => "Add inserted text", - Shape::Attachment { .. } => "Add file attachment", - } -} - /// Opened as owner, or nothing is restricted (no permissions password was set): security may /// be changed, as in Acrobat. -fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { +pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 p.owner || p.bits & ALL == ALL } -/// Whether the opening password allows an edit (§7.6.4.2, Table 22). -fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { - match edit { - Edit::RotatePages { .. } - | Edit::DeletePages { .. } - | Edit::MovePages { .. } - | Edit::ReversePages { .. } - | Edit::InsertBlankPage { .. } - | Edit::DuplicatePages { .. } - | Edit::ReplacePages { .. } - | Edit::SetPageBox { .. } - | Edit::SetTransition { .. } - | Edit::InsertPagesFrom { .. } - // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). - | Edit::AddBookmark { .. } - | Edit::RenameBookmark { .. } - | Edit::DeleteBookmark { .. } - | Edit::MoveBookmark { .. } - | Edit::SetBookmarkPage { .. } - | Edit::SetBookmarkColor { .. } - | Edit::SetBookmarkStyle { .. } - | Edit::BookmarksFromStructure - | Edit::NumberPages { .. } => { - if p.assemble() { - Ok(()) - } else { - Err(EditError::NotPermitted("page changes")) - } - } - Edit::AddMeasurement(_) - | Edit::AddAnnotation(_) - | Edit::AddCustomStamp { .. } - | Edit::DeleteAnnotation { .. } - | Edit::SetAnnotationContents { .. } - | Edit::ReplyToAnnotation { .. } - | Edit::SetAnnotationStatus { .. } - | Edit::MarkAnnotation { .. } - | Edit::ReplaceText { .. } - | Edit::EraseInk { .. } - | Edit::LockAnnotation { .. } - | Edit::MoveAnnotation { .. } - | Edit::ReshapeAnnotation { .. } - | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } - | Edit::FillAnnotation { .. } - | Edit::FontSizeAnnotation { .. } - | Edit::SetAnnotationInfo { .. } - | Edit::SetMeasurementScale { .. } => { - if p.annotate() { - Ok(()) - } else { - Err(EditError::NotPermitted("comments")) - } - } - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { - if p.fill_forms() { - Ok(()) - } else { - Err(EditError::NotPermitted("filling in form fields")) - } - } - Edit::Protect(_) | Edit::RemoveProtection => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("changing security")) - } - } - // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted - // document it is a change of security (#3921). - Edit::ConvertPdfA { .. } => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) - } - } - Edit::SetInfo { .. } - | Edit::AddHeaderFooter { .. } - | Edit::AddWatermark { .. } - | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } - | Edit::AddField { .. } - | Edit::ApplyRedactions { .. } - | Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::MoveObjects { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - | Edit::ClearRedactions - | Edit::RemoveHidden { .. } - | Edit::ImportData { .. } - | Edit::AddLink { .. } - | Edit::SetLink { .. } - | Edit::DeleteLink { .. } - | Edit::RemoveLinks { .. } - | Edit::AddLinks { .. } - | Edit::Sanitize - | Edit::XfaToStatic - | Edit::SetFieldProps { .. } - | Edit::DeleteField { .. } - | Edit::DuplicateField { .. } - | Edit::SetTabOrder { .. } - | Edit::MoveInTabOrder { .. } - | Edit::SetCalculationOrder { .. } - | Edit::SetInitialView(_) - | Edit::AddAttachment { .. } - | Edit::DeleteAttachment { .. } - | Edit::AutoTagText - | Edit::SetAltText { .. } - | Edit::AutoTagFormFields - | Edit::SetTagProperty { .. } - | Edit::MoveTag { .. } - | Edit::MarkDecorative { .. } - | Edit::MarkTagArtifact { .. } - | Edit::AddOcrText { .. } - | Edit::SetDocumentScript { .. } - | Edit::SetFieldScript { .. } - | Edit::RemoveStandardId { .. } - | Edit::EditTextLine { .. } - | Edit::EditTextBlock { .. } - | Edit::RegisterTextFont { .. } - | Edit::EditPageImage { .. } - | Edit::Flatten { .. } - | Edit::FlattenFillSign => { - if p.modify() { - Ok(()) - } else { - Err(EditError::NotPermitted("changes to the document")) - } - } - Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), - } -} - -/// Dates and unique ids stamped onto what an edit creates. -struct EditCtx { - /// Field scripts run (`None`: JavaScript is off) and what they produced. - js: Option, - /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what - /// its scripts produced. - xfa: Option>, - xfa_out: js::JsOutput, - /// The datasets stream this edit wrote (later writes in the edit replace it in place). - xfa_datasets: Option, - /// A form script ran too long and was abandoned: the document's scripts go off. - xfa_ran_away: bool, - /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). - xfa_time_scale: u32, - /// The edit took the XFA out of the form: what went with it, for the document's warnings. - xfa_removed: Vec, - /// Pages in the document, for `xfa.layout.pageCount()`. - pages: usize, - date: Option, - /// Today in local time, for date tokens. - today: (i64, u32, u32), - seed: u64, - count: u64, - /// Fields the edit renamed to keep imported names separate (old → new), for the repair - /// log and the undo label. - renamed_fields: Vec<(String, String)>, - /// The overlay streams the edit's applied redactions added, which alone the renderer's check - /// leaves out. - redaction_overlays: Vec, -} - -impl EditCtx { - fn new(now: Option, salt: u64) -> Self { - let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; - if let Some(t) = now { - seed ^= (t as u64).rotate_left(17); - } - #[cfg(not(target_arch = "wasm32"))] - if now.is_some() { - // Real clock: mix in sub-second time so ids from two sessions don't collide. - seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; - } - Self { - js: None, - xfa: None, - xfa_out: Default::default(), - xfa_datasets: None, - xfa_ran_away: false, - xfa_time_scale: 1, - xfa_removed: Vec::new(), - pages: 0, - date: now.map(pdfcraft_cos::pdf_date), - today: (1970, 1, 1), - seed, - count: 0, - renamed_fields: Vec::new(), - redaction_overlays: Vec::new(), - } - } - - /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the - /// operating system, so no extra dependency is needed. - fn entropy(&mut self) -> [u8; 32] { - use std::hash::{BuildHasher, Hasher}; - let mut out = [0u8; 32]; - for (i, chunk) in out.chunks_mut(8).enumerate() { - let mut h = std::collections::hash_map::RandomState::new().build_hasher(); - h.write_u64(self.seed ^ i as u64); - h.write_u64(self.count); - self.count += 1; - chunk.copy_from_slice(&h.finish().to_le_bytes()); - } - out - } - - /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. - fn meta(&mut self) -> pdfcraft_annot::Meta { - let mut next = || { - self.count += 1; - let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - }; - let (a, b) = (next(), next()); - let id = format!( - "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", - a >> 32, - (a >> 16) & 0xffff, - a & 0xfff, - 0x8000 | (b >> 48) & 0x3fff, - b & 0xffff_ffff_ffff - ); - pdfcraft_annot::Meta { date: self.date.clone(), id } - } -} - -/// Perform an edit on a working copy (the caller discards it on error). -fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { - match edit { - Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, - Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, - Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, - Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, - Edit::InsertBlankPage { at, width, height } => { - pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; - } - Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, - Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, - Edit::ReplacePages { pages, name, bytes, src_pages } => { - let src = open_source(name, bytes)?; - pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; - } - Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, - Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, - Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { - let src = open_source(name, bytes)?; - let pages = match pages { - Some(p) => p.clone(), - None => (0..pdfcraft_organize::page_count(&src)?).collect(), - }; - // The file's bookmarks come along under one named after it, as in Combine Files. - let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); - let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; - cx.renamed_fields = outcome.renamed_fields; - } - Edit::AddBookmark { parent, index, title, page, left, top } => { - pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; - } - Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?, - Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?, - Edit::MoveBookmark { from, to_parent, index } => { - pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; - } - Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, - Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, - Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, - Edit::BookmarksFromStructure => { - let entries: Vec<_> = pdfcraft_a11y::headings(doc) - .into_iter() - .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) - .collect(); - pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; - } - Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, - Edit::AddMeasurement(m) => { - measure::add(doc, m, &cx.meta())?; - } - Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, - Edit::AddAnnotation(a) => { - pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; - } - Edit::AddCustomStamp { page, rect, name, file, author } => { - let src = mark_source(doc, file)?; - let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); - let natural = rect[2] == rect[0] && rect[3] == rect[1]; - let rotation = pdfcraft_organize::page_rotation(doc, *page)?; - // The picture as displayed: a quarter turn swaps its sides in user space. - let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; - let rect = if natural { - let k = (200.0 / sw.max(sh)).min(1.0); - let (w, h) = (dw * k, dh * k); - [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] - } else { - fit_picture(*rect, dw, dh) - }; - let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; - let style = pdfcraft_annot::Style::default_for(&shape); - let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; - let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; - pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; - } - Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, - Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, - Edit::ReplyToAnnotation { page, index, text, author } => { - pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; - } - Edit::SetAnnotationStatus { page, index, state, author } => { - pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; - } - Edit::EraseInk { page, index, path, radius } => { - pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; - } - Edit::AddAttachment { name, bytes, description, mime } => { - pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; - } - Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, - Edit::ReplaceText { page, quads, text, author, strike, caret } => { - // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by - // the strikeout's `/NM` (#2666). - let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; - } - Edit::MarkAnnotation { page, index, marked, author } => { - pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; - } - Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, - Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, - Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, - Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, - Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { - pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; - } - Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, - Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, - Edit::SetAnnotationInfo { page, index, author, subject, icon } => { - pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; - } - Edit::SetFieldValue { name, value } => { - let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { - Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), - None => pdfcraft_forms::set_value(doc, name, value), - }; - match set(doc, value) { - Ok(()) => {} - // A date the field's format doesn't read may be typed by another of an XFA date - // field's patterns: it is kept in the first. Only a format refusal is tried - // again: it comes before any of the field's scripts run, so they run once. - Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { - Some(typed) => set(doc, &typed)?, - None => return Err(refused.into()), - }, - Err(refused) => return Err(refused.into()), - } - } - Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { - Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, - None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, - }, - Edit::XfaEvent { som, activity } => { - let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; - 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 }; - let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - ran.map_err(EditError::Invalid)?; - } - Edit::SetFieldImage { name, image } => { - let (img, _) = embed_image(doc, name, image)?; - let px = match &*doc.get(img) { - pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), - _ => (1, 1), - }; - // An XFA image field keeps the picture in the data too (as Designer saves it), and a - // laid-out form shows it from there: so only pictures the layout can show, and only - // for image fields (a laid-out one carries its SOM path; a static form's field name - // is its path). - let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); - let image_field = is_xfa - && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); - if image_field { - let Some(content_type) = image_content_type(image) else { - return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); - }; - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); - let path = match cx.xfa.as_deref() { - Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), - None => pdfcraft_xfa::som_to_path(&som), - }; - let r = - pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; - if !r.written && r.warnings.is_empty() { - return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); - } - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - xfa_after_data_change(doc, cx, false)?; - } else { - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - } - } - Edit::ResetForm { names } => { - pdfcraft_forms::reset(doc, names.as_deref())?; - } - Edit::AddField { page, rect, kind, name } => { - pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; - } - Edit::SetFieldProps { name, props } => { - pdfcraft_forms::set_props(doc, name, props)?; - } - Edit::DuplicateField { name, pages } => { - if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { - return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); - } - } - Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, - Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, - Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, - // As after a script edit: the values the new order gives show at once. - Edit::SetCalculationOrder { names } => { - pdfcraft_forms::set_calculation_order(doc, names)?; - match cx.js.as_mut() { - Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, - None => pdfcraft_forms::recalculate(doc)?, - }; - } - Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, - Edit::AutoTagText => { - a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::SetAltText { figure, alt } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AutoTagFormFields => { - a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; - } - Edit::SetTagProperty { object, property, value } => { - a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MoveTag { object, anchor, placement } => { - a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::EditTextLine { page, line, text } => { - pdfcraft_edit::replace_line(doc, *page, *line, text)?; - } - Edit::SetFieldScript { name, event, script } => { - pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; - match cx.js.as_mut() { - Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, - None => pdfcraft_forms::recalculate(doc)?, - }; - } - Edit::ConvertPdfA { level } => { - pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; - } - Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, - Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, - Edit::AddOcrText { page, words } => { - pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; - } - Edit::EditPageImage { page, index, change } => { - let img = pdfcraft_edit::page_images(doc, *page)? - .into_iter() - .nth(*index) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; - let c = match change { - ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), - ImageEdit::Rotate(q) => { - pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) - } - ImageEdit::RotateBy(degrees) => { - let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; - pdfcraft_edit::ImageChange::Transform(t) - } - ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( - img.visible_rect.unwrap_or(img.rect), - 0, - *horizontal, - !*horizontal, - )), - ImageEdit::Replace { name, bytes } => { - if img.is_form { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( - "grouped Form artwork cannot be replaced with a bitmap".into(), - ))); - } - pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) - } - ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), - ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, - }; - pdfcraft_edit::change_image(doc, *page, *index, &c)?; - } - Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, - Edit::EditTextBlock { page, block, text, style } => { - pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; - } - Edit::RegisterTextFont { page, bytes } => { - pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; - } - Edit::MarkDecorative { figure } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MarkTagArtifact { object } => { - a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AddHeaderFooter { pages, settings, replace } => { - let date = cx.today; - pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; - } - Edit::AddWatermark { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? - } - Edit::AddBackground { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_background(doc, pages, &s, *replace)? - } - Edit::RemoveMarks { kind } => { - let n = pdfcraft_model::pages(doc).len(); - if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); - } - } - Edit::AddText { page, text } => { - pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; - } - Edit::AddImage { page, rect, name, bytes } => { - let (image, natural) = embed_image(doc, name, bytes)?; - let rect = match rect { - Some(r) => *r, - None => { - let p = pdfcraft_model::pages(doc).swap_remove(*page); - let (pw, ph) = p.display_size(doc); - let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); - let (w, h) = (natural.0 * k, natural.1 * k); - [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] - } - }; - pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; - } - Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, - Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, - Edit::ReplaceImage { page, index, name, bytes } => { - let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); - let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); - }; - let (image, _) = embed_image(doc, name, bytes)?; - pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; - } - Edit::PasteContent { page, content, file } => { - let content = match content { - AddedContent::Text(_) => content.clone(), - AddedContent::Image(img) => { - let Some((name, bytes)) = file else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); - }; - // The same picture in this document (copied here, pasted here before, or an - // identical file): drawn again from the one image object rather than stored twice. - let image = if copy_image::is_picture(doc, img.image, bytes) { - img.image - } else { - match copy_image::added_picture(doc, bytes) { - Some(image) => image, - None => embed_image(doc, name, bytes)?.0, - } - }; - AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) - } - }; - pdfcraft_edit::add_content(doc, *page, &content)?; - } - Edit::ApplyRedactions { pages } => { - cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; - } - Edit::ClearRedactions => { - pdfcraft_redact::clear_marks(doc, None)?; - } - Edit::AddLink { page, rect, action, style } => { - pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; - } - Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, - Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, - Edit::RemoveLinks { pages } => { - if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); - } - } - Edit::AddLinks { links, style } => { - if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); - } - } - Edit::ImportData { bytes, .. } => { - // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; - // XFDF, FDF, Acrobat's XML and tables go the usual way. - let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); - match text { - Some(Ok(text)) if is_xfa_data_file(&text) => { - let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); - let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) - .map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); - xfa_after_data_change(doc, cx, true)?; - } - Some(Err(why)) => return Err(EditError::Invalid(why)), - _ => { - pdfcraft_xfdf::import(doc, bytes)?; - } - } - } - Edit::RemoveHidden { which } => { - pdfcraft_redact::sanitize::remove_hidden(doc, which)?; - } - Edit::Sanitize => { - pdfcraft_redact::sanitize::sanitize(doc)?; - } - Edit::Flatten { comments, fields } => { - // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers - // would lay the form out again from its template over the baked pages. A dynamic - // form that was never laid out keeps its XFA: without it only placeholders remain. - let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; - match shape { - Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, - Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), - None => {} - } - } - Edit::XfaToStatic => { - let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { - return Err(EditError::Invalid("this document is not an XFA form".into())); - }; - if !shape.strippable() { - return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); - } - strip_xfa(doc, cx)?; - } - Edit::FlattenFillSign => { - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; - } - Edit::Protect(p) => { - p.validate()?; - let seed = cx.entropy(); - // Without a permissions password nothing is restricted, so the owner password is a - // random one nobody needs. - let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); - let params = pdfcraft_cos::NewEncryption { - algorithm: p.algorithm, - user_password: p.open_password.as_deref().unwrap_or(""), - owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), - permissions: p.permission_bits(), - encrypt_metadata: p.encrypt_metadata, - seed, - }; - doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; - } - Edit::RemoveProtection => { - if doc.output_handler().is_none() { - return Err(EditError::Protection("the document isn't protected".into())); - } - doc.remove_encryption(); - } - Edit::Batch { edits, .. } => { - for e in edits { - run_edit(doc, e, cx)?; - } - } - } - Ok(()) -} - /// The comment list, read from the object graph (as `inspect` lists comments). fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { let mut list: Vec = pdfcraft_annot::summaries(doc) @@ -2882,7 +1324,7 @@ pub fn guard(f: impl FnOnce() -> T) -> Result { } /// Parse another PDF to copy pages from. -fn open_source(name: &str, bytes: &Arc>) -> Result { +pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { open_source_with(name, bytes, None) } @@ -2916,12 +1358,6 @@ pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option pdfcraft_organize::page_count(&doc).ok() } -/// The singular or the plural wording, by count. Both are written out: a language forms its plural -/// its own way, and `one` and `many` may differ in more than an "s". -fn plural(one: &'static str, many: &'static str, n: usize) -> Label { - Label::of(if n == 1 { one } else { many }) -} - #[derive(Default)] pub struct Session { docs: Vec, @@ -3061,7 +1497,7 @@ fn note_warnings(list: &mut Vec, new: &[String]) { /// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or /// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an /// encoding this can't read. -fn decode_data_file(bytes: &[u8]) -> Option> { +pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { if bytes.starts_with(b"%FDF") { return None; } @@ -3095,7 +1531,7 @@ fn decode_data_file(bytes: &[u8]) -> Option> { /// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, /// buttons that did nothing but run scripts, and fields whose value differs from the form's /// data (data PdfCraft could not read into them, now lost). -fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { +pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { let fields = pdfcraft_forms::fields(doc); let data = xfa_field_data(doc); let differ = pdfcraft_xfa::read_values(doc, &data) @@ -3150,7 +1586,7 @@ const XFA_REMOVED_PREFIX: &str = "XFA removed: "; /// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), /// rather than XFDF, Acrobat's `` XML or a table? By its root element. -fn is_xfa_data_file(text: &str) -> bool { +pub(crate) fn is_xfa_data_file(text: &str) -> bool { let text = text.get(..text.len().min(65_536)).unwrap_or(text); let mut rest = text.trim_start_matches('\u{feff}').trim_start(); // Past the XML declaration, comments and processing instructions. @@ -3174,7 +1610,7 @@ fn is_xfa_data_file(text: &str) -> bool { } /// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. -fn image_content_type(bytes: &[u8]) -> Option<&'static str> { +pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { match pdfcraft_xfa::image::kind(bytes)? { pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), pdfcraft_xfa::image::Kind::Png => Some("image/png"), @@ -3187,7 +1623,7 @@ fn image_content_type(bytes: &[u8]) -> Option<&'static str> { /// After the XFA data changed outside the fields (an import, a picture): a laid-out form is /// laid out again from the data (then, when `calculate`, its calculations run), and the /// fields take the data's values. -fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { +pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { if let Some(tpl) = cx.xfa.clone() { 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 }; @@ -5142,7 +3578,7 @@ pub struct MarkFile { } /// Embed an image that is placed on a page, turned the way its EXIF Orientation says. -fn embed_image( +pub(crate) fn embed_image( doc: &mut pdfcraft_cos::Document, name: &str, bytes: &[u8], @@ -5154,7 +3590,7 @@ fn embed_image( /// shape in a box of any other shape. A box that is already that shape (to within rounding) is /// kept exactly, so callers that size it themselves get their box back. An empty or non-finite /// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse. -fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { +pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { if !rect.iter().all(|v| v.is_finite()) { return rect; } @@ -5174,7 +3610,7 @@ fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { } /// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. -fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { +pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { let head = &f.bytes[..f.bytes.len().min(1024)]; if head.windows(5).any(|w| w == b"%PDF-") { let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; From 631fc13f13504c9d3395ce7410e6881e98d918ec Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:21:09 +0100 Subject: [PATCH 006/246] refactor(engine): move protection and the security summary to protection.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 158 +------------------------------- crates/engine/src/protection.rs | 158 ++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 155 deletions(-) create mode 100644 crates/engine/src/protection.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0926c3ed28..fdf57c87fa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -34,6 +34,7 @@ pub mod ocr; pub mod optimizer; pub mod print_raster; pub mod printcheck; +mod protection; mod scan_colours; mod scan_fields; pub mod signature_image; @@ -236,6 +237,8 @@ pub use history::Label; use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; +use protection::unrestricted; +pub use protection::{Changes, Printing, Protection, SecuritySummary}; /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -1083,161 +1086,6 @@ pub struct RecoverySnapshot { pub encrypted: bool, } -/// Document Properties ▸ Security. -#[derive(Clone, Debug, PartialEq)] -pub struct SecuritySummary { - /// "AES, 256-bit" etc. - pub method: String, - /// Opened with the owner password (no restrictions apply). - pub owner: bool, - /// For pending protection: the restrictions as they will apply to others. - pub permissions: pdfcraft_cos::Permissions, - /// Set in this session; written by the next save. - pub pending: bool, -} - -/// What printing a protected document allows (Acrobat: Printing allowed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Printing { - None, - /// "Low Resolution (150 dpi)". - Low, - High, -} - -/// What changes a protected document allows (Acrobat: Changes allowed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Changes { - None, - /// "Inserting, deleting, and rotating pages". - Pages, - /// "Filling in form fields and signing existing signature fields". - FillSign, - /// "Commenting, filling in form fields, and signing existing signature fields". - CommentFillSign, - /// "Any except extracting pages". - AnyExceptExtract, -} - -/// Password protection to apply (Protect Using Password and its Advanced options). -#[derive(Clone, PartialEq)] -pub struct Protection { - /// Required to open the document (the user password). - pub open_password: Option, - /// Required to change security and lift the restrictions below (the owner password). - pub permissions_password: Option, - pub printing: Printing, - pub changes: Changes, - /// "Enable copying of text, images, and other content". - pub copy: bool, - /// "Enable text access for screen reader devices". - pub accessibility: bool, - /// Compatibility level; AES-256 (Acrobat X and later) by default. - pub algorithm: pdfcraft_cos::Algorithm, - /// `false`: "Encrypt all document contents except metadata". - pub encrypt_metadata: bool, -} - -impl std::fmt::Debug for Protection { - // Never print passwords (edits end up in logs and error messages). - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Protection") - .field("open_password", &self.open_password.as_ref().map(|_| "…")) - .field("permissions_password", &self.permissions_password.as_ref().map(|_| "…")) - .field("printing", &self.printing) - .field("changes", &self.changes) - .field("copy", &self.copy) - .field("accessibility", &self.accessibility) - .field("algorithm", &self.algorithm) - .field("encrypt_metadata", &self.encrypt_metadata) - .finish() - } -} - -impl Default for Protection { - /// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying. - fn default() -> Self { - Self { - open_password: None, - permissions_password: None, - printing: Printing::High, - changes: Changes::None, - copy: false, - accessibility: true, - algorithm: pdfcraft_cos::Algorithm::Aes256, - encrypt_metadata: true, - } - } -} - -impl Protection { - /// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices. - pub fn permission_bits(&self) -> i32 { - let bit = |n: u32| 1i32 << (n - 1); - if self.permissions_password.is_none() { - return -1; // nothing restricted without a permissions password - } - let mut p = 0; - match self.printing { - Printing::None => {} - Printing::Low => p |= bit(3), - Printing::High => p |= bit(3) | bit(12), - } - p |= match self.changes { - Changes::None => 0, - Changes::Pages => bit(11), - Changes::FillSign => bit(9), - Changes::CommentFillSign => bit(6) | bit(9), - Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9), - }; - if self.copy { - p |= bit(5) | bit(10); - } - if self.accessibility { - p |= bit(10); - } - p - } - - fn validate(&self) -> Result<(), EditError> { - let bad = |m: &str| Err(EditError::Protection(m.to_string())); - match (&self.open_password, &self.permissions_password) { - (None, None) => return bad("enter a password to open the document, a permissions password, or both"), - (Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"), - _ => {} - } - if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) { - return bad("passwords can't be empty"); - } - if self.algorithm != pdfcraft_cos::Algorithm::Aes256 - && [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c))) - { - return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)"); - } - // Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep, - // which drops soft hyphens and folds compatibility forms; older levels: the first 32 - // bytes, padded). One that comes out as the empty password opens the file without asking, - // and an open password that comes out as the permissions password lifts the restrictions. - let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p); - let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective)); - let empty = effective(""); - if open.iter().chain(&permissions).any(|p| *p == empty) { - return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"); - } - if open.is_some() && open == permissions { - return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)"); - } - Ok(()) - } -} - -/// Opened as owner, or nothing is restricted (no permissions password was set): security may -/// be changed, as in Acrobat. -pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { - const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 - p.owner || p.bits & ALL == ALL -} - /// The comment list, read from the object graph (as `inspect` lists comments). fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { let mut list: Vec = pdfcraft_annot::summaries(doc) diff --git a/crates/engine/src/protection.rs b/crates/engine/src/protection.rs new file mode 100644 index 0000000000..e66f01538a --- /dev/null +++ b/crates/engine/src/protection.rs @@ -0,0 +1,158 @@ +//! Password protection to apply, and the security a document has. + +use crate::EditError; + +/// Document Properties ▸ Security. +#[derive(Clone, Debug, PartialEq)] +pub struct SecuritySummary { + /// "AES, 256-bit" etc. + pub method: String, + /// Opened with the owner password (no restrictions apply). + pub owner: bool, + /// For pending protection: the restrictions as they will apply to others. + pub permissions: pdfcraft_cos::Permissions, + /// Set in this session; written by the next save. + pub pending: bool, +} + +/// What printing a protected document allows (Acrobat: Printing allowed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Printing { + None, + /// "Low Resolution (150 dpi)". + Low, + High, +} + +/// What changes a protected document allows (Acrobat: Changes allowed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Changes { + None, + /// "Inserting, deleting, and rotating pages". + Pages, + /// "Filling in form fields and signing existing signature fields". + FillSign, + /// "Commenting, filling in form fields, and signing existing signature fields". + CommentFillSign, + /// "Any except extracting pages". + AnyExceptExtract, +} + +/// Password protection to apply (Protect Using Password and its Advanced options). +#[derive(Clone, PartialEq)] +pub struct Protection { + /// Required to open the document (the user password). + pub open_password: Option, + /// Required to change security and lift the restrictions below (the owner password). + pub permissions_password: Option, + pub printing: Printing, + pub changes: Changes, + /// "Enable copying of text, images, and other content". + pub copy: bool, + /// "Enable text access for screen reader devices". + pub accessibility: bool, + /// Compatibility level; AES-256 (Acrobat X and later) by default. + pub algorithm: pdfcraft_cos::Algorithm, + /// `false`: "Encrypt all document contents except metadata". + pub encrypt_metadata: bool, +} + +impl std::fmt::Debug for Protection { + // Never print passwords (edits end up in logs and error messages). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Protection") + .field("open_password", &self.open_password.as_ref().map(|_| "…")) + .field("permissions_password", &self.permissions_password.as_ref().map(|_| "…")) + .field("printing", &self.printing) + .field("changes", &self.changes) + .field("copy", &self.copy) + .field("accessibility", &self.accessibility) + .field("algorithm", &self.algorithm) + .field("encrypt_metadata", &self.encrypt_metadata) + .finish() + } +} + +impl Default for Protection { + /// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying. + fn default() -> Self { + Self { + open_password: None, + permissions_password: None, + printing: Printing::High, + changes: Changes::None, + copy: false, + accessibility: true, + algorithm: pdfcraft_cos::Algorithm::Aes256, + encrypt_metadata: true, + } + } +} + +impl Protection { + /// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices. + pub fn permission_bits(&self) -> i32 { + let bit = |n: u32| 1i32 << (n - 1); + if self.permissions_password.is_none() { + return -1; // nothing restricted without a permissions password + } + let mut p = 0; + match self.printing { + Printing::None => {} + Printing::Low => p |= bit(3), + Printing::High => p |= bit(3) | bit(12), + } + p |= match self.changes { + Changes::None => 0, + Changes::Pages => bit(11), + Changes::FillSign => bit(9), + Changes::CommentFillSign => bit(6) | bit(9), + Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9), + }; + if self.copy { + p |= bit(5) | bit(10); + } + if self.accessibility { + p |= bit(10); + } + p + } + + pub(crate) fn validate(&self) -> Result<(), EditError> { + let bad = |m: &str| Err(EditError::Protection(m.to_string())); + match (&self.open_password, &self.permissions_password) { + (None, None) => return bad("enter a password to open the document, a permissions password, or both"), + (Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"), + _ => {} + } + if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) { + return bad("passwords can't be empty"); + } + if self.algorithm != pdfcraft_cos::Algorithm::Aes256 + && [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c))) + { + return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)"); + } + // Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep, + // which drops soft hyphens and folds compatibility forms; older levels: the first 32 + // bytes, padded). One that comes out as the empty password opens the file without asking, + // and an open password that comes out as the permissions password lifts the restrictions. + let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p); + let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective)); + let empty = effective(""); + if open.iter().chain(&permissions).any(|p| *p == empty) { + return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"); + } + if open.is_some() && open == permissions { + return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)"); + } + Ok(()) + } +} + +/// Opened as owner, or nothing is restricted (no permissions password was set): security may +/// be changed, as in Acrobat. +pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { + const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 + p.owner || p.bits & ALL == ALL +} From 05098de5421130dfe2ae013b4e6e1f06afa5008e Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:22:07 +0100 Subject: [PATCH 007/246] refactor(engine): move the open pipeline to open.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 345 +----------------------------------- crates/engine/src/open.rs | 355 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 358 insertions(+), 342 deletions(-) create mode 100644 crates/engine/src/open.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fdf57c87fa..ac836aabc1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -31,6 +31,7 @@ mod history; pub mod js; pub mod links; pub mod ocr; +mod open; pub mod optimizer; pub mod print_raster; pub mod printcheck; @@ -234,7 +235,8 @@ pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; use edit::{EditCtx, check_permission, run_edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; -use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; +use history::{Editor, Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; +pub use open::{OpenContext, PreparedDocument}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; use protection::unrestricted; @@ -1230,35 +1232,6 @@ pub struct Session { xfa_time_scale: Option, } -/// Immutable session state needed while a document is prepared away from its owning thread. -#[derive(Clone)] -pub struct OpenContext { - now: Option, - js_off: bool, - trust: Arc, - xfa_time_scale: u32, -} - -/// A fully prepared document that has not yet been registered in a session. -pub struct PreparedDocument { - document: Document, -} - -impl OpenContext { - /// Prepare a document without mutating the session that supplied this context. - pub fn prepare_open( - &self, - name: impl Into, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - let name = name.into(); - guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) - .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) - } -} - /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; @@ -1649,318 +1622,6 @@ impl Session { self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) } - /// Snapshot the session state that affects open-time scripts, dates and signature trust. - pub fn open_context(&self) -> OpenContext { - OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) } - } - - /// Prepare a document without adding it to this session. - pub fn prepare_open( - &self, - name: impl Into, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - self.open_context().prepare_open(name, path, bytes, password) - } - - /// Register a prepared document. This is the only open step that mutates the session. - pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { - self.next_id += 1; - let id = DocId(self.next_id); - let mut document = prepared.document; - document.id = id; - self.docs.push(document); - id - } - - /// Synchronous convenience API used by the CLI and automation. - pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { - let prepared = self.prepare_open(name, path, bytes, password)?; - Ok(self.commit_open(prepared)) - } - - fn prepare_open_unguarded( - context: &OpenContext, - name: String, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); - // The renderer authenticates on its own. It cannot use the owner password of R2–R4 - // files, so give it the user password that owner authentication recovers. - let (info, render_password) = match inspect(bytes.clone(), password) { - Ok(info) => (info, password.map(str::to_owned)), - Err(OpenError::WrongPassword) => { - let user = match &cos { - Ok(Ok(d)) => recovered_render_password(d), - _ => None, - }; - let user = user.ok_or(OpenError::WrongPassword)?; - (inspect(bytes.clone(), Some(&user))?, Some(user)) - } - Err(e) => return Err(e), - }; - // A dynamic XFA form is a shell around an XML template; lay the template out into real - // pages and fields so the rest of the engine works on it. The original bytes stay: the - // laid-out form is one appended revision. - let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - // Write `work` as one more revision and reopen it: what the document then is. - let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { - let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; - let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; - let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; - Ok((new_bytes, new_info, new_cos)) - }; - let (bytes, info, cos, xfa) = match (info.xfa, cos) { - (Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => { - let mut work = cos.clone(); - let laid_out = guard(|| xfa_layout(&mut work)) - .unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})"))) - .and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report))); - match laid_out { - Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)), - Err(e) => { - let mut info = info; - info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}")); - (bytes, info, Ok(Ok(cos)), None) - } - } - } - // A static XFA form, or a dynamic one laid out earlier: its datasets may hold values - // filled in by another viewer since; give the fields those values. - (Some(_), Ok(Ok(cos))) => { - let report = pdfcraft_xfa::existing_layout(&cos); - let mut work = cos.clone(); - let synced = - guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})"))); - match synced { - Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report), - Ok(names) => match rebase(&work) { - Ok((b, mut i, c)) => { - // The fields were rewritten from the XFA data: say so, and which. - let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect(); - let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() }; - i.warnings.push(format!( - "{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}", - names.len(), - if names.len() == 1 { "" } else { "s" }, - shown.join(", ") - )); - (b, i, Ok(Ok(c)), report) - } - Err(e) => { - let mut info = info; - info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); - (bytes, info, Ok(Ok(cos)), report) - } - }, - Err(e) => { - let mut info = info; - info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); - (bytes, info, Ok(Ok(cos)), report) - } - } - } - (_, cos) => (bytes, info, cos, None), - }; - // A laid-out XFA form: its initialize and calculate scripts run now, as on opening in - // Acrobat; what they change is one more revision, still nothing to save. - let mut script_output = js::JsOutput::default(); - let mut ran_away = false; - let xfa_template = match (&xfa, &cos) { - (Some(_), Ok(Ok(c))) => xfa::template(c), - _ => None, - }; - let (bytes, info, cos) = match (&xfa, cos) { - (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { - Some(tpl) => { - let mut work = cos.clone(); - let pages = info.pages.len(); - let ran = guard(|| { - let mut run = xfa::XfaRun { - page_count: pages, - out: &mut script_output, - datasets: None, - ran_away: false, - time_scale: context.xfa_time_scale, - }; - let changes = xfa::on_open(&mut work, &tpl, &mut run); - if run.ran_away { - ran_away = true; - } - changes - }) - .unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})"))); - match ran { - Ok(changes) if work.is_modified() => match rebase(&work) { - Ok((b, mut i, c)) => { - // The file as saved differs from what opening shows: say so. - if !changes.is_empty() { - i.warnings.push(format!( - "This form's scripts changed it on opening ({}); saving keeps those changes", - changes.describe() - )); - } - (b, i, Ok(Ok(c))) - } - Err(e) => { - script_output.errors.push(format!("The form's scripts could not be applied: {e}")); - (bytes, info, Ok(Ok(cos))) - } - }, - Ok(_) => (bytes, info, Ok(Ok(cos))), - Err(e) => { - script_output.errors.push(format!("The form's scripts could not run: {e}")); - (bytes, info, Ok(Ok(cos))) - } - } - } - None => (bytes, info, Ok(Ok(cos))), - }, - (_, cos) => (bytes, info, cos), - }; - // The catalog leads to no page, but the reader found pages by scanning: gather the page - // objects into a page tree as one more revision, or page edits address none of them - // (#1656). Kept only when the reader then shows the same pages, and never on a signed - // document, where writing the repair could rewrite the file under its signatures. - let (bytes, info, cos) = match cos { - Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => { - let mut work = cos.clone(); - let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed); - let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) { - Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)), - _ => None, - }; - match rebuilt { - Some((b, i, mut c)) => { - for line in cos.repair_log() { - c.note_repair(line.clone()); - } - c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); - (b, i, Ok(Ok(c))) - } - None => (bytes, info, Ok(Ok(cos))), - } - } - cos => (bytes, info, cos), - }; - // XFA notes survive the document being re-read after edits. - let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); - // A script that ran away on open turns the form's scripts off for this document. - document.xfa_template = if ran_away { None } else { xfa_template }; - document.js_output.append(script_output); - if ran_away { - document.xfa_scripts_off = true; - document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - note_warnings(&mut document.xfa_warnings, &xfa_warnings); - Ok(PreparedDocument { document }) - } - - /// The last step of opening: build the document record and register it. - #[allow(clippy::too_many_arguments)] - fn build_document( - trust: &Arc, - name: String, - path: Option, - bytes: Arc>, - mut info: DocInfo, - cos: Result, Box>, - render_password: Option, - password: Option<&str>, - xfa: Option, - ) -> Document { - let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; - let (mut editor, read_only_reason) = match cos { - Ok(Ok(cos)) => { - let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; - let saved = cos.bytes().clone(); - ( - Some(Editor { - cos, - saved, - saved_keys: keys.clone(), - unsaved_file: false, - undo: Vec::new(), - redo: Vec::new(), - keys, - appearances: AppearanceCache::default(), - field_appearances: pdfcraft_forms::FieldAppearanceCache::default(), - text_blocks: TextBlocksCache::default(), - }), - None, - ) - } - Ok(Err(e)) => (None, Some(e.to_string())), - Err(_) => (None, Some("the document structure could not be read for editing".into())), - }; - let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); - let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); - // The inspection reads only `/Info`; a file stating its title or author only in XMP still - // shows them (#2373). - if let Some(e) = &editor { - for (field, value) in info_fields(&mut info, &e.cos) { - if field.is_none() { - *field = value; - } - } - } - let display = display_bytes(editor.as_mut(), &bytes); - let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); - let (form, adopted) = editor - .as_ref() - .map(|e| { - let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos); - xfa::mark_script_buttons(&e.cos, &mut fields.items); - (fields.items, fields.adopted_page_fields) - }) - .unwrap_or_default(); - // Fields the file lists only as page widgets are adopted leniently; the file says so. - if adopted > 0 - && let Some(e) = &mut editor - { - let line = if adopted == 1 { - "the form's /Fields list omits 1 field; it was read from the page annotations".to_string() - } else { - format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations") - }; - e.cos.note_repair(line); - } - Document { - id: DocId(0), - name, - path, - bytes, - display, - info, - renderer, - password: render_password, - dirty: false, - generation: 0, - snapshot_generation: 0, - read_only_reason, - form: Arc::new(form), - marker_scan: OnceLock::new(), - links: OnceLock::new(), - signatures: Arc::new(signatures), - trust: trust.clone(), - sig_cache, - editor, - config, - js_output: Default::default(), - xfa, - xfa_template: None, - xfa_scripts_off: false, - fonts_stale: false, - xfa_warnings: Vec::new(), - edit_notices: Vec::new(), - } - } - fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) } diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs new file mode 100644 index 0000000000..d8950d1d6d --- /dev/null +++ b/crates/engine/src/open.rs @@ -0,0 +1,355 @@ +//! Opening a document: preparing it away from the session, then registering it. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect}; + +use crate::history::{Editor, Keys, TextBlocksCache}; +use crate::{ + AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, guard, info_fields, js, note_warnings, + recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets, +}; + +/// Immutable session state needed while a document is prepared away from its owning thread. +#[derive(Clone)] +pub struct OpenContext { + now: Option, + js_off: bool, + trust: Arc, + xfa_time_scale: u32, +} + +/// A fully prepared document that has not yet been registered in a session. +pub struct PreparedDocument { + document: Document, +} + +impl OpenContext { + /// Prepare a document without mutating the session that supplied this context. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let name = name.into(); + guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) + .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) + } +} + +impl Session { + /// Snapshot the session state that affects open-time scripts, dates and signature trust. + pub fn open_context(&self) -> OpenContext { + OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) } + } + + /// Prepare a document without adding it to this session. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + self.open_context().prepare_open(name, path, bytes, password) + } + + /// Register a prepared document. This is the only open step that mutates the session. + pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { + self.next_id += 1; + let id = DocId(self.next_id); + let mut document = prepared.document; + document.id = id; + self.docs.push(document); + id + } + + /// Synchronous convenience API used by the CLI and automation. + pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { + let prepared = self.prepare_open(name, path, bytes, password)?; + Ok(self.commit_open(prepared)) + } + + fn prepare_open_unguarded( + context: &OpenContext, + name: String, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); + // The renderer authenticates on its own. It cannot use the owner password of R2–R4 + // files, so give it the user password that owner authentication recovers. + let (info, render_password) = match inspect(bytes.clone(), password) { + Ok(info) => (info, password.map(str::to_owned)), + Err(OpenError::WrongPassword) => { + let user = match &cos { + Ok(Ok(d)) => recovered_render_password(d), + _ => None, + }; + let user = user.ok_or(OpenError::WrongPassword)?; + (inspect(bytes.clone(), Some(&user))?, Some(user)) + } + Err(e) => return Err(e), + }; + // A dynamic XFA form is a shell around an XML template; lay the template out into real + // pages and fields so the rest of the engine works on it. The original bytes stay: the + // laid-out form is one appended revision. + let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + // Write `work` as one more revision and reopen it: what the document then is. + let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { + let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; + let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; + let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; + Ok((new_bytes, new_info, new_cos)) + }; + let (bytes, info, cos, xfa) = match (info.xfa, cos) { + (Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => { + let mut work = cos.clone(); + let laid_out = guard(|| xfa_layout(&mut work)) + .unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})"))) + .and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report))); + match laid_out { + Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)), + Err(e) => { + let mut info = info; + info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}")); + (bytes, info, Ok(Ok(cos)), None) + } + } + } + // A static XFA form, or a dynamic one laid out earlier: its datasets may hold values + // filled in by another viewer since; give the fields those values. + (Some(_), Ok(Ok(cos))) => { + let report = pdfcraft_xfa::existing_layout(&cos); + let mut work = cos.clone(); + let synced = + guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})"))); + match synced { + Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report), + Ok(names) => match rebase(&work) { + Ok((b, mut i, c)) => { + // The fields were rewritten from the XFA data: say so, and which. + let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect(); + let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() }; + i.warnings.push(format!( + "{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}", + names.len(), + if names.len() == 1 { "" } else { "s" }, + shown.join(", ") + )); + (b, i, Ok(Ok(c)), report) + } + Err(e) => { + let mut info = info; + info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); + (bytes, info, Ok(Ok(cos)), report) + } + }, + Err(e) => { + let mut info = info; + info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); + (bytes, info, Ok(Ok(cos)), report) + } + } + } + (_, cos) => (bytes, info, cos, None), + }; + // A laid-out XFA form: its initialize and calculate scripts run now, as on opening in + // Acrobat; what they change is one more revision, still nothing to save. + let mut script_output = js::JsOutput::default(); + let mut ran_away = false; + let xfa_template = match (&xfa, &cos) { + (Some(_), Ok(Ok(c))) => xfa::template(c), + _ => None, + }; + let (bytes, info, cos) = match (&xfa, cos) { + (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { + Some(tpl) => { + let mut work = cos.clone(); + let pages = info.pages.len(); + let ran = guard(|| { + let mut run = xfa::XfaRun { + page_count: pages, + out: &mut script_output, + datasets: None, + ran_away: false, + time_scale: context.xfa_time_scale, + }; + let changes = xfa::on_open(&mut work, &tpl, &mut run); + if run.ran_away { + ran_away = true; + } + changes + }) + .unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})"))); + match ran { + Ok(changes) if work.is_modified() => match rebase(&work) { + Ok((b, mut i, c)) => { + // The file as saved differs from what opening shows: say so. + if !changes.is_empty() { + i.warnings.push(format!( + "This form's scripts changed it on opening ({}); saving keeps those changes", + changes.describe() + )); + } + (b, i, Ok(Ok(c))) + } + Err(e) => { + script_output.errors.push(format!("The form's scripts could not be applied: {e}")); + (bytes, info, Ok(Ok(cos))) + } + }, + Ok(_) => (bytes, info, Ok(Ok(cos))), + Err(e) => { + script_output.errors.push(format!("The form's scripts could not run: {e}")); + (bytes, info, Ok(Ok(cos))) + } + } + } + None => (bytes, info, Ok(Ok(cos))), + }, + (_, cos) => (bytes, info, cos), + }; + // The catalog leads to no page, but the reader found pages by scanning: gather the page + // objects into a page tree as one more revision, or page edits address none of them + // (#1656). Kept only when the reader then shows the same pages, and never on a signed + // document, where writing the repair could rewrite the file under its signatures. + let (bytes, info, cos) = match cos { + Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => { + let mut work = cos.clone(); + let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed); + let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) { + Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)), + _ => None, + }; + match rebuilt { + Some((b, i, mut c)) => { + for line in cos.repair_log() { + c.note_repair(line.clone()); + } + c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); + (b, i, Ok(Ok(c))) + } + None => (bytes, info, Ok(Ok(cos))), + } + } + cos => (bytes, info, cos), + }; + // XFA notes survive the document being re-read after edits. + let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; + let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); + // A script that ran away on open turns the form's scripts off for this document. + document.xfa_template = if ran_away { None } else { xfa_template }; + document.js_output.append(script_output); + if ran_away { + document.xfa_scripts_off = true; + document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + note_warnings(&mut document.xfa_warnings, &xfa_warnings); + Ok(PreparedDocument { document }) + } + + /// The last step of opening: build the document record and register it. + #[allow(clippy::too_many_arguments)] + fn build_document( + trust: &Arc, + name: String, + path: Option, + bytes: Arc>, + mut info: DocInfo, + cos: Result, Box>, + render_password: Option, + password: Option<&str>, + xfa: Option, + ) -> Document { + let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; + let (mut editor, read_only_reason) = match cos { + Ok(Ok(cos)) => { + let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; + let saved = cos.bytes().clone(); + ( + Some(Editor { + cos, + saved, + saved_keys: keys.clone(), + unsaved_file: false, + undo: Vec::new(), + redo: Vec::new(), + keys, + appearances: AppearanceCache::default(), + field_appearances: pdfcraft_forms::FieldAppearanceCache::default(), + text_blocks: TextBlocksCache::default(), + }), + None, + ) + } + Ok(Err(e)) => (None, Some(e.to_string())), + Err(_) => (None, Some("the document structure could not be read for editing".into())), + }; + let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); + let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); + // The inspection reads only `/Info`; a file stating its title or author only in XMP still + // shows them (#2373). + if let Some(e) = &editor { + for (field, value) in info_fields(&mut info, &e.cos) { + if field.is_none() { + *field = value; + } + } + } + let display = display_bytes(editor.as_mut(), &bytes); + let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); + let (form, adopted) = editor + .as_ref() + .map(|e| { + let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos); + xfa::mark_script_buttons(&e.cos, &mut fields.items); + (fields.items, fields.adopted_page_fields) + }) + .unwrap_or_default(); + // Fields the file lists only as page widgets are adopted leniently; the file says so. + if adopted > 0 + && let Some(e) = &mut editor + { + let line = if adopted == 1 { + "the form's /Fields list omits 1 field; it was read from the page annotations".to_string() + } else { + format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations") + }; + e.cos.note_repair(line); + } + Document { + id: DocId(0), + name, + path, + bytes, + display, + info, + renderer, + password: render_password, + dirty: false, + generation: 0, + snapshot_generation: 0, + read_only_reason, + form: Arc::new(form), + marker_scan: OnceLock::new(), + links: OnceLock::new(), + signatures: Arc::new(signatures), + trust: trust.clone(), + sig_cache, + editor, + config, + js_output: Default::default(), + xfa, + xfa_template: None, + xfa_scripts_off: false, + fonts_stale: false, + xfa_warnings: Vec::new(), + edit_notices: Vec::new(), + } + } +} From f1489157303bdc831ac606405905f074aaad6582 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:22:54 +0100 Subject: [PATCH 008/246] refactor(engine): move the other crates' re-exports to reexports.rs The doc comment for SplitPart had drifted above the LabelStyle re-export; it is back on SplitPart. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 53 ++-------------------------------- crates/engine/src/reexports.rs | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 50 deletions(-) create mode 100644 crates/engine/src/reexports.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ac836aabc1..6edc4d7b7e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,6 +36,7 @@ pub mod optimizer; pub mod print_raster; pub mod printcheck; mod protection; +mod reexports; mod scan_colours; mod scan_fields; pub mod signature_image; @@ -51,36 +52,7 @@ pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use stamp_preview::StampPreview; pub use text_preview::TextEditPreview; -pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; - -/// One file produced by a split: (1-based first page, last page, PDF bytes). -pub use pdfcraft_organize::LabelStyle; -pub use pdfcraft_organize::Standard; -pub use pdfcraft_organize::check_custom_key; -pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; - -pub use pdfcraft_cos::Algorithm; -pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; -pub use pdfcraft_edit::added::{ - CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, - text_width as added_text_width, -}; -pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, -}; -pub use pdfcraft_forms::{ - BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, - FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, - Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, - calculations_changed as form_calculations_changed, flags as field_flags, -}; - -pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; -pub use pdfcraft_measure as measure; -pub use pdfcraft_xfa::Report as XfaLayout; - -pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; +pub use reexports::*; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] @@ -103,7 +75,6 @@ pub enum ImageEdit { }, Delete, } -pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; /// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user /// space, vertically centred) and `height` points tall. `None` for text with no outlines. @@ -151,25 +122,6 @@ pub fn typed_signature_graphic(text: &str) -> Option { pub use links::{ MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; -/// Comment geometry helpers (text-box line breaking) for frontends. -pub use pdfcraft_annot::appearance as annot_text; -pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; -pub use pdfcraft_annot::{ - AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, - ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, -}; -pub use pdfcraft_forms::detect; -pub use pdfcraft_optimize as optimize; -pub use pdfcraft_preflight as pdfa; -pub use pdfcraft_print as print; -pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; -pub use pdfcraft_redact::patterns::{ - MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, -}; -pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; -pub use pdfcraft_sign as sign; -pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; -pub use pdfcraft_xfdf::Format as DataFormat; /// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a /// filled-in PDF form), as CSV with one row per file. @@ -179,6 +131,7 @@ pub fn merge_data_files(files: &[(String, Vec)]) -> Result { Ok(pdfcraft_xfdf::merge_csv(&rows)) } +/// One file produced by a split: (1-based first page, last page, PDF bytes). pub type SplitPart = (usize, usize, Arc>); use std::sync::{Arc, OnceLock}; diff --git a/crates/engine/src/reexports.rs b/crates/engine/src/reexports.rs new file mode 100644 index 0000000000..208a8e450b --- /dev/null +++ b/crates/engine/src/reexports.rs @@ -0,0 +1,53 @@ +//! What the engine re-exports from the other pdfcraft crates, so frontends need only this one. + +pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; + +pub use pdfcraft_organize::LabelStyle; +pub use pdfcraft_organize::Standard; +pub use pdfcraft_organize::check_custom_key; +pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; + +pub use pdfcraft_cos::Algorithm; +pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; +pub use pdfcraft_edit::added::{ + CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, + text_width as added_text_width, +}; +pub use pdfcraft_edit::{ + Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, +}; +pub use pdfcraft_forms::{ + BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, + FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, + Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, + calculations_changed as form_calculations_changed, flags as field_flags, +}; + +pub use pdfcraft_a11y as a11y; +pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; +pub use pdfcraft_measure as measure; +pub use pdfcraft_xfa::Report as XfaLayout; + +pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; + +pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; + +/// Comment geometry helpers (text-box line breaking) for frontends. +pub use pdfcraft_annot::appearance as annot_text; +pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; +pub use pdfcraft_annot::{ + AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, + ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, +}; +pub use pdfcraft_forms::detect; +pub use pdfcraft_optimize as optimize; +pub use pdfcraft_preflight as pdfa; +pub use pdfcraft_print as print; +pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; +pub use pdfcraft_redact::patterns::{ + MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, +}; +pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; +pub use pdfcraft_sign as sign; +pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; +pub use pdfcraft_xfdf::Format as DataFormat; From 5b7518075950d1b2da929ffe79ba9d46dd8c8f62 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:24:44 +0100 Subject: [PATCH 009/246] refactor(engine): move Session and its operations to session.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 1581 +-------------------------------- crates/engine/src/session.rs | 1590 ++++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 2 + 3 files changed, 1598 insertions(+), 1575 deletions(-) create mode 100644 crates/engine/src/session.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6edc4d7b7e..91d22accd7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -39,6 +39,7 @@ mod protection; mod reexports; mod scan_colours; mod scan_fields; +mod session; pub mod signature_image; pub mod signature_scan; mod stamp_preview; @@ -184,16 +185,17 @@ fn record_link_scan() { }); } +use edit::EditCtx; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; -use edit::{EditCtx, check_permission, run_edit}; pub use error::{EditError, SourceProblem}; +use history::Editor; pub use history::Label; -use history::{Editor, Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; pub use open::{OpenContext, PreparedDocument}; -use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; -use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, Layer, LayerOp, RenderConfig, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; +pub use session::Session; /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -1161,30 +1163,6 @@ pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option pdfcraft_organize::page_count(&doc).ok() } -#[derive(Default)] -pub struct Session { - docs: Vec, - next_id: u64, - /// Seconds since the Unix epoch, injected so saves are deterministic in tests. - clock: Option i64>, - /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). - /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the - /// injected time AS local time and zeroes the zone, so injecting only it leaves either the - /// local dates or the UTC ones wrong by the zone's offset. - utc_offset: Option i64>, - /// Certificates trusted for signing (Acrobat: Trusted Certificates). - trust: Arc, - /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). - js_off: bool, - /// Preferences ▸ Date format, when not the default (see [`dates`]). - date_format: Option, - /// Preferences ▸ Date format ▸ Language, when not following the interface language. - date_language: Option, - /// What the XFA scripts' time limits are multiplied by (`None`: 1); see - /// [`Session::with_xfa_time_scale`]. - xfa_time_scale: Option, -} - /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; @@ -1483,1553 +1461,6 @@ fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) } -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) - } - - 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()) - } - - 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). - 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. - 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. - 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. - 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 - } -} - /// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` /// (0-based) of a PDF. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs new file mode 100644 index 0000000000..c3e34d90bd --- /dev/null +++ b/crates/engine/src/session.rs @@ -0,0 +1,1590 @@ +//! The session: the open documents and the operations on them. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; +use pdfcraft_render::{LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; + +#[cfg(test)] +use crate::check_refresh_panic; +use crate::edit::{EditCtx, check_permission, run_edit}; +use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; +use crate::{ + BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hidden, ImageResolution, + LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, SummarySort, TrustStore, + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, guard, info_fields, js, + local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster, render_threads, + scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, + writable, xfa, xfa_sync_datasets, +}; + +#[derive(Default)] +pub struct Session { + pub(crate) docs: Vec, + pub(crate) next_id: u64, + /// Seconds since the Unix epoch, injected so saves are deterministic in tests. + clock: Option i64>, + /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). + /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the + /// injected time AS local time and zeroes the zone, so injecting only it leaves either the + /// local dates or the UTC ones wrong by the zone's offset. + utc_offset: Option i64>, + /// Certificates trusted for signing (Acrobat: Trusted Certificates). + pub(crate) trust: Arc, + /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). + pub(crate) js_off: bool, + /// Preferences ▸ Date format, when not the default (see [`dates`]). + pub(crate) date_format: Option, + /// Preferences ▸ Date format ▸ Language, when not following the interface language. + pub(crate) date_language: Option, + /// What the XFA scripts' time limits are multiplied by (`None`: 1); see + /// [`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/tests.rs b/crates/engine/src/tests.rs index 360517a87c..d06faefc8b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,6 +1,8 @@ use std::sync::Arc; +use super::edit::check_permission; use super::*; +use pdfcraft_render::{OpenError, ReadingColors}; /// A minimal `n`-page document; page `i` shows "Page i+1". pub(crate) fn fixture(n: usize) -> Vec { From c4dc7e91268577bc74507ee5961ae0754f6a7fc9 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:29:41 +0100 Subject: [PATCH 010/246] refactor(engine): keep the moved docs' intra-doc links resolving Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/edit.rs | 6 +++--- crates/engine/src/history.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index a29e4a870b..faf4712078 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -364,7 +364,7 @@ pub enum Edit { activity: String, }, /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the - /// image (from [`ocr::OcrJob::run`]). + /// image (from [`ocr::OcrJob::run`](crate::ocr::OcrJob::run)). AddOcrText { page: usize, words: Vec, @@ -409,7 +409,7 @@ pub enum Edit { earlier: bool, }, /// Set the calculation order: these calculated fields first, in this order, then the others - /// in their current order (see [`Document::calculation_order`]). The form recalculates. + /// in their current order (see [`Document::calculation_order`](crate::Document::calculation_order)). The form recalculates. SetCalculationOrder { names: Vec, }, @@ -469,7 +469,7 @@ pub enum Edit { }, /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its /// style, an image in its box (display space) with its turns, flips and crop. An image's - /// picture is `file` (from [`Document::added_image_file`]): the copied image object is reused + /// picture is `file` (from [`Document::added_image_file`](crate::Document::added_image_file)): the copied image object is reused /// while it is still that picture in this document, else one an added image here already draws /// it from, else the file is embedded. PasteContent { diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 086d98a6ab..fafa65b4c2 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -169,7 +169,7 @@ impl Editor { /// the two cannot drift apart. #[derive(Clone, Debug, PartialEq)] pub struct Label { - /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`]. + /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`](crate::EDIT_LABELS). pub wording: &'static str, /// What the document supplies, when the wording has a hole for it. pub value: Option, From ff361fe13837515ec1fb2690704079c31d66978b Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:05:07 +0100 Subject: [PATCH 011/246] refactor(engine): move Document and its display helpers to document.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/document.rs | 713 ++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 708 +-------------------------------- crates/engine/src/tests.rs | 1 + 3 files changed, 719 insertions(+), 703 deletions(-) create mode 100644 crates/engine/src/document.rs diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs new file mode 100644 index 0000000000..0c85f51af6 --- /dev/null +++ b/crates/engine/src/document.rs @@ -0,0 +1,713 @@ +//! An open document: its working file, view data and edit state. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, RenderConfig, RenderPool}; + +use crate::history::Editor; +use crate::{ + CalculationOrder, CheckStyle, DocId, Edit, EditError, EditableObject, FormFieldKind, Hidden, InitialView, Label, MarkKind, SecuritySummary, + SignatureInfo, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password, unrestricted, +}; +#[cfg(test)] +use crate::{record_link_scan, record_marker_scan}; + +pub struct Document { + pub id: DocId, + pub name: String, + pub path: Option, + /// The working file: what Save writes. + pub bytes: Arc>, + /// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances + /// drawn for display only for comments that have none ([`display_bytes`]). Never saved. + pub display: Arc>, + pub info: DocInfo, + pub renderer: RenderPool, + /// The password the document was opened with (needed to read attachments, etc.). + pub password: Option, + /// `true` when there are edits that have not been saved. + pub dirty: bool, + /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. + pub(crate) generation: u64, + /// The generation last handed out by `autosave_snapshots`. + pub(crate) snapshot_generation: u64, + /// Why the document cannot be edited (e.g. encryption), if so. + pub read_only_reason: Option, + /// Interactive form fields of the current state (empty without a form). + pub form: Arc>, + pub(crate) marker_scan: OnceLock, + pub(crate) links: OnceLock>, + /// Signature fields, validated against the session's trust store (Signatures panel). + pub signatures: Arc>, + pub(crate) trust: Arc, + pub(crate) sig_cache: Arc, + pub(crate) editor: Option, + pub(crate) config: RenderConfig, + /// What field scripts printed or asked for (see [`Session::take_js_output`]). + pub(crate) js_output: js::JsOutput, + /// Dynamic XFA forms: what laying the template out produced (pages and fields are + /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). + pub xfa: Option, + /// The parsed template of a laid-out XFA form, for its scripts. + pub(crate) xfa_template: Option>, + /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). + pub(crate) xfa_scripts_off: bool, + /// A content-only refresh keeps `info` from before the edit; an edit may have added or + /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). + pub(crate) fonts_stale: bool, + /// XFA forms: what was approximated, rewritten or could not be written to the XFA data + /// (also in `info.warnings`, kept there when the document is re-read). + pub xfa_warnings: Vec, + /// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed, + /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. + pub edit_notices: Vec, +} + +/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn +/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat +/// keeps such text too, drawing it with fonts PdfCraft can't embed yet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextNotice { + /// The form field's full name, or `None` for a comment. + pub field: Option, + /// The page (0-based) of the comment or of the field's widget. + pub page: usize, + pub ch: char, + /// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances` + /// set); otherwise the `?` shows in every viewer. + pub redrawn_elsewhere: bool, +} + +impl std::fmt::Display for TextNotice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch)); + let page = self.page + 1; + match (&self.field, self.redrawn_elsewhere) { + (Some(name), true) => write!( + f, + "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value" + ), + (Some(name), false) => write!( + f, + "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere" + ), + (None, _) => write!( + f, + "a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer" + ), + } + } +} + +/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying +/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order. +pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { + let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() }; + let redrawn_elsewhere = pdfcraft_forms::need_appearances(next); + let mut names: Option> = None; + let mut out: Vec = Vec::new(); + for (page, p) in pages.iter().enumerate() { + let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a)); + for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) { + let now = next.get(r); + let Some(d) = now.as_dict() else { continue }; + let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue }; + // Only what this edit drew: a new annotation, or a new appearance. + let was = prev.get(r); + if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) { + continue; + } + let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| { + let names = names.get_or_insert_with(|| { + pdfcraft_forms::fields(next) + .into_iter() + .flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()) + .collect() + }); + names.get(&r).cloned().unwrap_or_default() + }); + if field.is_some() && out.iter().any(|n| n.field == field) { + continue; + } + out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch }); + } + } + out +} + +impl Document { + /// How the document opens (Document Properties ▸ Initial View). + /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be + /// read for editing). + pub fn accessibility_check(&self, options: &a11y::Options) -> Option { + self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) + } + + /// A script of this XFA form ran past its time limit and was abandoned, so the form's + /// scripts no longer run in this document (reopening it runs them again). The script's + /// error is in the JavaScript output; this is the state, for a viewer to show. + pub fn xfa_scripts_off(&self) -> bool { + self.xfa_scripts_off + } + + /// PDF Optimizer ▸ Audit space usage. + pub fn audit_space(&self) -> Vec { + self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() + } + + /// Edit a PDF: the images `page` draws (0-based). + pub fn page_images(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() + } + + /// Mixed editable objects on one page. Source inventories are zero-based and added items + /// appear once. Rectangles are user-space points, independent of the viewer's zoom. + pub fn editable_objects(&self, page: usize) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string()) + } + + /// Displayed top-left-origin motion to a user-space vector. Use the linear map only, + /// so even a large crop-box origin cannot erase a small drag by cancellation. + pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> { + let p = self.info.pages.get(page).ok_or("the page no longer exists")?; + let [x0, y0, x1, y1] = p.crop.map(f64::from); + let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3))); + let (u, v) = match p.rotation { + 90 => (b, -a), + 180 => (-a, -b), + 270 => (-b, a), + _ => (a, b), + }; + let offset = [u * (x1 - x0), -v * (y1 - y0)]; + if !offset.iter().all(|v| v.is_finite()) { + return Err("the move must be finite and non-zero".into()); + } + Ok(offset) + } + + /// Save image as: image `index` on `page` as a file (extension, bytes). + pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { + self.check_copying().map_err(|e| e.to_string())?; + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; + if img.is_form { + return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into()); + } + pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) + } + + /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). + pub fn text_blocks(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() + } + + /// Saved measurement annotations, calculated from their geometry and PDF scales, plus + /// the ones that couldn't be read (unsupported formats are skipped, not fatal). + pub fn measurements(&self) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + Ok(measure::list(&e.cos)) + } + pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string()) + } + /// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a + /// point) where the page sets no scale. + pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string()) + } + pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string()) + } + pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string()) + } + pub fn measurement_paths(&self, page: usize) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string()) + } + + /// A counter that changes with every edit (for caches of derived data). + pub fn edit_generation(&self) -> u64 { + self.generation + } + + /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). + pub fn text_lines(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() + } + + /// The form's calculation order: its calculated fields in the order they calculate, what each + /// formula reads, what is wrong with the order, and the order Fix order writes. + pub fn calculation_order(&self) -> CalculationOrder { + self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() + } + + /// Add alternate text: the figures, in document order. + pub fn figures(&self) -> Vec { + self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() + } + + /// Reachable indirect structure tags, in document order. + pub fn tags(&self) -> Vec { + self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() + } + + /// Marked-content items directly owned by one reachable indirect structure tag. + pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { + self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) + } + + /// The edit that fixes `rule`, for the rules with an automatic fix: the document language + /// (`value` is the language), the title (`value` replaces it; else the current title or the + /// file name) and the tab order. + pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result { + let value = value.map(str::trim).filter(|v| !v.is_empty()); + match rule { + a11y::Rule::PrimaryLanguage => { + let mut v = self.initial_view(); + v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned()); + Ok(Edit::SetInitialView(Box::new(v))) + } + a11y::Rule::Title => { + let title = value + .map(str::to_owned) + .or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty())) + .unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned()); + let mut v = self.initial_view(); + v.display_title = true; + Ok(Edit::Batch { + label: "Set document title".into(), + edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))], + }) + } + a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }), + other => Err(format!("\"{}\" has no automatic fix", other.name())), + } + } + + pub fn initial_view(&self) -> InitialView { + self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default() + } + + /// Signed: at least one signature field holds a signature. + pub fn is_signed(&self) -> bool { + self.signatures.iter().any(|s| s.signed) + } + + /// Whether comments are hidden (Comments ▸ Hide all comments). + pub fn comments_hidden(&self) -> bool { + self.config.hide_comments + } + + pub fn can_undo(&self) -> Option<&str> { + self.undo_label().map(Label::as_str) + } + + pub fn can_redo(&self) -> Option<&str> { + self.redo_label().map(Label::as_str) + } + + /// What Undo would undo, in parts, for an interface that says it in another language. + pub fn undo_label(&self) -> Option<&Label> { + self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) + } + + /// What Redo would redo, in parts. + pub fn redo_label(&self) -> Option<&Label> { + self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l) + } + + pub fn editable(&self) -> bool { + self.editor.is_some() + } + + /// What the opening password allows; `None` when the document is not encrypted. + pub fn permissions(&self) -> Option { + self.editor.as_ref().and_then(|e| e.cos.permissions()) + } + + /// Printing is allowed (Table 22, bit 3). + pub fn allows_printing(&self) -> bool { + self.permissions().is_none_or(|p| p.print()) + } + + /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying + /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the + /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. + pub fn allows_copying(&self) -> bool { + self.permissions().is_none_or(|p| p.copy()) + } + + /// [`Self::allows_copying`], as the error to report when it isn't. + pub fn check_copying(&self) -> Result<(), EditError> { + if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } + } + + /// Page changes (insert, delete, rotate, move, extract) are allowed. + pub fn allows_assembly(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.assemble()) + } + + /// Redaction marks waiting to be applied. + pub fn redaction_marks(&self) -> usize { + self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() + } + + /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. + pub fn marks(&self) -> &[MarkKind] { + &self + .marker_scan + .get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_marker_scan(); + pdfcraft_edit::scan_markers(&e.cos) + }) + .unwrap_or_default() + }) + .marks + } + + /// Text and images added with Edit a PDF ▸ Add content (still editable). + pub fn added(&self) -> &[pdfcraft_edit::Added] { + &self + .marker_scan + .get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_marker_scan(); + pdfcraft_edit::scan_markers(&e.cos) + }) + .unwrap_or_default() + }) + .added + } + + /// Link annotations, for Edit a PDF ▸ Link. + pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] { + self.links.get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_link_scan(); + pdfcraft_annot::links::list(&e.cos) + }) + .unwrap_or_default() + }) + } + + /// Changes to content and document information are allowed. + pub fn allows_modification(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.modify()) + } + + /// How widget `widget` of text field `name` lays its value out, for asking whether a typed + /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no + /// more than fits. `None` without such a field or widget, or when the document can't be + /// edited. + pub fn field_text_box(&self, name: &str, widget: usize) -> Option { + let cos = &self.editor.as_ref()?.cos; + let f = self.form.iter().find(|f| f.name == name)?; + let w = f.widgets.get(widget)?; + Some(form_appearance::text_box(cos, f, w)) + } + + /// Filling in form fields is allowed (Table 22, bits 6 and 9). + pub fn allows_form_filling(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) + } + + /// Adding and changing comments is allowed (Table 22, bit 6). + pub fn allows_annotation(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.annotate()) + } + + /// The document's security may be changed (Protect, Remove security): it is editable and, + /// if encrypted, was opened with the owner password. + pub fn allows_security_change(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| unrestricted(&p)) + } + + /// A password restricts the document and it wasn't opened with the owner password: entering + /// that password ([`Session::unlock`]) lifts the restrictions. + pub fn can_unlock(&self) -> bool { + self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) + } + + /// A summary of the document's security for Document Properties ▸ Security, including + /// protection applied in this session (written by the next save). + pub fn security_summary(&self) -> Option { + let editor = self.editor.as_ref()?; + let h = editor.cos.output_handler()?; + let d = h.dict(); + let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m); + let method = match (d.v, stream) { + (1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit", + (1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit", + (_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit", + (_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit", + _ => "Attachments only", + }; + let pending = editor.cos.encryption_changed(); + let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; + Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) + } + + /// Comment properties of the comment at `(page, index)` (Comment Properties dialog). + pub fn comment_props(&self, page: usize, index: usize) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index)) + } + + /// A form field's Appearance-tab look (borders, colours, font). + pub fn field_look(&self, name: &str) -> Option { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + Some(pdfcraft_forms::look(&e.cos, f)) + } + + /// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields. + pub fn field_check_style(&self, name: &str) -> Option { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) + } + + /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): + /// the page (0-based) of the first widget that cuts it, and how. + pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) + } + + /// Remove Hidden Information: what each category would remove. + pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { + self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() + } + + /// Verification: faux redactions (opaque covers over extractable text) and redaction + /// marks that were never applied. Fails closed: any read error is returned, never an + /// empty list. + pub fn audit_redactions(&self) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string()) + } + + /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. + pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { + self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() + } + + /// Current value of a document-information entry (Title, Author, …). + pub fn info_value(&self, key: &str) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) + } + + /// Whether the document information has an entry `key`, of any type, listed or not. + pub fn has_info(&self, key: &str) -> bool { + self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_info(&e.cos, key)) + } + + /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. + pub fn custom_info(&self) -> Vec<(String, String)> { + self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() + } + + /// The name shown on the tab and window: the document title when the document asks for it + /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. + pub fn display_name(&self) -> String { + self.editor + .as_ref() + .filter(|e| pdfcraft_organize::displays_doc_title(&e.cos)) + .and_then(|e| pdfcraft_organize::info(&e.cos, "Title")) + .map(|t| t.trim().to_owned()) + .filter(|t| !t.is_empty() && !is_placeholder_title(t)) + .unwrap_or_else(|| self.name.clone()) + } + + /// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that + /// revision). Unsaved edits aren't a revision yet. + pub fn revision_ends(&self) -> Vec { + self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default() + } + + /// The file as last opened or saved, without unsaved edits: what an incremental save appends + /// to. This stays that file when undo has restored an older snapshot. + pub fn saved_bytes(&self) -> Arc> { + self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) + } + + /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). + pub fn repair_log(&self) -> Vec { + self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default() + } +} + +/// A title that names no document: what a web browser writes when it saves a blank page or a +/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a +/// title says less than the file name, so the file name is shown instead. An address is one +/// word: "About: our company" is a real title. +pub(crate) fn is_placeholder_title(title: &str) -> bool { + let t = title.trim().to_lowercase(); + let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p)); + address || t == "untitled" +} + +/// The working file plus appearances for comments that have none, and for form fields the file +/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the +/// viewer and never enter these bytes, which image/text exports and Print as Image also consume. +pub(crate) fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { + let Some(e) = editor else { return bytes.clone() }; + let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances); + let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances); + let Some(display) = fields.or(comments) else { + return bytes.clone(); + }; + match write_incremental(&display, &SaveOptions::default()) { + Ok(written) => Arc::new(written), + Err(err) => { + e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})")); + bytes.clone() + } + } +} + +/// `comments` (the display copy with comment appearances, if it needed any; else the document +/// itself) with its form fields drawn from their values where the file leaves that to the viewer. +fn with_field_appearances(comments: Option, doc: &pdfcraft_cos::Document) -> Option { + pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments) +} + +/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation. +pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool { + a.len() == b.len() + && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) +} + +/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read +/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. +pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { + let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); + [ + (&mut info.title, read("Title")), + (&mut info.author, read("Author")), + (&mut info.subject, read("Subject")), + (&mut info.keywords, read("Keywords")), + (&mut info.creator, read("Creator")), + (&mut info.producer, read("Producer")), + ] +} + +/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for +/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page +/// images agree with the app and independent viewers. It never changes the source document, and an +/// unparseable file, a write failure or a parser panic falls back to the original bytes. +pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc> { + guard(|| { + pdfcraft_cos::Document::open_with_password(bytes.clone(), password) + .ok() + .and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos)) + .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) + .map(Arc::new) + }) + .ok() + .flatten() + .unwrap_or(bytes) +} + +/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with +/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their +/// owner password this is the user password that owner authentication recovers; otherwise it is +/// `password` itself. Render paths outside a [`Session`], such as the CLI, use this so the owner +/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps +/// it, so the renderer reports the same error it always did. +pub fn render_password_for(bytes: Arc>, password: Option<&str>) -> Option { + let password = password?; + let recovered = + guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten(); + Some(recovered.unwrap_or_else(|| password.to_owned())) +} + +/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it. +pub(crate) fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { + cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user)) +} + +/// Fails when the renderer still draws text under an applied redaction mark. +/// +/// Redaction decides what lies under a mark from the positions its own interpreter computes, and +/// its verify pass reuses them. Where they differ from the renderer's (a standard font without +/// /Widths, whose advances the interpreter approximates; a CMap +/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the +/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is +/// what the user saw when placing the marks, so it is asked. `after` is written as a save would +/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its +/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps +/// the document as it was. +pub(crate) fn shown_under_redactions( + after: &pdfcraft_cos::Document, + marks: &[pdfcraft_redact::Mark], + overlays: &[pdfcraft_cos::ObjRef], + pages: &[pdfcraft_render::PageInfo], + password: Option<&str>, +) -> Result<(), EditError> { + use pdfcraft_cos::Object; + use pdfcraft_redact::RedactError; + let mut marked: Vec = marks.iter().map(|m| m.page).collect(); + marked.sort_unstable(); + marked.dedup(); + if marked.is_empty() { + return Ok(()); + } + let mut shown = after.clone(); + let model = pdfcraft_model::pages(&shown); + for &pi in &marked { + let Some(page) = model.get(pi) else { continue }; + let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; + let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); + shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; + } + let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; + let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); + let mut residue = 0; + for &pi in &marked { + let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; + // A page that cannot be read back cannot be confirmed clean. + let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; + let rects: Vec<[f64; 4]> = marks + .iter() + .filter(|m| m.page == pi) + .flat_map(|m| m.rects.iter()) + .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) + .collect(); + for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { + let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); + let area = (x1 - x0) * (y1 - y0); + if area.is_nan() || area <= 0.0 { + continue; + } + // Redaction itself takes any glyph a fifth under a mark; one that is half under it + // and still drawn was not where redaction thought it was. + let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area; + if rects.iter().any(under) { + residue += 1; + } + } + } + if residue > 0 { + return Err(RedactError::Residue(residue).into()); + } + Ok(()) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 91d22accd7..15f6629e07 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod commands; pub mod compare; mod copy_image; pub mod dates; +mod document; mod edit; mod error; pub mod export; @@ -135,7 +136,7 @@ pub fn merge_data_files(files: &[(String, Vec)]) -> Result { /// One file produced by a split: (1-based first page, last page, PDF bytes). pub type SplitPart = (usize, usize, Arc>); -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; #[cfg(test)] thread_local! { @@ -185,14 +186,14 @@ fn record_link_scan() { }); } +pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; +use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; use edit::EditCtx; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; -use history::Editor; pub use history::Label; pub use open::{OpenContext, PreparedDocument}; -use pdfcraft_cos::{SaveOptions, write_incremental}; -use pdfcraft_render::{DocInfo, Layer, LayerOp, RenderConfig, RenderPool}; +use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; @@ -205,705 +206,6 @@ pub struct DocId(pub u64); /// clear) is printed at. const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0; -pub struct Document { - pub id: DocId, - pub name: String, - pub path: Option, - /// The working file: what Save writes. - pub bytes: Arc>, - /// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances - /// drawn for display only for comments that have none ([`display_bytes`]). Never saved. - pub display: Arc>, - pub info: DocInfo, - pub renderer: RenderPool, - /// The password the document was opened with (needed to read attachments, etc.). - pub password: Option, - /// `true` when there are edits that have not been saved. - pub dirty: bool, - /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. - generation: u64, - /// The generation last handed out by `autosave_snapshots`. - snapshot_generation: u64, - /// Why the document cannot be edited (e.g. encryption), if so. - pub read_only_reason: Option, - /// Interactive form fields of the current state (empty without a form). - pub form: Arc>, - marker_scan: OnceLock, - links: OnceLock>, - /// Signature fields, validated against the session's trust store (Signatures panel). - pub signatures: Arc>, - trust: Arc, - sig_cache: Arc, - editor: Option, - config: RenderConfig, - /// What field scripts printed or asked for (see [`Session::take_js_output`]). - js_output: js::JsOutput, - /// Dynamic XFA forms: what laying the template out produced (pages and fields are - /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). - pub xfa: Option, - /// The parsed template of a laid-out XFA form, for its scripts. - xfa_template: Option>, - /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). - xfa_scripts_off: bool, - /// A content-only refresh keeps `info` from before the edit; an edit may have added or - /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). - fonts_stale: bool, - /// XFA forms: what was approximated, rewritten or could not be written to the XFA data - /// (also in `info.warnings`, kept there when the document is re-read). - pub xfa_warnings: Vec, - /// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed, - /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. - pub edit_notices: Vec, -} - -/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn -/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat -/// keeps such text too, drawing it with fonts PdfCraft can't embed yet. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TextNotice { - /// The form field's full name, or `None` for a comment. - pub field: Option, - /// The page (0-based) of the comment or of the field's widget. - pub page: usize, - pub ch: char, - /// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances` - /// set); otherwise the `?` shows in every viewer. - pub redrawn_elsewhere: bool, -} - -impl std::fmt::Display for TextNotice { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch)); - let page = self.page + 1; - match (&self.field, self.redrawn_elsewhere) { - (Some(name), true) => write!( - f, - "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value" - ), - (Some(name), false) => write!( - f, - "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere" - ), - (None, _) => write!( - f, - "a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer" - ), - } - } -} - -/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying -/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order. -fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { - let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() }; - let redrawn_elsewhere = pdfcraft_forms::need_appearances(next); - let mut names: Option> = None; - let mut out: Vec = Vec::new(); - for (page, p) in pages.iter().enumerate() { - let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a)); - for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) { - let now = next.get(r); - let Some(d) = now.as_dict() else { continue }; - let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue }; - // Only what this edit drew: a new annotation, or a new appearance. - let was = prev.get(r); - if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) { - continue; - } - let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| { - let names = names.get_or_insert_with(|| { - pdfcraft_forms::fields(next) - .into_iter() - .flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()) - .collect() - }); - names.get(&r).cloned().unwrap_or_default() - }); - if field.is_some() && out.iter().any(|n| n.field == field) { - continue; - } - out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch }); - } - } - out -} - -impl Document { - /// How the document opens (Document Properties ▸ Initial View). - /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be - /// read for editing). - pub fn accessibility_check(&self, options: &a11y::Options) -> Option { - self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) - } - - /// A script of this XFA form ran past its time limit and was abandoned, so the form's - /// scripts no longer run in this document (reopening it runs them again). The script's - /// error is in the JavaScript output; this is the state, for a viewer to show. - pub fn xfa_scripts_off(&self) -> bool { - self.xfa_scripts_off - } - - /// PDF Optimizer ▸ Audit space usage. - pub fn audit_space(&self) -> Vec { - self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() - } - - /// Edit a PDF: the images `page` draws (0-based). - pub fn page_images(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() - } - - /// Mixed editable objects on one page. Source inventories are zero-based and added items - /// appear once. Rectangles are user-space points, independent of the viewer's zoom. - pub fn editable_objects(&self, page: usize) -> Result, String> { - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string()) - } - - /// Displayed top-left-origin motion to a user-space vector. Use the linear map only, - /// so even a large crop-box origin cannot erase a small drag by cancellation. - pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> { - let p = self.info.pages.get(page).ok_or("the page no longer exists")?; - let [x0, y0, x1, y1] = p.crop.map(f64::from); - let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3))); - let (u, v) = match p.rotation { - 90 => (b, -a), - 180 => (-a, -b), - 270 => (-b, a), - _ => (a, b), - }; - let offset = [u * (x1 - x0), -v * (y1 - y0)]; - if !offset.iter().all(|v| v.is_finite()) { - return Err("the move must be finite and non-zero".into()); - } - Ok(offset) - } - - /// Save image as: image `index` on `page` as a file (extension, bytes). - pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { - self.check_copying().map_err(|e| e.to_string())?; - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; - if img.is_form { - return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into()); - } - pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) - } - - /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). - pub fn text_blocks(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() - } - - /// Saved measurement annotations, calculated from their geometry and PDF scales, plus - /// the ones that couldn't be read (unsupported formats are skipped, not fatal). - pub fn measurements(&self) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - Ok(measure::list(&e.cos)) - } - pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string()) - } - /// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a - /// point) where the page sets no scale. - pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string()) - } - pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string()) - } - pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string()) - } - pub fn measurement_paths(&self, page: usize) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string()) - } - - /// A counter that changes with every edit (for caches of derived data). - pub fn edit_generation(&self) -> u64 { - self.generation - } - - /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). - pub fn text_lines(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() - } - - /// The form's calculation order: its calculated fields in the order they calculate, what each - /// formula reads, what is wrong with the order, and the order Fix order writes. - pub fn calculation_order(&self) -> CalculationOrder { - self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() - } - - /// Add alternate text: the figures, in document order. - pub fn figures(&self) -> Vec { - self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() - } - - /// Reachable indirect structure tags, in document order. - pub fn tags(&self) -> Vec { - self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() - } - - /// Marked-content items directly owned by one reachable indirect structure tag. - pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { - self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) - } - - /// The edit that fixes `rule`, for the rules with an automatic fix: the document language - /// (`value` is the language), the title (`value` replaces it; else the current title or the - /// file name) and the tab order. - pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result { - let value = value.map(str::trim).filter(|v| !v.is_empty()); - match rule { - a11y::Rule::PrimaryLanguage => { - let mut v = self.initial_view(); - v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned()); - Ok(Edit::SetInitialView(Box::new(v))) - } - a11y::Rule::Title => { - let title = value - .map(str::to_owned) - .or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty())) - .unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned()); - let mut v = self.initial_view(); - v.display_title = true; - Ok(Edit::Batch { - label: "Set document title".into(), - edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))], - }) - } - a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }), - other => Err(format!("\"{}\" has no automatic fix", other.name())), - } - } - - pub fn initial_view(&self) -> InitialView { - self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default() - } - - /// Signed: at least one signature field holds a signature. - pub fn is_signed(&self) -> bool { - self.signatures.iter().any(|s| s.signed) - } - - /// Whether comments are hidden (Comments ▸ Hide all comments). - pub fn comments_hidden(&self) -> bool { - self.config.hide_comments - } - - pub fn can_undo(&self) -> Option<&str> { - self.undo_label().map(Label::as_str) - } - - pub fn can_redo(&self) -> Option<&str> { - self.redo_label().map(Label::as_str) - } - - /// What Undo would undo, in parts, for an interface that says it in another language. - pub fn undo_label(&self) -> Option<&Label> { - self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) - } - - /// What Redo would redo, in parts. - pub fn redo_label(&self) -> Option<&Label> { - self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l) - } - - pub fn editable(&self) -> bool { - self.editor.is_some() - } - - /// What the opening password allows; `None` when the document is not encrypted. - pub fn permissions(&self) -> Option { - self.editor.as_ref().and_then(|e| e.cos.permissions()) - } - - /// Printing is allowed (Table 22, bit 3). - pub fn allows_printing(&self) -> bool { - self.permissions().is_none_or(|p| p.print()) - } - - /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying - /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the - /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. - pub fn allows_copying(&self) -> bool { - self.permissions().is_none_or(|p| p.copy()) - } - - /// [`Self::allows_copying`], as the error to report when it isn't. - pub fn check_copying(&self) -> Result<(), EditError> { - if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } - } - - /// Page changes (insert, delete, rotate, move, extract) are allowed. - pub fn allows_assembly(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.assemble()) - } - - /// Redaction marks waiting to be applied. - pub fn redaction_marks(&self) -> usize { - self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() - } - - /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. - pub fn marks(&self) -> &[MarkKind] { - &self - .marker_scan - .get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_marker_scan(); - pdfcraft_edit::scan_markers(&e.cos) - }) - .unwrap_or_default() - }) - .marks - } - - /// Text and images added with Edit a PDF ▸ Add content (still editable). - pub fn added(&self) -> &[pdfcraft_edit::Added] { - &self - .marker_scan - .get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_marker_scan(); - pdfcraft_edit::scan_markers(&e.cos) - }) - .unwrap_or_default() - }) - .added - } - - /// Link annotations, for Edit a PDF ▸ Link. - pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] { - self.links.get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_link_scan(); - pdfcraft_annot::links::list(&e.cos) - }) - .unwrap_or_default() - }) - } - - /// Changes to content and document information are allowed. - pub fn allows_modification(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.modify()) - } - - /// How widget `widget` of text field `name` lays its value out, for asking whether a typed - /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no - /// more than fits. `None` without such a field or widget, or when the document can't be - /// edited. - pub fn field_text_box(&self, name: &str, widget: usize) -> Option { - let cos = &self.editor.as_ref()?.cos; - let f = self.form.iter().find(|f| f.name == name)?; - let w = f.widgets.get(widget)?; - Some(form_appearance::text_box(cos, f, w)) - } - - /// Filling in form fields is allowed (Table 22, bits 6 and 9). - pub fn allows_form_filling(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) - } - - /// Adding and changing comments is allowed (Table 22, bit 6). - pub fn allows_annotation(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.annotate()) - } - - /// The document's security may be changed (Protect, Remove security): it is editable and, - /// if encrypted, was opened with the owner password. - pub fn allows_security_change(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| unrestricted(&p)) - } - - /// A password restricts the document and it wasn't opened with the owner password: entering - /// that password ([`Session::unlock`]) lifts the restrictions. - pub fn can_unlock(&self) -> bool { - self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) - } - - /// A summary of the document's security for Document Properties ▸ Security, including - /// protection applied in this session (written by the next save). - pub fn security_summary(&self) -> Option { - let editor = self.editor.as_ref()?; - let h = editor.cos.output_handler()?; - let d = h.dict(); - let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m); - let method = match (d.v, stream) { - (1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit", - (1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit", - (_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit", - (_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit", - _ => "Attachments only", - }; - let pending = editor.cos.encryption_changed(); - let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; - Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) - } - - /// Comment properties of the comment at `(page, index)` (Comment Properties dialog). - pub fn comment_props(&self, page: usize, index: usize) -> Option { - self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index)) - } - - /// A form field's Appearance-tab look (borders, colours, font). - pub fn field_look(&self, name: &str) -> Option { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - Some(pdfcraft_forms::look(&e.cos, f)) - } - - /// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields. - pub fn field_check_style(&self, name: &str) -> Option { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) - } - - /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): - /// the page (0-based) of the first widget that cuts it, and how. - pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) - } - - /// Remove Hidden Information: what each category would remove. - pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { - self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() - } - - /// Verification: faux redactions (opaque covers over extractable text) and redaction - /// marks that were never applied. Fails closed: any read error is returned, never an - /// empty list. - pub fn audit_redactions(&self) -> Result, String> { - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string()) - } - - /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. - pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { - self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() - } - - /// Current value of a document-information entry (Title, Author, …). - pub fn info_value(&self, key: &str) -> Option { - self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) - } - - /// Whether the document information has an entry `key`, of any type, listed or not. - pub fn has_info(&self, key: &str) -> bool { - self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_info(&e.cos, key)) - } - - /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. - pub fn custom_info(&self) -> Vec<(String, String)> { - self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() - } - - /// The name shown on the tab and window: the document title when the document asks for it - /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. - pub fn display_name(&self) -> String { - self.editor - .as_ref() - .filter(|e| pdfcraft_organize::displays_doc_title(&e.cos)) - .and_then(|e| pdfcraft_organize::info(&e.cos, "Title")) - .map(|t| t.trim().to_owned()) - .filter(|t| !t.is_empty() && !is_placeholder_title(t)) - .unwrap_or_else(|| self.name.clone()) - } - - /// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that - /// revision). Unsaved edits aren't a revision yet. - pub fn revision_ends(&self) -> Vec { - self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default() - } - - /// The file as last opened or saved, without unsaved edits: what an incremental save appends - /// to. This stays that file when undo has restored an older snapshot. - pub fn saved_bytes(&self) -> Arc> { - self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) - } - - /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). - pub fn repair_log(&self) -> Vec { - self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default() - } -} - -/// A title that names no document: what a web browser writes when it saves a blank page or a -/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a -/// title says less than the file name, so the file name is shown instead. An address is one -/// word: "About: our company" is a real title. -fn is_placeholder_title(title: &str) -> bool { - let t = title.trim().to_lowercase(); - let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p)); - address || t == "untitled" -} - -/// The working file plus appearances for comments that have none, and for form fields the file -/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the -/// viewer and never enter these bytes, which image/text exports and Print as Image also consume. -fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { - let Some(e) = editor else { return bytes.clone() }; - let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances); - let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances); - let Some(display) = fields.or(comments) else { - return bytes.clone(); - }; - match write_incremental(&display, &SaveOptions::default()) { - Ok(written) => Arc::new(written), - Err(err) => { - e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})")); - bytes.clone() - } - } -} - -/// `comments` (the display copy with comment appearances, if it needed any; else the document -/// itself) with its form fields drawn from their values where the file leaves that to the viewer. -fn with_field_appearances(comments: Option, doc: &pdfcraft_cos::Document) -> Option { - pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments) -} - -/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation. -fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool { - a.len() == b.len() - && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) -} - -/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read -/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. -fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { - let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); - [ - (&mut info.title, read("Title")), - (&mut info.author, read("Author")), - (&mut info.subject, read("Subject")), - (&mut info.keywords, read("Keywords")), - (&mut info.creator, read("Creator")), - (&mut info.producer, read("Producer")), - ] -} - -/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for -/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page -/// images agree with the app and independent viewers. It never changes the source document, and an -/// unparseable file, a write failure or a parser panic falls back to the original bytes. -pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc> { - guard(|| { - pdfcraft_cos::Document::open_with_password(bytes.clone(), password) - .ok() - .and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos)) - .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) - .map(Arc::new) - }) - .ok() - .flatten() - .unwrap_or(bytes) -} - -/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with -/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their -/// owner password this is the user password that owner authentication recovers; otherwise it is -/// `password` itself. Render paths outside a [`Session`], such as the CLI, use this so the owner -/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps -/// it, so the renderer reports the same error it always did. -pub fn render_password_for(bytes: Arc>, password: Option<&str>) -> Option { - let password = password?; - let recovered = - guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten(); - Some(recovered.unwrap_or_else(|| password.to_owned())) -} - -/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it. -fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { - cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user)) -} - -/// Fails when the renderer still draws text under an applied redaction mark. -/// -/// Redaction decides what lies under a mark from the positions its own interpreter computes, and -/// its verify pass reuses them. Where they differ from the renderer's (a standard font without -/// /Widths, whose advances the interpreter approximates; a CMap -/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the -/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is -/// what the user saw when placing the marks, so it is asked. `after` is written as a save would -/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its -/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps -/// the document as it was. -fn shown_under_redactions( - after: &pdfcraft_cos::Document, - marks: &[pdfcraft_redact::Mark], - overlays: &[pdfcraft_cos::ObjRef], - pages: &[pdfcraft_render::PageInfo], - password: Option<&str>, -) -> Result<(), EditError> { - use pdfcraft_cos::Object; - use pdfcraft_redact::RedactError; - let mut marked: Vec = marks.iter().map(|m| m.page).collect(); - marked.sort_unstable(); - marked.dedup(); - if marked.is_empty() { - return Ok(()); - } - let mut shown = after.clone(); - let model = pdfcraft_model::pages(&shown); - for &pi in &marked { - let Some(page) = model.get(pi) else { continue }; - let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; - let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); - shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; - } - let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; - let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; - let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); - let mut residue = 0; - for &pi in &marked { - let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; - // A page that cannot be read back cannot be confirmed clean. - let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; - let rects: Vec<[f64; 4]> = marks - .iter() - .filter(|m| m.page == pi) - .flat_map(|m| m.rects.iter()) - .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) - .collect(); - for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { - let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); - let area = (x1 - x0) * (y1 - y0); - if area.is_nan() || area <= 0.0 { - continue; - } - // Redaction itself takes any glyph a fifth under a mark; one that is half under it - // and still drawn was not where redaction thought it was. - let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area; - if rects.iter().any(under) { - residue += 1; - } - } - } - if residue > 0 { - return Err(RedactError::Residue(residue).into()); - } - Ok(()) -} - fn render_threads() -> usize { std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(4).clamp(2, 8) - 1 } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d06faefc8b..a30d81b74b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use super::document::is_placeholder_title; use super::edit::check_permission; use super::*; use pdfcraft_render::{OpenError, ReadingColors}; From 748b20abb91ee2ad8a5e9cdc23fdbc6aceb672ff Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:05:58 +0100 Subject: [PATCH 012/246] refactor(engine): move the XFA form helpers to xfa_form.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 299 +--------------------------------- crates/engine/src/xfa_form.rs | 297 +++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 294 deletions(-) create mode 100644 crates/engine/src/xfa_form.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 15f6629e07..56677ed5d3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -48,6 +48,7 @@ pub mod text_fonts; mod text_preview; pub mod word_count; pub mod xfa; +mod xfa_form; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; @@ -188,7 +189,6 @@ fn record_link_scan() { pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; -use edit::EditCtx; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; @@ -197,6 +197,10 @@ use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; +use xfa_form::{ + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, + undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, +}; /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -465,299 +469,6 @@ pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option pdfcraft_organize::page_count(&doc).ok() } -/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. -fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { - let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; - xfa_redraw(doc)?; - // The form's own values: one its field's font can't draw is drawn with `?` and reported. - report.warnings.extend(undrawn_field_warnings(doc)); - Ok(report) -} - -/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the -/// picture its signer's application drew. -pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { - for f in pdfcraft_forms::fields(doc) { - if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { - continue; - } - pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; - } - Ok(()) -} - -/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font -/// can't draw (laying out an XFA form's own values). -fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { - pdfcraft_forms::undrawn_fields(doc) - .into_iter() - .map(|(name, c)| { - format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) - }) - .collect() -} - -/// The form's fields as the XFA data layer wants them. -fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec { - use pdfcraft_forms::FieldKind as K; - pdfcraft_forms::fields(doc) - .into_iter() - .map(|f| { - let data = match f.kind { - K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")), - K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()), - K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()), - K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None, - }; - pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data } - }) - .collect() -} - -/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not -/// be written. -/// `datasets`: the stream this edit already wrote, replaced in place rather than added again -/// (and set to the one written). -/// `tpl`: the form's parsed template when the document has one cached (else it is parsed). -fn xfa_sync_datasets( - doc: &mut pdfcraft_cos::Document, - tpl: Option<&pdfcraft_xfa::model::Template>, - datasets: &mut Option, -) -> Result, String> { - let data = xfa_field_data(doc); - let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?; - if r.stream.is_some() { - *datasets = r.stream; - } - Ok(r.warnings) -} - -/// What a document whose form script ran away is told. -const XFA_SCRIPTS_OFF: &str = - "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; - -/// Most XFA warnings kept per document. -const MAX_XFA_WARNINGS: usize = 50; - -/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]). -fn note_warnings(list: &mut Vec, new: &[String]) { - for w in new { - if !list.contains(w) && list.len() < MAX_XFA_WARNINGS { - list.push(w.clone()); - } - } -} - -/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or -/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an -/// encoding this can't read. -pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { - if bytes.starts_with(b"%FDF") { - return None; - } - let declared = |text: &str| -> Option { - let head = text.get(..text.len().min(256))?; - let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; - let enc = decl.split_once("encoding=")?.1; - let quote = enc.chars().next()?; - Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) - }; - if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { - match declared(&text).as_deref() { - None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), - Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { - // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 - // differs, and is rare in form data). - return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); - } - Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), - } - } - // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. - let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); - match declared(&latin1).as_deref() { - Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), - _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), - } -} - -/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, -/// buttons that did nothing but run scripts, and fields whose value differs from the form's -/// data (data PdfCraft could not read into them, now lost). -pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { - let fields = pdfcraft_forms::fields(doc); - let data = xfa_field_data(doc); - let differ = pdfcraft_xfa::read_values(doc, &data) - .into_iter() - .filter(|(name, value)| { - let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; - match value { - pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), - pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, - pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), - pdfcraft_xfa::FieldData::None => false, - } - }) - .count(); - let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_removed.extend(r.notes()); - if differ > 0 { - cx.xfa_removed.push(format!( - "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" - )); - } - Ok(()) -} - -/// Keep the document's XFA state in step with its file after an edit: a script may have laid -/// the form out again (rows added, subforms shown), an edit may have taken the XFA out -/// (flattened, or made static), or an undo put it back. -fn sync_xfa_state(doc: &mut Document) { - let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; - match pdfcraft_xfa::existing_layout(cos) { - Some(report) => { - if doc.xfa.is_none() { - // Back after an undo: the XFA's own warnings apply again, the strip's don't. - doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); - if doc.xfa_template.is_none() && !doc.xfa_scripts_off { - doc.xfa_template = xfa::template(cos); - } - } - doc.xfa = Some(report); - } - None => { - doc.xfa = None; - doc.xfa_template = None; - // Only what the strip said stays (the XFA's own warnings no longer apply). - doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); - } - } -} - -/// How the warnings of a strip start, so they can be told from the XFA's own. -const XFA_REMOVED_PREFIX: &str = "XFA removed: "; - -/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), -/// rather than XFDF, Acrobat's `` XML or a table? By its root element. -pub(crate) fn is_xfa_data_file(text: &str) -> bool { - let text = text.get(..text.len().min(65_536)).unwrap_or(text); - let mut rest = text.trim_start_matches('\u{feff}').trim_start(); - // Past the XML declaration, comments and processing instructions. - let mut guard = 0; - while guard < 100 { - guard += 1; - if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); - } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); - } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); - } else { - break; - } - } - let Some(tag) = rest.strip_prefix('<') else { return false }; - let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); - let local = name.rsplit(':').next().unwrap_or(name); - !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") -} - -/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. -pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { - match pdfcraft_xfa::image::kind(bytes)? { - pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), - pdfcraft_xfa::image::Kind::Png => Some("image/png"), - pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), - pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"), - pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"), - } -} - -/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is -/// laid out again from the data (then, when `calculate`, its calculations run), and the -/// fields take the data's values. -pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { - if let Some(tpl) = cx.xfa.clone() { - 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 }; - let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) - .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = r { - cx.xfa_out.errors.push(e); - } - } - let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; - if !taken.is_empty() { - cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); - } - // Not laid out again (a static form, or scripts off): fields the data no longer holds are - // emptied, so the data replaced is the data shown. - if cx.xfa.is_none() && calculate { - let data = xfa_field_data(doc); - let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); - for f in pdfcraft_forms::fields(doc) { - if present.contains(&f.name) || f.value.is_empty() { - continue; - } - use pdfcraft_forms::FieldKind as K; - let empty = match f.kind { - K::Text => FieldValue::Text(String::new()), - K::CheckBox => FieldValue::Check(false), - K::Radio => FieldValue::Radio(None), - K::Combo | K::List => FieldValue::Choice(Vec::new()), - K::PushButton | K::Signature => continue, - }; - if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { - cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); - } - } - } - Ok(()) -} - -/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns -/// the names of the fields that changed. -fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { - let data = xfa_field_data(doc); - let fields = pdfcraft_forms::fields(doc); - // The first field of each name, as a lookup by name would find it. - let mut by_name = std::collections::HashMap::new(); - for f in &fields { - by_name.entry(f.name.as_str()).or_insert(f); - } - let mut updates = Vec::new(); - for (name, value) in pdfcraft_xfa::read_values(doc, &data) { - let Some(f) = by_name.get(name.as_str()) else { continue }; - let new = match value { - pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { - // Saved values, one per line for a multi-select list; shown text stands for - // its saved value (an older viewer may have written it). - let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone()); - let picked: Vec = if f.has(pdfcraft_forms::flags::MULTI_SELECT) { - t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect() - } else { - vec![saved(t.trim())] - }; - (f.value != picked).then_some(FieldValue::Choice(picked)) - } - pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), - pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), - pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), - pdfcraft_xfa::FieldData::None => None, - }; - if let Some(v) = new { - updates.push((name, v)); - } - } - // All at once: one by one, each value would read the whole form again, which took minutes - // on a form with thousands of fields. - let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) }; - Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect()) -} - /// Validate the signature fields of `cos` (written as `bytes`). fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Vec { pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs new file mode 100644 index 0000000000..5ceaba1cd4 --- /dev/null +++ b/crates/engine/src/xfa_form.rs @@ -0,0 +1,297 @@ +//! A document's XFA form: laying it out, keeping its datasets and fields in step, and taking it out. + +use crate::edit::EditCtx; +use crate::{Document, EditError, FieldValue, XfaLayout, guard, xfa}; + +/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. +pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { + let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; + xfa_redraw(doc)?; + // The form's own values: one its field's font can't draw is drawn with `?` and reported. + report.warnings.extend(undrawn_field_warnings(doc)); + Ok(report) +} + +/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the +/// picture its signer's application drew. +pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { + for f in pdfcraft_forms::fields(doc) { + if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { + continue; + } + pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; + } + Ok(()) +} + +/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font +/// can't draw (laying out an XFA form's own values). +pub(crate) fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { + pdfcraft_forms::undrawn_fields(doc) + .into_iter() + .map(|(name, c)| { + format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) + }) + .collect() +} + +/// The form's fields as the XFA data layer wants them. +fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec { + use pdfcraft_forms::FieldKind as K; + pdfcraft_forms::fields(doc) + .into_iter() + .map(|f| { + let data = match f.kind { + K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")), + K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()), + K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()), + K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None, + }; + pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data } + }) + .collect() +} + +/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not +/// be written. +/// `datasets`: the stream this edit already wrote, replaced in place rather than added again +/// (and set to the one written). +/// `tpl`: the form's parsed template when the document has one cached (else it is parsed). +pub(crate) fn xfa_sync_datasets( + doc: &mut pdfcraft_cos::Document, + tpl: Option<&pdfcraft_xfa::model::Template>, + datasets: &mut Option, +) -> Result, String> { + let data = xfa_field_data(doc); + let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?; + if r.stream.is_some() { + *datasets = r.stream; + } + Ok(r.warnings) +} + +/// What a document whose form script ran away is told. +pub(crate) const XFA_SCRIPTS_OFF: &str = + "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; + +/// Most XFA warnings kept per document. +const MAX_XFA_WARNINGS: usize = 50; + +/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]). +pub(crate) fn note_warnings(list: &mut Vec, new: &[String]) { + for w in new { + if !list.contains(w) && list.len() < MAX_XFA_WARNINGS { + list.push(w.clone()); + } + } +} + +/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or +/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an +/// encoding this can't read. +pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { + if bytes.starts_with(b"%FDF") { + return None; + } + let declared = |text: &str| -> Option { + let head = text.get(..text.len().min(256))?; + let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; + let enc = decl.split_once("encoding=")?.1; + let quote = enc.chars().next()?; + Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) + }; + if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { + match declared(&text).as_deref() { + None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { + // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 + // differs, and is rare in form data). + return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); + } + Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), + } + } + // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. + let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); + match declared(&latin1).as_deref() { + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), + _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), + } +} + +/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, +/// buttons that did nothing but run scripts, and fields whose value differs from the form's +/// data (data PdfCraft could not read into them, now lost). +pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { + let fields = pdfcraft_forms::fields(doc); + let data = xfa_field_data(doc); + let differ = pdfcraft_xfa::read_values(doc, &data) + .into_iter() + .filter(|(name, value)| { + let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; + match value { + pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), + pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, + pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), + pdfcraft_xfa::FieldData::None => false, + } + }) + .count(); + let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_removed.extend(r.notes()); + if differ > 0 { + cx.xfa_removed.push(format!( + "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" + )); + } + Ok(()) +} + +/// Keep the document's XFA state in step with its file after an edit: a script may have laid +/// the form out again (rows added, subforms shown), an edit may have taken the XFA out +/// (flattened, or made static), or an undo put it back. +pub(crate) fn sync_xfa_state(doc: &mut Document) { + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; + match pdfcraft_xfa::existing_layout(cos) { + Some(report) => { + if doc.xfa.is_none() { + // Back after an undo: the XFA's own warnings apply again, the strip's don't. + doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); + if doc.xfa_template.is_none() && !doc.xfa_scripts_off { + doc.xfa_template = xfa::template(cos); + } + } + doc.xfa = Some(report); + } + None => { + doc.xfa = None; + doc.xfa_template = None; + // Only what the strip said stays (the XFA's own warnings no longer apply). + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + } + } +} + +/// How the warnings of a strip start, so they can be told from the XFA's own. +pub(crate) const XFA_REMOVED_PREFIX: &str = "XFA removed: "; + +/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), +/// rather than XFDF, Acrobat's `` XML or a table? By its root element. +pub(crate) fn is_xfa_data_file(text: &str) -> bool { + let text = text.get(..text.len().min(65_536)).unwrap_or(text); + let mut rest = text.trim_start_matches('\u{feff}').trim_start(); + // Past the XML declaration, comments and processing instructions. + let mut guard = 0; + while guard < 100 { + guard += 1; + if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); + } else { + break; + } + } + let Some(tag) = rest.strip_prefix('<') else { return false }; + let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); + let local = name.rsplit(':').next().unwrap_or(name); + !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") +} + +/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. +pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { + match pdfcraft_xfa::image::kind(bytes)? { + pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), + pdfcraft_xfa::image::Kind::Png => Some("image/png"), + pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), + pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"), + pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"), + } +} + +/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is +/// laid out again from the data (then, when `calculate`, its calculations run), and the +/// fields take the data's values. +pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { + if let Some(tpl) = cx.xfa.clone() { + 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 }; + let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) + .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = r { + cx.xfa_out.errors.push(e); + } + } + let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; + if !taken.is_empty() { + cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); + } + // Not laid out again (a static form, or scripts off): fields the data no longer holds are + // emptied, so the data replaced is the data shown. + if cx.xfa.is_none() && calculate { + let data = xfa_field_data(doc); + let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); + for f in pdfcraft_forms::fields(doc) { + if present.contains(&f.name) || f.value.is_empty() { + continue; + } + use pdfcraft_forms::FieldKind as K; + let empty = match f.kind { + K::Text => FieldValue::Text(String::new()), + K::CheckBox => FieldValue::Check(false), + K::Radio => FieldValue::Radio(None), + K::Combo | K::List => FieldValue::Choice(Vec::new()), + K::PushButton | K::Signature => continue, + }; + if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { + cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); + } + } + } + Ok(()) +} + +/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns +/// the names of the fields that changed. +pub(crate) fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { + let data = xfa_field_data(doc); + let fields = pdfcraft_forms::fields(doc); + // The first field of each name, as a lookup by name would find it. + let mut by_name = std::collections::HashMap::new(); + for f in &fields { + by_name.entry(f.name.as_str()).or_insert(f); + } + let mut updates = Vec::new(); + for (name, value) in pdfcraft_xfa::read_values(doc, &data) { + let Some(f) = by_name.get(name.as_str()) else { continue }; + let new = match value { + pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { + // Saved values, one per line for a multi-select list; shown text stands for + // its saved value (an older viewer may have written it). + let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone()); + let picked: Vec = if f.has(pdfcraft_forms::flags::MULTI_SELECT) { + t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect() + } else { + vec![saved(t.trim())] + }; + (f.value != picked).then_some(FieldValue::Choice(picked)) + } + pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), + pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), + pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), + pdfcraft_xfa::FieldData::None => None, + }; + if let Some(v) = new { + updates.push((name, v)); + } + } + // All at once: one by one, each value would read the whole form again, which took minutes + // on a form with thousands of fields. + let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) }; + Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect()) +} From 71dccdda505bf891c147d25ca985adcaadea0aa5 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:07:23 +0100 Subject: [PATCH 013/246] refactor(engine): move the comment list and summary to comments.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/comments.rs | 163 +++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 165 +--------------------------------- 2 files changed, 166 insertions(+), 162 deletions(-) create mode 100644 crates/engine/src/comments.rs diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs new file mode 100644 index 0000000000..eee799ed2f --- /dev/null +++ b/crates/engine/src/comments.rs @@ -0,0 +1,163 @@ +//! The comment list and its summary. + +/// The comment list, read from the object graph (as `inspect` lists comments). +pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { + let mut list: Vec = pdfcraft_annot::summaries(doc) + .into_iter() + .map(|s| pdfcraft_render::Annotation { + page: s.page, + subtype: s.subtype, + author: s.author, + contents: s.contents, + modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)), + name: s.name, + in_reply_to: s.in_reply_to, + parent: s.parent, + grouped: s.grouped, + rect: s.rect, + color: s.color, + index: s.index, + state: s.state, + quads: s.quads, + locked: s.locked, + intent: s.intent, + fill_sign: s.fill_sign, + no_rotate: s.no_rotate, + points: s.points, + }) + .collect(); + pdfcraft_render::link_replies(&mut list); + list +} + +/// Order of Summarize Comments. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SummarySort { + #[default] + Page, + Author, + Date, + Type, +} + +impl SummarySort { + pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type]; + pub fn name(self) -> &'static str { + match self { + SummarySort::Page => "Page", + SummarySort::Author => "Author", + SummarySort::Date => "Date", + SummarySort::Type => "Type", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s)) + } +} + +/// Acrobat's name for a comment type, as comment lists show it. +pub fn comment_type_name(subtype: &str) -> &str { + match subtype { + "Text" => "Sticky Note", + "FreeText" => "Text Box", + "Highlight" => "Highlight", + "Underline" => "Underline", + "StrikeOut" => "Strikethrough", + "Squiggly" => "Squiggly", + "Square" => "Rectangle", + "Circle" => "Oval", + "Line" => "Line", + "Ink" => "Pencil", + "Polygon" => "Polygon", + "PolyLine" => "Polygonal Line", + "Stamp" => "Stamp", + "Caret" => "Inserted Text", + "FileAttachment" => "File Attachment", + "Sound" => "Sound", + "Redact" => "Redaction", + other => other, + } +} + +/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. +pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { + match a.intent.as_deref() { + Some("FreeTextCallout") => "Callout", + Some("PolygonCloud") => "Cloud", + Some("FreeTextTypeWriter") => "Typewriter", + _ => comment_type_name(&a.subtype), + } +} + +/// The text of a comment summary: one block per comment (replies indented under it), with a +/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are +/// pop-ups; the number is the comment's position on its page. +pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { + use std::fmt::Write; + let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect(); + // Each reply under the annotation its `/IRT` points at, in document order, gathered once. + type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>; + let mut children = Children::new(); + for r in all.iter().filter(|r| r.state.is_none()) { + if let Some(parent) = r.parent { + children.entry(parent).or_default().push(r); + } + } + // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the + // direct parent, so following one level would leave the deeper replies out of the summary. + fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) { + use std::fmt::Write; + if depth > 64 { + return; + } + for r in children.get(&(parent.page, parent.index)).into_iter().flatten() { + let indent = " ".repeat(depth + 1); + let _ = + writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); + for line in r.contents.as_deref().unwrap_or("").lines() { + let _ = writeln!(out, "{indent}{line}"); + } + write_replies(out, children, r, depth + 1); + } + } + let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); + let mut last = usize::MAX; + let mut n = 0; + for a in &top { + if a.page != last { + last = a.page; + n = 0; + } + n += 1; + numbered.push((n, a)); + } + match sort { + SummarySort::Page => {} + SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)), + SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)), + SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))), + } + let mut out = format!("Summary of Comments on {name}\n\n"); + if numbered.is_empty() { + out.push_str("This document has no comments.\n"); + return out; + } + let mut heading = usize::MAX; + for (n, a) in numbered { + if sort == SummarySort::Page && a.page != heading { + heading = a.page; + let _ = writeln!(out, "Page: {}", a.page + 1); + } + let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a)); + if sort != SummarySort::Page { + let _ = write!(out, " Page: {}", a.page + 1); + } + let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or("")); + if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { + let _ = writeln!(out, "{c}"); + } + write_replies(&mut out, &children, a, 0); + out.push('\n'); + } + out +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 56677ed5d3..a809b91021 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,6 +20,7 @@ pub mod camera_clean; pub mod camera_edges; pub mod catalog; pub mod commands; +mod comments; pub mod compare; mod copy_image; pub mod dates; @@ -187,6 +188,8 @@ fn record_link_scan() { }); } +use comments::comment_list; +pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name}; pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; @@ -349,36 +352,6 @@ pub struct RecoverySnapshot { pub encrypted: bool, } -/// The comment list, read from the object graph (as `inspect` lists comments). -fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { - let mut list: Vec = pdfcraft_annot::summaries(doc) - .into_iter() - .map(|s| pdfcraft_render::Annotation { - page: s.page, - subtype: s.subtype, - author: s.author, - contents: s.contents, - modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)), - name: s.name, - in_reply_to: s.in_reply_to, - parent: s.parent, - grouped: s.grouped, - rect: s.rect, - color: s.color, - index: s.index, - state: s.state, - quads: s.quads, - locked: s.locked, - intent: s.intent, - fill_sign: s.fill_sign, - no_rotate: s.no_rotate, - points: s.points, - }) - .collect(); - pdfcraft_render::link_replies(&mut list); - list -} - /// Seconds to add to UTC for local time (0 where unknown). fn local_utc_offset() -> i64 { #[cfg(not(target_arch = "wasm32"))] @@ -538,138 +511,6 @@ pub struct OptimizeReport { pub discarded: Vec<(Hidden, usize)>, } -/// Order of Summarize Comments. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum SummarySort { - #[default] - Page, - Author, - Date, - Type, -} - -impl SummarySort { - pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type]; - pub fn name(self) -> &'static str { - match self { - SummarySort::Page => "Page", - SummarySort::Author => "Author", - SummarySort::Date => "Date", - SummarySort::Type => "Type", - } - } - pub fn parse(s: &str) -> Option { - Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s)) - } -} - -/// Acrobat's name for a comment type, as comment lists show it. -pub fn comment_type_name(subtype: &str) -> &str { - match subtype { - "Text" => "Sticky Note", - "FreeText" => "Text Box", - "Highlight" => "Highlight", - "Underline" => "Underline", - "StrikeOut" => "Strikethrough", - "Squiggly" => "Squiggly", - "Square" => "Rectangle", - "Circle" => "Oval", - "Line" => "Line", - "Ink" => "Pencil", - "Polygon" => "Polygon", - "PolyLine" => "Polygonal Line", - "Stamp" => "Stamp", - "Caret" => "Inserted Text", - "FileAttachment" => "File Attachment", - "Sound" => "Sound", - "Redact" => "Redaction", - other => other, - } -} - -/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. -pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { - match a.intent.as_deref() { - Some("FreeTextCallout") => "Callout", - Some("PolygonCloud") => "Cloud", - Some("FreeTextTypeWriter") => "Typewriter", - _ => comment_type_name(&a.subtype), - } -} - -/// The text of a comment summary: one block per comment (replies indented under it), with a -/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are -/// pop-ups; the number is the comment's position on its page. -pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { - use std::fmt::Write; - let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect(); - // Each reply under the annotation its `/IRT` points at, in document order, gathered once. - type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>; - let mut children = Children::new(); - for r in all.iter().filter(|r| r.state.is_none()) { - if let Some(parent) = r.parent { - children.entry(parent).or_default().push(r); - } - } - // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the - // direct parent, so following one level would leave the deeper replies out of the summary. - fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) { - use std::fmt::Write; - if depth > 64 { - return; - } - for r in children.get(&(parent.page, parent.index)).into_iter().flatten() { - let indent = " ".repeat(depth + 1); - let _ = - writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); - for line in r.contents.as_deref().unwrap_or("").lines() { - let _ = writeln!(out, "{indent}{line}"); - } - write_replies(out, children, r, depth + 1); - } - } - let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); - let mut last = usize::MAX; - let mut n = 0; - for a in &top { - if a.page != last { - last = a.page; - n = 0; - } - n += 1; - numbered.push((n, a)); - } - match sort { - SummarySort::Page => {} - SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)), - SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)), - SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))), - } - let mut out = format!("Summary of Comments on {name}\n\n"); - if numbered.is_empty() { - out.push_str("This document has no comments.\n"); - return out; - } - let mut heading = usize::MAX; - for (n, a) in numbered { - if sort == SummarySort::Page && a.page != heading { - heading = a.page; - let _ = writeln!(out, "Page: {}", a.page + 1); - } - let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a)); - if sort != SummarySort::Page { - let _ = write!(out, " Page: {}", a.page + 1); - } - let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or("")); - if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { - let _ = writeln!(out, "{c}"); - } - write_replies(&mut out, &children, a, 0); - out.push('\n'); - } - out -} - #[cfg(test)] mod tests; From 20974e7822ef8a9c12cfe3d9de4bcbd6c47df291 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:08:01 +0100 Subject: [PATCH 014/246] refactor(engine): move the combine and page-source helpers to sources.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 49 +++------------------------------- crates/engine/src/sources.rs | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 46 deletions(-) create mode 100644 crates/engine/src/sources.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a809b91021..274c00456b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -44,6 +44,7 @@ mod scan_fields; mod session; pub mod signature_image; pub mod signature_scan; +mod sources; mod stamp_preview; pub mod text_fonts; mod text_preview; @@ -200,6 +201,8 @@ use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; +pub use sources::{CombineSource, Combined, combine_source_check, source_page_count}; +use sources::{open_source, open_source_with}; use xfa_form::{ XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, @@ -270,17 +273,6 @@ fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: layers.iter().zip(before).any(|(l, was)| l.visible != was) } -/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all). -pub type CombineSource = (String, Arc>, Option); - -/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names -/// separate (old fully-qualified name → new one), in order across the sources. -#[derive(Debug)] -pub struct Combined { - pub bytes: Arc>, - pub renamed_fields: Vec<(String, String)>, -} - /// A bookmark as the bookmark list shows it. #[derive(Clone, Debug, PartialEq)] pub struct BookmarkRow { @@ -407,41 +399,6 @@ pub fn guard(f: impl FnOnce() -> T) -> Result { }) } -/// Parse another PDF to copy pages from. -pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { - open_source_with(name, bytes, None) -} - -/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file. -fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> Result { - source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) -} - -fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { - match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { - Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), - Ok(Ok(d)) => Ok(d), - Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), - Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), - Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), - Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), - } -} - -/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not -/// why: what Combine would refuse it for, so the Combine files list can say so before Combine -/// is pressed. The owner (permissions) password lifts the restriction on copying pages. -pub fn combine_source_check(bytes: &Arc>, password: Option<&str>) -> Result<(), SourceProblem> { - source_document(bytes, password).map(|_| ()) -} - -/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever -/// its permissions allow; `None` when it can't be read. -pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option { - let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?; - pdfcraft_organize::page_count(&doc).ok() -} - /// Validate the signature fields of `cos` (written as `bytes`). fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Vec { pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) diff --git a/crates/engine/src/sources.rs b/crates/engine/src/sources.rs new file mode 100644 index 0000000000..3c8083162e --- /dev/null +++ b/crates/engine/src/sources.rs @@ -0,0 +1,51 @@ +//! Other files to combine with or copy pages from: opening them, and why one can't be used. + +use std::sync::Arc; + +use crate::{EditError, SourceProblem}; + +/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all). +pub type CombineSource = (String, Arc>, Option); + +/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names +/// separate (old fully-qualified name → new one), in order across the sources. +#[derive(Debug)] +pub struct Combined { + pub bytes: Arc>, + pub renamed_fields: Vec<(String, String)>, +} + +/// Parse another PDF to copy pages from. +pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { + open_source_with(name, bytes, None) +} + +/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file. +pub(crate) fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> Result { + source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) +} + +fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { + match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { + Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), + Ok(Ok(d)) => Ok(d), + Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), + Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), + Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), + Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), + } +} + +/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not +/// why: what Combine would refuse it for, so the Combine files list can say so before Combine +/// is pressed. The owner (permissions) password lifts the restriction on copying pages. +pub fn combine_source_check(bytes: &Arc>, password: Option<&str>) -> Result<(), SourceProblem> { + source_document(bytes, password).map(|_| ()) +} + +/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever +/// its permissions allow; `None` when it can't be read. +pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option { + let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?; + pdfcraft_organize::page_count(&doc).ok() +} From 4972a4e1ebb02fcda1225f2082eba73a476db4d6 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:08:14 +0100 Subject: [PATCH 015/246] refactor(engine): move the bookmark list rows to bookmarks.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/bookmarks.rs | 61 ++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 63 ++-------------------------------- 2 files changed, 64 insertions(+), 60 deletions(-) create mode 100644 crates/engine/src/bookmarks.rs diff --git a/crates/engine/src/bookmarks.rs b/crates/engine/src/bookmarks.rs new file mode 100644 index 0000000000..deaece22e4 --- /dev/null +++ b/crates/engine/src/bookmarks.rs @@ -0,0 +1,61 @@ +//! The bookmark list. + +/// A bookmark as the bookmark list shows it. +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkRow { + /// Child indices from the top level, 0-based, as the edit functions take them. + pub path: Vec, + pub title: String, + /// Shown expanded (a positive `/Count`). + pub open: bool, + /// The 0-based page it goes to. + pub page: Option, + /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). + pub view: pdfcraft_render::DestView, + /// The title's colour (`/C`), `None` for the default black, and its style (`/F`). + pub color: Option<[f32; 3]>, + pub italic: bool, + pub bold: bool, +} + +/// One page of the bookmark list, in outline order. +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkPage { + pub rows: Vec, + /// Where the next page starts, or `None` at the end. + pub next: Option, + /// Set only when the document's structure could not be read for editing: its listing then + /// comes from the viewer's copy of the outline, which is shorter than the file's. + pub truncated: bool, +} + +/// The rows of a viewer outline, in outline order, with their paths. +pub(crate) fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { + let mut rows = Vec::new(); + // One entry per open level: its items, and how many of them have been listed. + let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)]; + while let Some(&(list, next)) = levels.last() { + let Some(item) = list.get(next) else { + levels.pop(); + continue; + }; + if let Some(top) = levels.last_mut() { + top.1 += 1; + } + let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); + rows.push(BookmarkRow { + path, + title: item.title.clone(), + open: item.open, + page: item.page, + view: item.view, + color: item.color, + italic: item.italic, + bold: item.bold, + }); + if !item.children.is_empty() { + levels.push((&item.children, 0)); + } + } + rows +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 274c00456b..fa18e5a66d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod actions; pub mod autotag; +mod bookmarks; pub mod build_info; pub mod camera; pub mod camera_clean; @@ -189,6 +190,8 @@ fn record_link_scan() { }); } +use bookmarks::outline_rows; +pub use bookmarks::{BookmarkPage, BookmarkRow}; use comments::comment_list; pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name}; pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; @@ -273,66 +276,6 @@ fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: layers.iter().zip(before).any(|(l, was)| l.visible != was) } -/// A bookmark as the bookmark list shows it. -#[derive(Clone, Debug, PartialEq)] -pub struct BookmarkRow { - /// Child indices from the top level, 0-based, as the edit functions take them. - pub path: Vec, - pub title: String, - /// Shown expanded (a positive `/Count`). - pub open: bool, - /// The 0-based page it goes to. - pub page: Option, - /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). - pub view: pdfcraft_render::DestView, - /// The title's colour (`/C`), `None` for the default black, and its style (`/F`). - pub color: Option<[f32; 3]>, - pub italic: bool, - pub bold: bool, -} - -/// One page of the bookmark list, in outline order. -#[derive(Clone, Debug, PartialEq)] -pub struct BookmarkPage { - pub rows: Vec, - /// Where the next page starts, or `None` at the end. - pub next: Option, - /// Set only when the document's structure could not be read for editing: its listing then - /// comes from the viewer's copy of the outline, which is shorter than the file's. - pub truncated: bool, -} - -/// The rows of a viewer outline, in outline order, with their paths. -fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { - let mut rows = Vec::new(); - // One entry per open level: its items, and how many of them have been listed. - let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)]; - while let Some(&(list, next)) = levels.last() { - let Some(item) = list.get(next) else { - levels.pop(); - continue; - }; - if let Some(top) = levels.last_mut() { - top.1 += 1; - } - let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); - rows.push(BookmarkRow { - path, - title: item.title.clone(), - open: item.open, - page: item.page, - view: item.view, - color: item.color, - italic: item.italic, - bold: item.bold, - }); - if !item.children.is_empty() { - levels.push((&item.children, 0)); - } - } - rows -} - /// A document's working file captured for crash recovery. #[derive(Clone, Debug)] pub struct RecoverySnapshot { From 8473403a5dce297e9ca0abdc6bde74af045e07de Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:08:38 +0100 Subject: [PATCH 016/246] refactor(engine): move layer visibility to layers.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layers.rs | 60 +++++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 56 ++-------------------------------- crates/engine/src/tests.rs | 2 +- 3 files changed, 63 insertions(+), 55 deletions(-) create mode 100644 crates/engine/src/layers.rs diff --git a/crates/engine/src/layers.rs b/crates/engine/src/layers.rs new file mode 100644 index 0000000000..c2f4f63714 --- /dev/null +++ b/crates/engine/src/layers.rs @@ -0,0 +1,60 @@ +//! Layer (optional content) visibility: what the view shows and what printing writes. + +use std::sync::Arc; + +use pdfcraft_render::{Layer, LayerOp, RenderPool}; + +use crate::{Document, render_threads}; + +/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy +/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its +/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` +/// is left alone. +pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; + let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; + let Some(properties) = root.get(b"OCProperties").cloned() else { return }; + let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; + let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); + let refs = + |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); + config.set(b"BaseState".to_vec(), Object::name("ON")); + config.set(b"ON".to_vec(), refs(true)); + config.set(b"OFF".to_vec(), refs(false)); + properties.set(b"D".to_vec(), Object::Dict(config)); + root.set(b"OCProperties".to_vec(), Object::Dict(properties)); + cos.set(root_ref, Object::Dict(root)); +} + +/// Render `doc` with the layer visibility in its `info.layers`. +pub(crate) fn use_layer_choices(doc: &mut Document) { + let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); + doc.config.layers = Arc::new(overrides); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); +} + +/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the +/// state the changes before it left. Returns whether any layer ended up changed. +pub(crate) fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { + let before: Vec = layers.iter().map(|l| l.visible).collect(); + let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect(); + for &(op, ocg) in changes { + let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue }; + let on = match op { + LayerOp::On => true, + LayerOp::Off => false, + LayerOp::Toggle => !layer.visible, + }; + layer.visible = on; + // Turning a layer off leaves the rest of its groups alone. + if on && preserve_rb { + for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) { + if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) { + l.visible = false; + } + } + } + } + layers.iter().zip(before).any(|(l, was)| l.visible != was) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fa18e5a66d..7a93fc87b0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -32,6 +32,7 @@ pub mod export; pub mod field_table; mod history; pub mod js; +mod layers; pub mod links; pub mod ocr; mod open; @@ -199,8 +200,8 @@ use document::{display_bytes, info_fields, recovered_render_password, same_pages pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; +use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; -use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; @@ -223,59 +224,6 @@ fn render_threads() -> usize { std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(4).clamp(2, 8) - 1 } -/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy -/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its -/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` -/// is left alone. -fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { - use pdfcraft_cos::{Dict, ObjRef, Object}; - let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; - let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; - let Some(properties) = root.get(b"OCProperties").cloned() else { return }; - let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; - let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); - let refs = - |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); - config.set(b"BaseState".to_vec(), Object::name("ON")); - config.set(b"ON".to_vec(), refs(true)); - config.set(b"OFF".to_vec(), refs(false)); - properties.set(b"D".to_vec(), Object::Dict(config)); - root.set(b"OCProperties".to_vec(), Object::Dict(properties)); - cos.set(root_ref, Object::Dict(root)); -} - -/// Render `doc` with the layer visibility in its `info.layers`. -fn use_layer_choices(doc: &mut Document) { - let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); - doc.config.layers = Arc::new(overrides); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); -} - -/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the -/// state the changes before it left. Returns whether any layer ended up changed. -fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { - let before: Vec = layers.iter().map(|l| l.visible).collect(); - let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect(); - for &(op, ocg) in changes { - let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue }; - let on = match op { - LayerOp::On => true, - LayerOp::Off => false, - LayerOp::Toggle => !layer.visible, - }; - layer.visible = on; - // Turning a layer off leaves the rest of its groups alone. - if on && preserve_rb { - for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) { - if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) { - l.visible = false; - } - } - } - } - layers.iter().zip(before).any(|(l, was)| l.visible != was) -} - /// A document's working file captured for crash recovery. #[derive(Clone, Debug)] pub struct RecoverySnapshot { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a30d81b74b..c622af21b4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::document::is_placeholder_title; use super::edit::check_permission; use super::*; -use pdfcraft_render::{OpenError, ReadingColors}; +use pdfcraft_render::{Layer, LayerOp, OpenError, ReadingColors}; /// A minimal `n`-page document; page `i` shows "Page i+1". pub(crate) fn fixture(n: usize) -> Vec { From 94b611081941737961d30dcef3e6b5fae75ad1af Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:09:11 +0100 Subject: [PATCH 017/246] refactor(engine): move placed pictures and mark sources to pictures.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 57 ++------------------------------- crates/engine/src/pictures.rs | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 54 deletions(-) create mode 100644 crates/engine/src/pictures.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7a93fc87b0..ea9c267ae0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -37,6 +37,7 @@ pub mod links; pub mod ocr; mod open; pub mod optimizer; +mod pictures; pub mod print_raster; pub mod printcheck; mod protection; @@ -202,6 +203,8 @@ pub use error::{EditError, SourceProblem}; pub use history::Label; use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; +pub use pictures::MarkFile; +use pictures::{embed_image, fit_picture, mark_source}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; @@ -295,60 +298,6 @@ fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) } -/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` -/// (0-based) of a PDF. -#[derive(Clone, Debug, PartialEq)] -pub struct MarkFile { - pub name: String, - pub bytes: Arc>, - pub page: usize, -} - -/// Embed an image that is placed on a page, turned the way its EXIF Orientation says. -pub(crate) fn embed_image( - doc: &mut pdfcraft_cos::Document, - name: &str, - bytes: &[u8], -) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { - pdfcraft_create::image_xobject(doc, name, bytes) -} - -/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its -/// shape in a box of any other shape. A box that is already that shape (to within rounding) is -/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite -/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse. -pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { - if !rect.iter().all(|v| v.is_finite()) { - return rect; - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]); - let usable = |v: f64| v.is_finite() && v > 0.0; - if !(usable(bw) && usable(bh) && usable(w) && usable(h)) { - return rect; - } - let k = (bw / w).min(bh / h); - let (fw, fh) = (w * k, h * k); - if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 { - return rect; - } - let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0); - [cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0] -} - -/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. -pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { - let head = &f.bytes[..f.bytes.len().min(1024)]; - if head.windows(5).any(|w| w == b"%PDF-") { - let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; - let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?; - Ok(pdfcraft_edit::MarkSource { xobject, size, image: false }) - } else { - let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?; - Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) - } -} - /// What Optimize PDF did. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct OptimizeReport { diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs new file mode 100644 index 0000000000..b29941720a --- /dev/null +++ b/crates/engine/src/pictures.rs @@ -0,0 +1,59 @@ +//! Pictures placed on pages: added images, and the pictures of watermarks and backgrounds. + +use std::sync::Arc; + +use crate::EditError; + +/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` +/// (0-based) of a PDF. +#[derive(Clone, Debug, PartialEq)] +pub struct MarkFile { + pub name: String, + pub bytes: Arc>, + pub page: usize, +} + +/// Embed an image that is placed on a page, turned the way its EXIF Orientation says. +pub(crate) fn embed_image( + doc: &mut pdfcraft_cos::Document, + name: &str, + bytes: &[u8], +) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { + pdfcraft_create::image_xobject(doc, name, bytes) +} + +/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its +/// shape in a box of any other shape. A box that is already that shape (to within rounding) is +/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite +/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse. +pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { + if !rect.iter().all(|v| v.is_finite()) { + return rect; + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]); + let usable = |v: f64| v.is_finite() && v > 0.0; + if !(usable(bw) && usable(bh) && usable(w) && usable(h)) { + return rect; + } + let k = (bw / w).min(bh / h); + let (fw, fh) = (w * k, h * k); + if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 { + return rect; + } + let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0); + [cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0] +} + +/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. +pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { + let head = &f.bytes[..f.bytes.len().min(1024)]; + if head.windows(5).any(|w| w == b"%PDF-") { + let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; + let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?; + Ok(pdfcraft_edit::MarkSource { xobject, size, image: false }) + } else { + let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?; + Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) + } +} From fc6072154d8321ff48cb802189b1bc856bc958be Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:09:23 +0100 Subject: [PATCH 018/246] refactor(engine): move the Fill & Sign geometry helpers to fill_sign.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/fill_sign.rs | 47 ++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 45 ++------------------------------ 2 files changed, 49 insertions(+), 43 deletions(-) create mode 100644 crates/engine/src/fill_sign.rs diff --git a/crates/engine/src/fill_sign.rs b/crates/engine/src/fill_sign.rs new file mode 100644 index 0000000000..08739addfa --- /dev/null +++ b/crates/engine/src/fill_sign.rs @@ -0,0 +1,47 @@ +//! Fill & Sign geometry: typed signatures, and boxes placed as a turned page displays them. + +use crate::{Shape, script_outline, signature_image}; + +/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user +/// space, vertically centred) and `height` points tall. `None` for text with no outlines. +/// +/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): +/// the box runs along the displayed axes, like an image signature's +/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across. +pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { + let o = script_outline(text); + let [left, bottom, right, top] = o.bounds(); + let span = (top - bottom).max(0.1); + let width = right - left; + if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) { + return None; + } + let k = height / span; + let rect = signature_image::upright_box(rotation, at, width * k, height)?; + // Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box + // is at this fraction of the user-space box. Exact for an unturned page. + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)]; + let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); + Some(Shape::TypedSignature { rect, contours }) +} + +/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). +pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + [at[0] + a * right + c * up, at[1] + b * right + d * up] +} + +/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, +/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a +/// non-finite point. +pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { + signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) +} + +/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types +/// it. `None` for text with no outlines. +pub fn typed_signature_graphic(text: &str) -> Option { + let o = script_outline(text); + o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours)) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ea9c267ae0..25428ebf67 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -30,6 +30,7 @@ mod edit; mod error; pub mod export; pub mod field_table; +mod fill_sign; mod history; pub mod js; mod layers; @@ -84,49 +85,6 @@ pub enum ImageEdit { Delete, } -/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user -/// space, vertically centred) and `height` points tall. `None` for text with no outlines. -/// -/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): -/// the box runs along the displayed axes, like an image signature's -/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across. -pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { - let o = script_outline(text); - let [left, bottom, right, top] = o.bounds(); - let span = (top - bottom).max(0.1); - let width = right - left; - if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) { - return None; - } - let k = height / span; - let rect = signature_image::upright_box(rotation, at, width * k, height)?; - // Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box - // is at this fraction of the user-space box. Exact for an unturned page. - let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); - let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)]; - let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); - Some(Shape::TypedSignature { rect, contours }) -} -/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). -pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { - let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); - [at[0] + a * right + c * up, at[1] + b * right + d * up] -} - -/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, -/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a -/// non-finite point. -pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { - signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) -} - -/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types -/// it. `None` for text with no outlines. -pub fn typed_signature_graphic(text: &str) -> Option { - let o = script_outline(text); - o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours)) -} - pub use links::{ MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; @@ -200,6 +158,7 @@ pub use document::{Document, TextNotice, display_bytes_for, render_password_for} use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; +pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, typed_signature_shape}; pub use history::Label; use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; From 8ca4bf641f05fb96d55dbbfc28292d8943681ce0 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:09:38 +0100 Subject: [PATCH 019/246] refactor(engine): move the page filters to page_filter.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 42 ++------------------------------ crates/engine/src/page_filter.rs | 41 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 40 deletions(-) create mode 100644 crates/engine/src/page_filter.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 25428ebf67..825494f26f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -38,6 +38,7 @@ pub mod links; pub mod ocr; mod open; pub mod optimizer; +mod page_filter; mod pictures; pub mod print_raster; pub mod printcheck; @@ -162,6 +163,7 @@ pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, type pub use history::Label; use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; +pub use page_filter::{PageOrientation, PageParity, filter_pages}; pub use pictures::MarkFile; use pictures::{embed_image, fit_picture, mark_source}; use protection::unrestricted; @@ -269,43 +271,3 @@ pub struct OptimizeReport { #[cfg(test)] mod tests; - -/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and -/// orientation (as displayed). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageParity { - #[default] - Both, - Even, - Odd, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageOrientation { - #[default] - Both, - Landscape, - Portrait, -} - -/// The pages of `pages` (0-based) that pass the filters. -pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec { - pages - .iter() - .copied() - .filter(|p| match parity { - PageParity::Both => true, - // Page numbers are 1-based: index 1 is page 2, an even page. - PageParity::Even => p % 2 == 1, - PageParity::Odd => p % 2 == 0, - }) - .filter(|p| { - let Some(pg) = info.pages.get(*p) else { return false }; - match orientation { - PageOrientation::Both => true, - PageOrientation::Landscape => pg.width > pg.height, - PageOrientation::Portrait => pg.width <= pg.height, - } - }) - .collect() -} diff --git a/crates/engine/src/page_filter.rs b/crates/engine/src/page_filter.rs new file mode 100644 index 0000000000..aaedfbd65c --- /dev/null +++ b/crates/engine/src/page_filter.rs @@ -0,0 +1,41 @@ +//! Page filters: even or odd page numbers, and orientation as displayed. + +/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and +/// orientation (as displayed). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageParity { + #[default] + Both, + Even, + Odd, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageOrientation { + #[default] + Both, + Landscape, + Portrait, +} + +/// The pages of `pages` (0-based) that pass the filters. +pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec { + pages + .iter() + .copied() + .filter(|p| match parity { + PageParity::Both => true, + // Page numbers are 1-based: index 1 is page 2, an even page. + PageParity::Even => p % 2 == 1, + PageParity::Odd => p % 2 == 0, + }) + .filter(|p| { + let Some(pg) = info.pages.get(*p) else { return false }; + match orientation { + PageOrientation::Both => true, + PageOrientation::Landscape => pg.width > pg.height, + PageOrientation::Portrait => pg.width <= pg.height, + } + }) + .collect() +} From 51fbed293bbc00f7470f5a09de44fa4d715cd665 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:09:57 +0100 Subject: [PATCH 020/246] refactor(engine): move the test-only hooks to test_hooks.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 52 +++------------------------------ crates/engine/src/test_hooks.rs | 42 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 48 deletions(-) create mode 100644 crates/engine/src/test_hooks.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 825494f26f..ebd0b5c7a1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -51,6 +51,8 @@ pub mod signature_image; pub mod signature_scan; mod sources; mod stamp_preview; +#[cfg(test)] +mod test_hooks; pub mod text_fonts; mod text_preview; pub mod word_count; @@ -103,54 +105,6 @@ pub type SplitPart = (usize, usize, Arc>); use std::sync::Arc; -#[cfg(test)] -thread_local! { - static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) }; - static REFRESH_PANICS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -#[cfg(test)] -fn panic_on_refresh(count: usize) { - REFRESH_PANICS.with(|n| n.set(count)); -} - -#[cfg(test)] -fn check_refresh_panic() { - REFRESH_PANICS.with(|n| { - let rem = n.get(); - if rem > 0 { - n.set(rem - 1); - panic!("injected refresh panic"); - } - }); -} - -#[cfg(test)] -fn reset_secondary_scan_counts() { - SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0))); -} - -#[cfg(test)] -fn secondary_scan_counts() -> (usize, usize) { - SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get) -} - -#[cfg(test)] -fn record_marker_scan() { - SECONDARY_SCAN_COUNTS.with(|counts| { - let (markers, links) = counts.get(); - counts.set((markers + 1, links)); - }); -} - -#[cfg(test)] -fn record_link_scan() { - SECONDARY_SCAN_COUNTS.with(|counts| { - let (markers, links) = counts.get(); - counts.set((markers, links + 1)); - }); -} - use bookmarks::outline_rows; pub use bookmarks::{BookmarkPage, BookmarkRow}; use comments::comment_list; @@ -171,6 +125,8 @@ pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; pub use sources::{CombineSource, Combined, combine_source_check, source_page_count}; use sources::{open_source, open_source_with}; +#[cfg(test)] +use test_hooks::{check_refresh_panic, panic_on_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts, secondary_scan_counts}; use xfa_form::{ XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, diff --git a/crates/engine/src/test_hooks.rs b/crates/engine/src/test_hooks.rs new file mode 100644 index 0000000000..d6e504599e --- /dev/null +++ b/crates/engine/src/test_hooks.rs @@ -0,0 +1,42 @@ +//! Test-only hooks: injected refresh panics and counts of the secondary page scans. + +thread_local! { + static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) }; + static REFRESH_PANICS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub(crate) fn panic_on_refresh(count: usize) { + REFRESH_PANICS.with(|n| n.set(count)); +} + +pub(crate) fn check_refresh_panic() { + REFRESH_PANICS.with(|n| { + let rem = n.get(); + if rem > 0 { + n.set(rem - 1); + panic!("injected refresh panic"); + } + }); +} + +pub(crate) fn reset_secondary_scan_counts() { + SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0))); +} + +pub(crate) fn secondary_scan_counts() -> (usize, usize) { + SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get) +} + +pub(crate) fn record_marker_scan() { + SECONDARY_SCAN_COUNTS.with(|counts| { + let (markers, links) = counts.get(); + counts.set((markers + 1, links)); + }); +} + +pub(crate) fn record_link_scan() { + SECONDARY_SCAN_COUNTS.with(|counts| { + let (markers, links) = counts.get(); + counts.set((markers, links + 1)); + }); +} From dc224884cb9c50307ef589b683405d17ee3be4e3 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:10:38 +0100 Subject: [PATCH 021/246] refactor(engine): gather lib.rs's imports and re-exports under the module list Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 77 ++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ebd0b5c7a1..78e5ea1eb4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -59,6 +59,11 @@ pub mod word_count; pub mod xfa; mod xfa_form; +use std::sync::Arc; + +pub use links::{ + MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, +}; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use stamp_preview::StampPreview; @@ -66,45 +71,6 @@ pub use text_preview::TextEditPreview; pub use reexports::*; -/// A change to an existing page image. -#[derive(Clone, Debug, PartialEq)] -pub enum ImageEdit { - /// Move and resize it to this box (user space). - Move([f64; 4]), - /// Quarter turns clockwise about its centre. - Rotate(i32), - /// Any angle in degrees clockwise about its centre (the unit is degrees, not quarter turns). - RotateBy(f64), - Flip { - horizontal: bool, - }, - /// Show only this box of it (user space), the pixels staying where they are. - Crop([f64; 4]), - /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. - Replace { - name: String, - bytes: Arc>, - }, - Delete, -} - -pub use links::{ - MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, -}; - -/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a -/// filled-in PDF form), as CSV with one row per file. -pub fn merge_data_files(files: &[(String, Vec)]) -> Result { - let rows = - files.iter().map(|(name, bytes)| pdfcraft_xfdf::data_values(bytes).map_err(|e| format!("{name}: {e}"))).collect::, _>>()?; - Ok(pdfcraft_xfdf::merge_csv(&rows)) -} - -/// One file produced by a split: (1-based first page, last page, PDF bytes). -pub type SplitPart = (usize, usize, Arc>); - -use std::sync::Arc; - use bookmarks::outline_rows; pub use bookmarks::{BookmarkPage, BookmarkRow}; use comments::comment_list; @@ -132,6 +98,39 @@ use xfa_form::{ undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, }; +/// A change to an existing page image. +#[derive(Clone, Debug, PartialEq)] +pub enum ImageEdit { + /// Move and resize it to this box (user space). + Move([f64; 4]), + /// Quarter turns clockwise about its centre. + Rotate(i32), + /// Any angle in degrees clockwise about its centre (the unit is degrees, not quarter turns). + RotateBy(f64), + Flip { + horizontal: bool, + }, + /// Show only this box of it (user space), the pixels staying where they are. + Crop([f64; 4]), + /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. + Replace { + name: String, + bytes: Arc>, + }, + Delete, +} + +/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a +/// filled-in PDF form), as CSV with one row per file. +pub fn merge_data_files(files: &[(String, Vec)]) -> Result { + let rows = + files.iter().map(|(name, bytes)| pdfcraft_xfdf::data_values(bytes).map_err(|e| format!("{name}: {e}"))).collect::, _>>()?; + Ok(pdfcraft_xfdf::merge_csv(&rows)) +} + +/// One file produced by a split: (1-based first page, last page, PDF bytes). +pub type SplitPart = (usize, usize, Arc>); + /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct DocId(pub u64); From 00596b69a6c4cf7a46d83f3ed9723815d55ae4a0 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:12:15 +0100 Subject: [PATCH 022/246] refactor(engine): keep the moved docs' intra-doc links resolving (document, fill_sign) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/document.rs | 6 +++--- crates/engine/src/fill_sign.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 0c85f51af6..7023caa55e 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -59,7 +59,7 @@ pub struct Document { /// XFA forms: what was approximated, rewritten or could not be written to the XFA data /// (also in `info.warnings`, kept there when the document is re-read). pub xfa_warnings: Vec, - /// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed, + /// What the last successful [`Session::apply`](crate::Session::apply) wrote that its fonts can't draw: kept as typed, /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. pub edit_notices: Vec, } @@ -440,7 +440,7 @@ impl Document { } /// A password restricts the document and it wasn't opened with the owner password: entering - /// that password ([`Session::unlock`]) lifts the restrictions. + /// that password ([`Session::unlock`](crate::Session::unlock)) lifts the restrictions. pub fn can_unlock(&self) -> bool { self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) } @@ -629,7 +629,7 @@ pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc>, password: Option<&str>) -> Option { diff --git a/crates/engine/src/fill_sign.rs b/crates/engine/src/fill_sign.rs index 08739addfa..1bd8c68d05 100644 --- a/crates/engine/src/fill_sign.rs +++ b/crates/engine/src/fill_sign.rs @@ -7,7 +7,7 @@ use crate::{Shape, script_outline, signature_image}; /// /// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): /// the box runs along the displayed axes, like an image signature's -/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across. +/// ([`SignatureImage::rect`](crate::SignatureImage::rect)), and the outlines are turned back so the name reads across. pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { let o = script_outline(text); let [left, bottom, right, top] = o.bounds(); From 323f569b37b2982b73bde3bddc3453479da56dbe Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:14:58 +0100 Subject: [PATCH 023/246] docs: point form-performance at the engine's session.rs and edit.rs Co-Authored-By: Claude Opus 5.5 --- docs/form-performance.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/form-performance.md b/docs/form-performance.md index c00adf0913..1ef72dfc6d 100644 --- a/docs/form-performance.md +++ b/docs/form-performance.md @@ -8,8 +8,9 @@ not fresh measurements on upstream main. No private document is needed for the b - `crates/ui-egui/src/forms_ui.rs` queues `Edit::SetFieldValue` for a text commit or checkbox toggle. `crates/automation/src/forms.rs::form_fill` uses the same edit API. -- `crates/engine/src/lib.rs::Session::apply` runs edits on a copy, preserving rollback and undo. - `run_edit` calls `pdfcraft_forms::set_value_with` with an engine `JsRunner`. +- `crates/engine/src/session.rs::Session::apply` runs edits on a copy, preserving rollback and + undo. `run_edit` (`crates/engine/src/edit.rs`) calls `pdfcraft_forms::set_value_with` with an + engine `JsRunner`. - `crates/forms/src/lib.rs` validates and writes the edited value, then runs one calculation pass. `/CO` precedes calculated fields omitted from `/CO`. Each script sees prior writes. There is no dependency graph or dependency-based filtering: dynamic references and script @@ -265,6 +266,8 @@ checks, not hosted CI or a new performance speedup claim. ## Revision history +- 2026-10-25: point the architecture notes at `session.rs` and `edit.rs`, where `Session::apply` + and `run_edit` moved when the engine's `lib.rs` was split. - 2026-10-19: refresh PR #3318 onto upstream `e397efa8`, retain upstream roadmap additions and all performance changes, and repeat focused, scoped, benchmark and headless validation. From fc578a0b02e9a577b4bdb9e7cc2d9113623e9f4f Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 25 Oct 2026 16:36:22 -0600 Subject: [PATCH 024/246] chore(deps): bump 11 safe dependency updates --- Cargo.lock | 349 +++++++++++++---------------------- apps/pdfcraft-cli/Cargo.toml | 2 +- crates/automation/Cargo.toml | 2 +- crates/sign/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 2 +- crates/xfa/Cargo.toml | 2 +- 6 files changed, 138 insertions(+), 221 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd3e1930fd..54be313cc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,21 +215,21 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -443,9 +443,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -454,9 +454,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -489,15 +489,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -508,10 +499,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -519,6 +513,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -576,7 +580,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -645,7 +649,7 @@ dependencies = [ "oneshot", "pastey", "portable-atomic", - "rand 0.10.3", + "rand", "regress", "rustc-hash 2.1.3", "ryu-js", @@ -736,6 +740,30 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bp256" version = "0.14.0" @@ -1164,7 +1192,7 @@ dependencies = [ "dispatch2", "egui", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", ] @@ -1175,7 +1203,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd0 dependencies = [ "egui", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", @@ -1473,7 +1501,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1594,7 +1622,7 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", - "sha2 0.11.0", + "sha2 0.11.1", "subtle", "zeroize", ] @@ -1617,7 +1645,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1689,7 +1717,7 @@ dependencies = [ "egui", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", @@ -1920,7 +1948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2090,7 +2118,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -2409,7 +2437,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3241,10 +3269,10 @@ dependencies = [ "log", "md-5", "nom", - "rand 0.10.3", + "rand", "rangemap", "rayon", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", "weezl 0.2.1", @@ -3346,7 +3374,7 @@ dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3551,9 +3579,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -3582,7 +3610,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3632,7 +3660,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3643,7 +3671,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3699,7 +3727,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3710,7 +3738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3746,7 +3774,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3770,7 +3798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3815,7 +3843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -3913,7 +3941,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3926,7 +3954,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3940,7 +3968,7 @@ dependencies = [ "fiat-crypto", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3954,7 +3982,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -4065,7 +4093,7 @@ dependencies = [ name = "pdfcraft-automation" version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "flate2", "image", "pdfcraft-cos", @@ -4079,7 +4107,7 @@ dependencies = [ "png 0.18.1", "rustix 1.1.5", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tempfile", ] @@ -4087,7 +4115,7 @@ dependencies = [ name = "pdfcraft-cli" version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "pdfcraft-audit", "pdfcraft-automation", "pdfcraft-cos", @@ -4151,7 +4179,7 @@ dependencies = [ "aes", "md-5", "proptest", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", ] @@ -4398,7 +4426,7 @@ version = "0.14.0" dependencies = [ "aes", "aws-lc-rs", - "base64 0.22.1", + "base64 0.23.1", "bp256", "bp384", "cbc", @@ -4423,7 +4451,7 @@ dependencies = [ "rustls-cng", "security-framework", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "sha3", "thiserror 2.0.21", ] @@ -4437,7 +4465,7 @@ name = "pdfcraft-ui-egui" version = "0.14.0" dependencies = [ "arboard", - "base64 0.22.1", + "base64 0.23.1", "block2 0.6.2", "bytemuck", "chrono", @@ -4453,7 +4481,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "pdfcraft-cos", @@ -4498,13 +4526,13 @@ dependencies = [ name = "pdfcraft-xfa" version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "image", "pdfcraft-cos", "pdfcraft-fonts", "png 0.18.1", "roxmltree", - "sha2 0.11.0", + "sha2 0.11.1", "thiserror 2.0.21", ] @@ -4750,9 +4778,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.10.2" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" dependencies = [ "base64 0.23.1", "indexmap", @@ -4852,15 +4880,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -4944,16 +4963,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -5027,7 +5047,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.3", + "rand", "rand_pcg", "ring", "rustc-hash 2.1.3", @@ -5051,7 +5071,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5075,16 +5095,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -5096,31 +5106,12 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -5138,11 +5129,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5169,7 +5160,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -5362,7 +5353,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5601,7 +5592,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5633,9 +5624,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5671,7 +5662,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5843,9 +5834,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5942,9 +5933,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -6100,7 +6091,7 @@ dependencies = [ "const-oid 0.9.6", "der 0.7.10", "hex", - "rand 0.10.3", + "rand", "reqwest", "rustls-pki-types", "rustls-webpki", @@ -6555,10 +6546,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7012,9 +7003,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.2" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ "base64 0.23.1", "flate2", @@ -7029,9 +7020,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64 0.23.1", "http", @@ -7453,7 +7444,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", @@ -7609,7 +7600,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -7670,7 +7661,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7791,7 +7782,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -7800,16 +7791,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -7827,31 +7809,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -7869,96 +7834,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -8051,9 +7968,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -8235,7 +8152,7 @@ dependencies = [ "png 0.18.1", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "toml", ] diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index 7b5f9a82f3..3d9d87a812 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -21,7 +21,7 @@ pdfcraft-cos = { workspace = true } pdfcraft-preflight = { workspace = true } pdfcraft-automation = { workspace = true } pdfcraft-platform = { workspace = true } -base64 = "0.22" +base64 = "0.23" [dev-dependencies] # Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine. diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index bed579ab84..a6e43de1f0 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -17,7 +17,7 @@ pdfcraft-platform = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" tempfile = "3.27" -base64 = { version = "0.22", optional = true } +base64 = { version = "0.23", optional = true } [features] diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index 4f1f0d4094..3c0f207a98 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -18,7 +18,7 @@ pdfcraft-model = { workspace = true } thiserror.workspace = true # ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code). chrono = { workspace = true } -base64 = "0.22" +base64 = "0.23" sha1 = "0.11" sha2 = "0.11" p256 = { version = "0.14", default-features = false, features = ["ecdsa", "std"] } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c096254995..5279f56248 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -33,7 +33,7 @@ bytemuck = { version = "1", features = ["extern_crate_alloc"] } rfd = { workspace = true } png = "0.18" -base64 = "0.22" +base64 = "0.23" # Unicode bidi paragraph segmentation for right-to-left comment wrapping; egui 0.37's epaint owns # display reordering. Already in the tree through egui’s epaint. unicode-bidi = "0.3" diff --git a/crates/xfa/Cargo.toml b/crates/xfa/Cargo.toml index dcec1e75bc..d917018057 100644 --- a/crates/xfa/Cargo.toml +++ b/crates/xfa/Cargo.toml @@ -14,7 +14,7 @@ workspace = true pdfcraft-cos = { workspace = true } pdfcraft-fonts = { workspace = true } roxmltree = "0.20" -base64 = "0.22" +base64 = "0.23" png = "0.18" sha2 = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "tiff"] } From 01b696df2c79636ff6ea737e6b02eff634d99f6c Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:11:45 -0400 Subject: [PATCH 025/246] test(ui): CJK interface tests skip without craft-fonts (#4706) * test(ui): CJK interface tests skip without craft-fonts * test(ui): a build that requires craft-fonts never skips the CJK interface tests --- crates/ui-egui/tests/it/combine_grid.rs | 4 +++ crates/ui-egui/tests/it/commands.rs | 3 ++ crates/ui-egui/tests/it/content.rs | 6 ++++ crates/ui-egui/tests/it/control.rs | 21 +++++++++++++ crates/ui-egui/tests/it/date_format.rs | 7 +++++ crates/ui-egui/tests/it/editing.rs | 6 ++++ crates/ui-egui/tests/it/pickers.rs | 3 ++ crates/ui-egui/tests/it/support/mod.rs | 41 +++++++++++++++++++++++-- 8 files changed, 89 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/it/combine_grid.rs b/crates/ui-egui/tests/it/combine_grid.rs index c6f2e3e721..0339e0b6bb 100644 --- a/crates/ui-egui/tests/it/combine_grid.rs +++ b/crates/ui-egui/tests/it/combine_grid.rs @@ -530,6 +530,10 @@ fn combine_follows_the_order_shown_in_the_grid() { #[test] fn long_and_unusual_names_are_shortened_on_the_card_but_kept_whole() { + if crate::support::skip_without_glyphs("long_and_unusual_names_are_shortened_on_the_card_but_kept_whole", "en", "日本語のファイル名.pdf") + { + return; + } let long = "Überweisungsbestätigung für das dritte Quartal 2026 – Endfassung (unterschrieben).pdf"; let mut h = grid_of(vec![(long, fixture(1)), ("日本語のファイル名.pdf", fixture(1)), ("a&b 'd'.pdf", fixture(1))], vec2(1400.0, 900.0)); // The card's label is the whole name; only the drawing is shortened. diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index ff82820148..15c23c6296 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -623,6 +623,9 @@ fn palette_ime_idle_frames_and_reopen_do_not_interrupt_composition() { #[test] fn palette_ime_chinese_preedit_commit_and_cancel_remain_uninterrupted() { + if crate::support::skip_without_glyphs("palette_ime_chinese_preedit_commit_and_cancel_remain_uninterrupted", "en", "你") { + return; + } let mut h = harness(); let mut interruptions = Vec::new(); h.state_mut().run_command("view.palette"); diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 0353a7d2b3..4a7927f692 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -335,6 +335,9 @@ fn a_picture_on_the_clipboard_is_pasted_onto_the_page() { /// written to the page as `?` (or lost). #[test] fn text_the_standard_fonts_cant_draw_stays_in_the_editor() { + if crate::support::skip_without_glyphs("text_the_standard_fonts_cant_draw_stays_in_the_editor", "en", "Hello 世界") { + return; + } let _gpu = gpu(); let mut h = harness(); assert!(h.state_mut().execute("edit.text")); @@ -372,6 +375,9 @@ fn text_the_standard_fonts_cant_draw_stays_in_the_editor() { /// is left blocking Save out of sight. #[test] fn a_kept_draft_goes_with_its_deleted_page() { + if crate::support::skip_without_glyphs("a_kept_draft_goes_with_its_deleted_page", "en", "日本語") { + return; + } let mut h = harness(); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 })); h.run_steps(2); diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index e18c7bf47a..fe96bb5951 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -504,6 +504,9 @@ fn language_switch_preserves_document_and_command_ids() { assert_eq!(documents[0]["dirty"], true); let commands = ok(&mut h, &c, "ui.commands", json!({})); for code in ["ja", "zh-hans", "fr", "de", "uk", "lt", "en"] { + if crate::support::skip_without_glyphs(&format!("language_switch_preserves_document_and_command_ids ({code})"), code, "") { + continue; + } ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": code })); h.run_steps(2); let state = ok(&mut h, &c, "ui.state", json!({})); @@ -908,6 +911,9 @@ fn state_and_view_options() { #[test] fn japanese_controls_and_search_keep_command_ids() { + if crate::support::skip_without_glyphs("japanese_controls_and_search_keep_command_ids", "ja", "") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "ja"); @@ -1002,6 +1008,9 @@ fn ukrainian_preferences_and_search_keep_command_ids() { #[test] fn preferences_menu_and_shortcut_allow_switching_interface_languages() { + if crate::support::skip_without_glyphs("preferences_menu_and_shortcut_allow_switching_interface_languages", "ja", "") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" })); @@ -1044,6 +1053,9 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() { #[test] fn japanese_dialogs_errors_and_custom_action_names() { + if crate::support::skip_without_glyphs("japanese_dialogs_errors_and_custom_action_names", "ja", "") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "ja"})); for (dialog, label) in [ @@ -1084,6 +1096,9 @@ fn japanese_dialogs_errors_and_custom_action_names() { #[test] fn simplified_chinese_about_tabs_and_credit_controls() { + if crate::support::skip_without_glyphs("simplified_chinese_about_tabs_and_credit_controls", "zh-hans", "") { + return; + } let (mut h, c) = harness(); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"})); @@ -1113,6 +1128,9 @@ fn simplified_chinese_about_tabs_and_credit_controls() { #[test] fn simplified_chinese_signature_prompts_and_errors_keep_document_state() { + if crate::support::skip_without_glyphs("simplified_chinese_signature_prompts_and_errors_keep_document_state", "zh-hans", "") { + return; + } let (mut h, c) = harness(); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"})); @@ -1721,6 +1739,9 @@ fn control_quit_retains_inactive_fill_sign_typing_after_cancel() { #[test] fn control_quit_retains_blocked_content_typing_after_cancel() { + if crate::support::skip_without_glyphs("control_quit_retains_blocked_content_typing_after_cancel", "en", "Keep 世界") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.command", json!({"id":"edit.text"})); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/date_format.rs b/crates/ui-egui/tests/it/date_format.rs index 0606e021de..c2dadb28d3 100644 --- a/crates/ui-egui/tests/it/date_format.rs +++ b/crates/ui-egui/tests/it/date_format.rs @@ -133,6 +133,13 @@ fn month_names_follow_the_date_language_or_else_the_interface_language() { #[test] fn a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first() { + if crate::support::skip_without_glyphs( + "a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first", + "en", + "日曜日月曜日火曜日水曜日木曜日金曜日土曜日", + ) { + return; + } let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 9bf7f70813..0895a8b5be 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -1087,6 +1087,9 @@ fn save_prompt_fits_the_smallest_window_whatever_the_name() { let size = egui::vec2(820.0, 520.0); for name in names { let start: String = name.chars().take(10).collect(); + if support::skip_without_glyphs(&format!("save_prompt_fits_the_smallest_window_whatever_the_name ({start}…)"), "en", &name) { + continue; + } let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); @@ -3575,6 +3578,9 @@ fn an_applied_paragraph_keeps_its_edited_look_until_the_page_is_redrawn() { /// as a form field does; the editor used to close with the error and the typing was lost. #[test] fn a_paragraph_whose_edit_fails_stays_open_with_its_typing() { + if crate::support::skip_without_glyphs("a_paragraph_whose_edit_fails_stays_open_with_its_typing", "en", "Top ก") { + return; + } let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) Tj ET BT /F2 24 Tf 20 120 Td (Bottom) Tj ET"); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index 6aa5515527..f9e46c5f2f 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -963,6 +963,9 @@ impl Drop for AttachmentDir { #[test] fn attachment_save_preserves_bytes_metadata_and_selected_destination() { + if crate::support::skip_without_glyphs("attachment_save_preserves_bytes_metadata_and_selected_destination", "en", "user chosen 資料.bin") { + return; + } let dir = AttachmentDir::new(); let chosen = dir.0.join("user chosen 資料.bin"); let original = attachment_fixture(); diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index aa96ab20dc..174ec5cc0b 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -1,7 +1,12 @@ //! State-based waits for native preparation, which completes between UI frames, and pointer -//! input in the frames the platform delivers it in ([`input`]). +//! input in the frames the platform delivers it in ([`input`]), and the skip for tests whose text +//! the interface fonts cannot draw ([`skip_without_glyphs`]). +use std::collections::BTreeSet; + +use egui::FontId; +use egui::epaint::text::{Fonts, TextOptions}; use egui_kittest::Harness; -use pdfcraft_ui_egui::PdfCraftApp; +use pdfcraft_ui_egui::{PdfCraftApp, i18n, theme}; pub mod input; @@ -45,3 +50,35 @@ pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl h.state().toast ); } + +/// Whether the test `name` must skip because the interface fonts lack a glyph for a character it +/// draws: one in `text` or in the catalog of the interface `language`. egui 0.37 test harnesses +/// panic on a missing glyph, and Japanese, Chinese and Thai faces come only from the optional +/// craft-fonts build input or a face installed on this machine. Prints the skip line. +/// +/// The fonts are those the app installs for `language` and for the default CJK preference: a +/// switch to Chinese installs its fonts for the next frame, so the first frame in Chinese draws +/// with the fonts of the language before it. +/// +/// A build that requires craft-fonts (`CRAFT_FONTS_REQUIRED=1`, as the CI gate sets) never skips: +/// a missing glyph there is a failure. +pub fn skip_without_glyphs(name: &str, language: &str, text: &str) -> bool { + if std::env::var("CRAFT_FONTS_REQUIRED").as_deref() == Ok("1") { + return false; + } + let catalog = i18n::LANGUAGES.iter().find(|l| l.code == language).map_or("", |l| l.source); + let translations = catalog.lines().filter(|l| !l.starts_with('#')).filter_map(|l| l.split('\t').nth(2)); + let chars: BTreeSet = std::iter::once(text).chain(translations).flat_map(str::chars).filter(|c| !c.is_control()).collect(); + let cjk = theme::CjkPreference::for_language(language); + let missing = [theme::CjkPreference::Default, cjk].into_iter().find_map(|cjk| { + let defs = theme::installed_font_definitions(cjk); + let families: Vec = defs.families.keys().map(|f| FontId::new(13.0, f.clone())).collect(); + let mut fonts = Fonts::new(TextOptions::default(), defs); + chars.iter().copied().find(|c| families.iter().any(|id| !fonts.has_glyphs(id, &c.to_string()))) + }); + let Some(missing) = missing else { + return false; + }; + eprintln!("skipping {name}: no interface font draws {missing:?} (set CRAFT_FONTS_DIR to a craft-fonts checkout with a face for it)"); + true +} From 64e0868b844e0230027a493c90d6e4bdc7af086f Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:57:46 -0400 Subject: [PATCH 026/246] feat(ui): menus from craft-menu, native on macOS (#4752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): menus from craft-menu, native on macOS One menu definition (crates/ui-egui/src/menus.rs) built from the command registry drives the native macOS menu bar (craft-menu's muda backend) and an in-window egui bar on Windows, Linux and the web, which takes the place of the "Menu" button at the start of the top bar. - File, Edit, View, Sign, Window and Help, with separators and submenus. Edit gains Cut, Paste, Delete and Select All (they act as their keys do) and a Pages submenu; View gains a Tools submenu built from the All tools groups; Sign holds Fill & Sign and the certificate commands; Window lists the open documents. - Toggles are check items and the page layouts, side panels and themes radio items, with their state from the app. - File > Open Recent lists the recent files (file.open_recent with an index). - A menu click and its shortcut run the same dispatch, including the form-field deferral (#166) and macOS Cmd+W closing the window (#869). Dialogs and the palette grey the menus through craft-menu's gate. - Quit goes through craft-quit, so the unsaved-changes prompt runs. - craft-ui, craft-quit, craft-menu and craft-keymap pinned to storytold/craft-libs 70ade1fb. Menu state and dispatch follow #2610 by @hajdukste; the menu tree and the view and panel items follow #3870 by @RivanJarjes. * test(ui): the menu model, native dispatch and the in-window bar - Unit tests for the menu definition: every registry menu command is listed once with its registry shortcut; check and radio items follow the app; a dialog greys document commands and drops their clicks; the recent files and open documents are listed once each; a native menu click (through a fake craft-menu backend) runs its command once. - The kittests that opened the old "Menu" button use the menu bar, and the ones that look up a label the bar's titles share (Edit, File, Sign) skip the bar. - The in-window bar takes its titles' width, so the mode tabs, search and buttons keep their place in the top bar. - Edit menu clicks that stand for keys (Copy, Cut, Delete, Select All) reach this frame's input from `logic`, where test hosts see them too. * test(ui): the language switch test opens File in the menu bar Translated titles and rows carry their mnemonic ("ファイル(F)", "開く(O)…"). * feat(ui): the menu bars run Quit themselves, the bar shrinks to its titles, and AppKit's window list stays off craft-libs main edc41f6 (craft-libs#354): MenuBarUi runs Quit and Minimize, shrink_to_fit sizes the in-window bar, Key::parse reads the registry's arrow and minus glyphs, and mac::install_with(.., window_list(false)) keeps the Window menu to PdfCraft's own document list. * fix(ui): the menu command filter is an if, for clippy * test(ui): drop a needless borrow in the edit-tab helper * test(ui): two tests follow the menu bar The tests were wrong, not the app. The Preferences language test clicked the old Menu button; it opens Edit in the menu bar and its Preferences row. The prepare Actions test counted the Edit labels: the narrower menu bar leaves room for the Edit tab, so the rows' Edit buttons come third and fourth. --- Cargo.lock | 49 +- Cargo.toml | 7 +- apps/pdfcraft/Cargo.toml | 3 - apps/pdfcraft/src/main.rs | 9 +- crates/ui-egui/Cargo.toml | 9 +- crates/ui-egui/src/chrome.rs | 129 +--- crates/ui-egui/src/commands.rs | 91 +-- crates/ui-egui/src/lib.rs | 25 +- crates/ui-egui/src/menus.rs | 791 ++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 4 - crates/ui-egui/tests/it/commands.rs | 52 +- crates/ui-egui/tests/it/control.rs | 33 +- crates/ui-egui/tests/it/editing.rs | 11 +- crates/ui-egui/tests/it/marks.rs | 3 +- crates/ui-egui/tests/it/prepare.rs | 10 +- crates/ui-egui/tests/it/signing.rs | 17 +- crates/ui-egui/tests/it/support/mod.rs | 21 + crates/ui-egui/tests/it/text_select.rs | 15 +- crates/ui-egui/tests/it/theme.rs | 7 +- crates/ui-egui/tests/it/trust_signer.rs | 3 +- crates/ui-egui/tests/it/ui.rs | 10 +- crates/ui-egui/tests/it/view.rs | 18 +- 22 files changed, 990 insertions(+), 327 deletions(-) create mode 100644 crates/ui-egui/src/menus.rs diff --git a/Cargo.lock b/Cargo.lock index 54be313cc6..3c713ba5cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,7 +229,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -1171,6 +1171,29 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-keymap" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +dependencies = [ + "egui", + "serde", + "serde_json", +] + +[[package]] +name = "craft-menu" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +dependencies = [ + "craft-keymap", + "craft-quit", + "egui", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -1187,11 +1210,10 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" dependencies = [ "dispatch2", "egui", - "muda", "objc2 0.6.5", "objc2-app-kit 0.3.2", ] @@ -1199,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" dependencies = [ "egui", "log", @@ -1948,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3941,7 +3963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4035,7 +4057,6 @@ name = "pdfcraft" version = "0.14.0" dependencies = [ "chrono", - "craft-quit", "craft-update", "eframe", "egui", @@ -4469,6 +4490,8 @@ dependencies = [ "block2 0.6.2", "bytemuck", "chrono", + "craft-keymap", + "craft-menu", "craft-quit", "craft-ui", "craft-update", @@ -5071,7 +5094,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5592,7 +5615,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5662,7 +5685,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6546,10 +6569,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7661,7 +7684,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4d1d5e5f6c..8eb38b29af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,9 +13,12 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } +# One menu definition for the native macOS menu bar and the in-window bar elsewhere. +craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false } # Shared system spooler; document composition remains in pdfcraft-print. craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Whether a path stored in a document may be opened (GoToR link targets). diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 15ec5d6f68..966af354d1 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -29,9 +29,6 @@ chrono = { workspace = true } # crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids) # behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" -# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the -# window to close instead of winit's, which ends the process without asking. -craft-quit = { workspace = true, features = ["menu"] } [target.'cfg(target_os = "linux")'.dependencies] # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index ab6ebf830c..ee8ae152cc 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -429,10 +429,11 @@ fn app_creator<'a>( #[cfg(target_os = "macos")] { app.os_events = Some(apple_events.connect(&cc.egui_ctx)); - // winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This - // menu's Quit closes the window instead, so `guard_quit` asks, as for the close button. - if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") { - log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}"); + // The native menu bar. Its Quit (⌘Q) closes the window rather than ending the process as + // winit's does (#1136), so `guard_quit` asks about unsaved changes, as for the close + // button. + if !app.install_native_menu(&cc.egui_ctx) { + log::warn!("couldn't install the macOS menu bar; the menus stay in the window"); } } if let Some(server) = instance { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5279f56248..804b5ea22a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,6 +11,12 @@ workspace = true [dependencies] craft-ui = { workspace = true } +# The menus: native on macOS, an egui bar elsewhere, from one definition (menus.rs). +craft-menu = { workspace = true } +craft-keymap = { workspace = true } +# Quit from the menus asks the window to close, so the unsaved-changes check runs; on macOS a window +# close also quits through `terminate:` (the Touch Bar crash, #1136). +craft-quit = { workspace = true, features = ["egui"] } pdfcraft-fonts = { workspace = true } pdfcraft-engine = { workspace = true } pdfcraft-cos = { workspace = true } @@ -55,9 +61,6 @@ pollster = "1.0" fontconfig-parser = "0.5.8" [target.'cfg(target_os = "macos")'.dependencies] -# macOS: a window close quits through `terminate:` (the Touch Bar crash, #1136); elsewhere a no-op. -# The egui feature binds `on_close_request` to the app's egui Context, so it stays macOS-only. -craft-quit = { workspace = true, features = ["egui"] } # Safe AppKit calls for drag coordinates and repairing an existing per-file PDFCraft binding. # The Objective-C wrappers and block support are already in the tree through arboard and rfd. objc2 = "0.6" diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b977112460..b81ac2d58c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -311,25 +311,6 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } -/// Both theme entry points use the same choices and command path. -fn theme_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { - for (preference, command, label) in [ - (ThemePreference::System, "view.theme.system", "Use system setting"), - (ThemePreference::Light, "view.theme.light", "Light gray"), - (ThemePreference::Dark, "view.theme.dark", "Dark gray"), - ] { - if ui.add(widgets::radio(app.theme_preference == preference, tl!(label))).clicked() { - app.execute(command); - ui.close(); - } - } - ui.separator(); - if ui.button(tl!("Appearance settings…")).clicked() { - app.dialog = Some(Dialog::Preferences); - ui.close(); - } -} - /// The right-click menu on the title bar's theme button. fn appearance_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if ui.button(tl!("Appearance settings…")).clicked() { @@ -497,10 +478,13 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .show(ui, |ui| { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - main_menu(app, ui); - ui.add_space(6.0); - ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); - ui.add_space(10.0); + // The menus, unless the native macOS menu bar has them. + if app.native_menu.is_none() { + app.menu_bar_ui(ui); + ui.add_space(6.0); + ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); + ui.add_space(10.0); + } // Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool // in use. Read mode (the panes hidden) stays a View command and a default workspace. for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] { @@ -540,103 +524,6 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } -/// Keep menus usable when their contents are taller than the window. -pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { - let height = (ui.ctx().content_rect().height() - 24.0).max(40.0); - egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add); -} - -fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu")); - egui::Popup::menu(&resp).show(|ui| { - scroll_menu(ui, |ui| { - ui.set_min_width(230.0); - ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File"))); - ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit"))); - ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages"))); - ui.menu_button(tl!("View"), |ui| { - scroll_menu(ui, |ui| { - if let Some(i) = app.active { - let v = &mut app.views[i]; - let ctx = ui.ctx().clone(); - let key = |s| crate::commands::shortcut_label(&ctx, s); - use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT}; - ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small()); - if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() { - v.set_zoom(1.0); - } - if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() { - v.fit = Fit::Page; - } - if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() { - v.fit = Fit::Width; - } - if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() { - v.fit = Fit::Height; - v.goto = Some((v.current, 0.0)); - } - if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() { - ui.close(); - app.execute("view.fit_visible"); - return; - } - if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() { - v.zoom_step(true); - } - if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() { - v.zoom_step(false); - } - if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() { - v.rotate_view(true); - } - if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() { - v.rotate_view(false); - } - ui.separator(); - ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small()); - if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() { - v.view_history(false); - } - if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() { - v.view_history(true); - } - ui.separator(); - if let Some(id) = page_display_menu(ui, &app.views[i]) { - app.execute(id); - } - ui.separator(); - } - crate::commands::registry_menu(app, ui, "View"); - ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui)); - ui.menu_button(tl!("Side panels"), |ui| { - scroll_menu(ui, |ui| { - for (p, label) in [ - (RightPanel::Comments, tl!("Comments")), - (RightPanel::Bookmarks, tl!("Bookmarks")), - (RightPanel::Pages, tl!("Pages")), - (RightPanel::Fields, tl!("Fields")), - (RightPanel::Layers, tl!("Layers")), - (RightPanel::Attachments, tl!("Attachments")), - (RightPanel::Signatures, tl!("Signatures")), - (RightPanel::Accessibility, tl!("Accessibility Checker")), - (RightPanel::Tags, tl!("Tags")), - (RightPanel::Search, tl!("Search")), - (RightPanel::Compare, tl!("Compare")), - ] { - if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() { - app.choose_right_panel(Some(p)); - } - } - }) - }); - }) - }); - ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help"))); - }) - }); -} - /// Draw the right tool rail with page navigation, history, zoom, rotation and page-display controls for the /// active view. pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { @@ -822,7 +709,7 @@ fn page_position(current: usize, count: usize, label: &str) -> String { if label.is_empty() || label == number || current >= count { count.to_string() } else { format!("{number}/{count}") } } -/// View ▸ Page display and the rail's Page display menu: the layouts and the cover page. +/// The rail's Page display menu: the layouts and the cover page (also View ▸ Page display). /// Returns the command a click asks for, for the caller to run. fn page_display_menu(ui: &mut egui::Ui, view: &DocView) -> Option<&'static str> { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index a27a25ff82..26fad6f77c 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -10,7 +10,6 @@ use pdfcraft_engine::commands::{self, COMMANDS, CommandSpec, Shortcut}; use crate::{ Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, SaveTarget, theme::{ThemeKind, ThemePreference}, - widgets, }; /// Keys the document view handles itself (`canvas::shortcuts`), for the View menu, tooltips and @@ -724,11 +723,6 @@ impl PdfCraftApp { // Buttons also own keyboard focus for Tab/Enter activation, but they must not // suppress document shortcuts. Text editors retain their own undo/redo handling. let typing = ctx.text_edit_focused(); - // A form field's editor is open on the page: its text isn't in the document until - // committed. (Not egui's keyboard focus: an Escape in this frame has already cleared that, - // while the field has yet to see the Escape and discard its draft.) - let active = self.active_ids(); - let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some()); let mut specs: Vec<&CommandSpec> = COMMANDS.iter().filter(|c| c.shortcut.is_some()).collect(); specs.sort_by_key(|c| std::cmp::Reverse(c.shortcut.map(|s| s.modifier_count()).unwrap_or(0))); for spec in specs { @@ -752,23 +746,29 @@ impl PdfCraftApp { m |= Modifiers::CTRL; } if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(m, key))) { - if form_typing { - // A form field has the keyboard: let it take this frame's typing (and - // Escape) first, so ⌘S saves what's on screen (#166). Runs next frame, for - // this document only. - self.deferred_commands.push((spec.id, active.map(|(_, id)| id))); - ctx.request_repaint(); - } else if spec.id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") { - // macOS ⌘W closes the last window when no document tabs remain (#869). - // Route through the normal viewport-close path (and its quit guard), - // not the disabled document-only `file.close` command. - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } else { - self.execute(spec.id); - } + self.dispatch_command(ctx, spec.id); } } } + + /// Run a registry command from its shortcut or a menu, which share this path. + pub(crate) fn dispatch_command(&mut self, ctx: &egui::Context, id: &'static str) { + let active = self.active_ids(); + let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some()); + if form_typing { + // A form field has the keyboard: let it take this frame's typing (and Escape) first, + // so ⌘S saves what's on screen (#166). Runs next frame, for this document only. + self.deferred_commands.push((id, active.map(|(_, id)| id))); + ctx.request_repaint(); + } else if id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") { + // macOS ⌘W closes the last window when no document tabs remain (#869). Route through + // the normal viewport-close path (and its quit guard), not the disabled document-only + // `file.close` command. + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else { + self.execute(id); + } + } } /// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From @@ -789,57 +789,6 @@ fn tab_after(current: Option, count: usize, forward: bool) -> Option = None; - let mut clear = false; - ui.menu_button(label, |ui| { - crate::chrome::scroll_menu(ui, |ui| { - for r in &app.recent { - if ui.button(&r.name).on_hover_text(&r.path).clicked() { - open = Some(r.path.clone()); - ui.close(); - } - } - ui.separator(); - if ui.button(tl!("Clear Recent Files")).clicked() { - clear = true; - ui.close(); - } - }) - }); - if let Some(p) = open { - app.open_recent(&p); - ui.close(); - } - if clear { - app.execute("file.clear_recent"); - ui.close(); - } - continue; - } - let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(COPY)).map(|s| shortcut_label(ui.ctx(), s)).unwrap_or_default(); - let enabled = app.command_enabled(spec); - let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut)); - if resp.clicked() { - app.execute(spec.id); - ui.close(); - } - } - let _ = widgets::menu_item; -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79f46bee7b..3446bcd26e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,7 @@ mod js_ui; mod macos_association; mod marks_ui; mod measure_ui; +mod menus; mod ocr_ui; mod optimize_ui; mod search_ui; @@ -927,6 +928,10 @@ pub struct PdfCraftApp { /// Shortcuts pressed while a text field had the keyboard, run on the next frame (see /// `registry_shortcuts`). deferred_commands: Vec<(&'static str, Option)>, + /// The native menu bar (macOS), while it is installed; the in-window bar shows otherwise. + pub(crate) native_menu: Option, + /// Input a menu click stands for (⌘C, Delete…), added to the next frame's input in `logic`. + pub(crate) menu_input: Vec, /// The dialog seen at the last check, and a counter bumped whenever it changes (see /// [`Self::dialog_epoch`]). dialog_seen: Option, @@ -1261,6 +1266,8 @@ impl PdfCraftApp { last_autosave: None, allow_quit: false, deferred_commands: Vec::new(), + native_menu: None, + menu_input: Vec::new(), dialog_seen: None, dialog_epoch: 0, ctx: None, @@ -3291,7 +3298,12 @@ impl PdfCraftApp { { return; } - self.registry_shortcuts(ctx); + // The in-window menu bar holds the keys while one of its menus is open or it has the + // keyboard (Alt, F10); a command shortcut leaves it and runs. + craft_menu::bar::leave_for_shortcut(ctx); + if !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx) { + self.registry_shortcuts(ctx); + } // (Esc closes a Combine file shown large first.) if self.full_screen && self.combine_tab.preview.is_none() && ctx.input(|i| i.key_pressed(Key::Escape)) { self.set_full_screen(ctx, false); @@ -3499,6 +3511,10 @@ impl PdfCraftApp { } // Before egui reads this frame's wheel input. self.scroll_speed.apply(ctx, raw); + // The native menu's key equivalents. + if let Some(native) = self.native_menu.as_mut() { + native.raw_input(raw); + } } } @@ -3670,6 +3686,12 @@ impl eframe::App for PdfCraftApp { self.poll_print(); // Shortcuts deferred last frame: the text field has taken that frame's typing since. let deferred = std::mem::take(&mut self.deferred_commands); + self.native_menu_clicks(ctx); + // The keys a menu click stands for (⌘C, Delete…), before anything reads this frame's keys. + if !self.menu_input.is_empty() { + let events = std::mem::take(&mut self.menu_input); + ctx.input_mut(|i| i.events.extend(events)); + } self.shortcuts(ctx); // Scrolling and group-drag previews are transient: never resume after changing tabs, // opening a modal/palette, or returning to a window that lost focus. @@ -3700,6 +3722,7 @@ impl eframe::App for PdfCraftApp { self.process_picked(); // Last, after everything that can open or answer the "save changes?" question. self.attend_save_prompt(ctx); + self.sync_native_menu(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs new file mode 100644 index 0000000000..786840b650 --- /dev/null +++ b/crates/ui-egui/src/menus.rs @@ -0,0 +1,791 @@ +//! The menus: one definition ([`PdfCraftApp::menu_bar`]) for the native macOS menu bar and the +//! in-window bar on Windows, Linux and the web, laid out for each platform by craft-menu. +//! +//! Items run registry commands (`pdfcraft_engine::commands`) by id, with the registry's label and +//! shortcut, so a menu click and its shortcut run the same command. A few items have menu-only +//! ids: the zoom keys the document view handles itself, the side panel choices, the clipboard +//! keys, the recent files and the open documents. [`PdfCraftApp::run_menu_command`] runs every +//! one of them, once per click, whichever bar it came from. + +use craft_keymap::{Key, Mods}; +use craft_menu::{ + Builtin, Chord, CommandRef, Document, Gate, Item, ItemRole, ItemState, Labeled, LayoutOptions, Menu, MenuBar, MenuRole, Node, Target, +}; +use pdfcraft_engine::catalog::{Availability, TOOL_GROUPS}; +use pdfcraft_engine::commands::{self, Shortcut}; + +use crate::canvas::{Fit, PageLayout}; +use crate::theme::ThemePreference; +use crate::{PdfCraftApp, RightPanel}; + +/// The app's name in the menus the layouts write (About, Hide, Quit). +pub(crate) const APP_NAME: &str = "PdfCraft"; + +/// The top bar's width after the menus: the mode tabs, the search box and the buttons. +const ROW_AFTER_MENUS: f32 = 720.0; +/// The narrowest the menu bar gets: one "Menu" title. +const MIN_MENUS_WIDTH: f32 = 80.0; + +/// Menu-only commands. The registry's commands keep their own ids. +pub(crate) const CUT: &str = "edit.cut"; +pub(crate) const PASTE: &str = "edit.paste"; +pub(crate) const DELETE: &str = "edit.delete"; +pub(crate) const SELECT_ALL: &str = "edit.select_all"; +pub(crate) const NO_RECENT: &str = "file.no_recent"; +/// An open document in the Window menu, `{"index": n}` in the tab strip. +pub(crate) const DOCUMENT: &str = "window.document"; +/// A side panel, `{"panel": name}` ([`PANELS`]). +pub(crate) const PANEL: &str = "view.panel"; +const ACTUAL_SIZE: &str = "view.zoom.actual_size"; +const PAGE_LEVEL: &str = "view.zoom.page_level"; +const FIT_WIDTH: &str = "view.zoom.fit_width"; +const FIT_HEIGHT: &str = "view.zoom.fit_height"; +const ZOOM_IN: &str = "view.zoom.in"; +const ZOOM_OUT: &str = "view.zoom.out"; +const ROTATE_CW: &str = "view.rotate.cw"; +const ROTATE_CCW: &str = "view.rotate.ccw"; + +/// The menu-only items: id, English label and the shortcut shown (the keys stay with the view). +const EXTRA: &[(&str, &str, Option)] = &[ + (CUT, "Cut", Some(Shortcut::cmd("X"))), + (PASTE, "Paste", Some(Shortcut::cmd("V"))), + (DELETE, "Delete", Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "Delete" })), + (SELECT_ALL, "Select all", Some(crate::commands::SELECT_ALL)), + (NO_RECENT, "No recent files", None), + (ACTUAL_SIZE, "Actual size", Some(crate::commands::ACTUAL_SIZE)), + (PAGE_LEVEL, "Zoom to page level", Some(crate::commands::PAGE_LEVEL)), + (FIT_WIDTH, "Fit to width", Some(crate::commands::FIT_WIDTH)), + (FIT_HEIGHT, "Fit to height", None), + (ZOOM_IN, "Zoom in", Some(crate::commands::ZOOM_IN)), + (ZOOM_OUT, "Zoom out", Some(crate::commands::ZOOM_OUT)), + (ROTATE_CW, "Rotate view clockwise", Some(crate::commands::ROTATE_CW)), + (ROTATE_CCW, "Rotate view counterclockwise", Some(crate::commands::ROTATE_CCW)), +]; + +/// View ▸ Side panels: the panel, its id in [`PANEL`] commands and its English label. +const PANELS: &[(RightPanel, &str, &str)] = &[ + (RightPanel::Comments, "comments", "Comments"), + (RightPanel::Bookmarks, "bookmarks", "Bookmarks"), + (RightPanel::Pages, "pages", "Pages"), + (RightPanel::Fields, "fields", "Fields"), + (RightPanel::Layers, "layers", "Layers"), + (RightPanel::Attachments, "attachments", "Attachments"), + (RightPanel::Signatures, "signatures", "Signatures"), + (RightPanel::Accessibility, "accessibility", "Accessibility Checker"), + (RightPanel::Tags, "tags", "Tags"), + (RightPanel::Search, "search", "Search"), + (RightPanel::Compare, "compare", "Compare"), +]; + +/// The English titles of the menus and submenus, for the bar's mnemonics in languages whose +/// labels have no ASCII letter. +const TITLES: &[&str] = &[ + "File", + "Edit", + "View", + "Pages", + "Sign", + "Window", + "Help", + "Open Recent", + "Create", + "Export", + "Protect", + "Zoom", + "Rotate view", + "Page navigation", + "Page display", + "Side panels", + "Display theme", + "Tools", + "Fill & Sign", +]; + +/// Separator. +const SEP: &str = "---"; + +const FILE: &[&str] = &[ + "create.blank", + "file.open", + "file.open_recent", + "file.pin_folder", + SEP, + "page.combine", + SEP, + "file.save", + "file.save_as", + "file.revert", + SEP, + "file.close", + "file.close_all", + SEP, + "optimize.reduce", + "optimize.advanced", + SEP, + "file.properties", + "file.word_count", + SEP, + "print.dialog", +]; +const CREATE: &[&str] = &["create.file", "create.multiple", "create.multiple_separate", "create.images", "create.clipboard"]; +const EXPORT: &[&str] = &["export.docx", "export.pptx", "export.rtf", "export.html", "export.text", SEP, "export.image", "export.all_images"]; +const PROTECT: &[&str] = &["protect.password", "protect.remove", "protect.unlock", SEP, "protect.properties"]; +const EDIT: &[&str] = &[ + "edit.undo", + "edit.redo", + SEP, + CUT, + "edit.copy", + PASTE, + DELETE, + SEP, + SELECT_ALL, + SEP, + "edit.find", + "edit.advanced_search", + SEP, + "edit.column_select", + "edit.snapshot", + SEP, + "edit.attach", + "form.clear", + SEP, + "app.preferences", +]; +const ZOOM: &[&str] = &[ACTUAL_SIZE, PAGE_LEVEL, FIT_WIDTH, FIT_HEIGHT, "view.fit_visible", SEP, ZOOM_IN, ZOOM_OUT, SEP, "view.marquee_zoom"]; +const NAVIGATION: &[&str] = &["view.focus_page_input", SEP, "view.previous_view", "view.next_view"]; +const DISPLAY: &[&str] = &[ + "view.layout.single", + "view.layout.two_up", + SEP, + "view.continuous", + "view.page_gaps", + "view.layout.cover", + SEP, + "view.fit_width_scrolling", + "view.fit_one_page", +]; +const THEMES: &[&str] = &["view.theme.system", "view.theme.light", "view.theme.dark"]; +const PANES: &[&str] = &["view.side_panel", "view.tools_pane"]; +const PANE_COMMANDS: &[&str] = &["comment.list", "form.fields", "ui.dock.reset"]; +const MODES: &[&str] = &["view.full_screen", "view.read_mode"]; +const PAGES: &[&str] = &[ + "page.organize", + SEP, + "page.insert_blank", + "page.insert", + "page.replace", + "page.extract", + "page.split", + "page.duplicate", + "page.delete", + SEP, + "page.rotate", + "page.rotate_ccw", + "page.rotate_dialog", + "page.reverse", + SEP, + "page.crop", + "page.boxes", + "page.number", + SEP, + "bookmark.add", + "bookmark.from_structure", +]; +/// Sign ▸ Fill & Sign, with the short labels of the Fill & Sign toolbar. +const FILL: &[(&str, &str)] = &[ + ("sign.fill.text", "Add text"), + ("sign.fill.check", "Checkmark"), + ("sign.fill.cross", "Cross"), + ("sign.fill.dot", "Dot"), + ("sign.fill.line", "Line"), + ("sign.fill.date", "Date"), + ("sign.fill.signature", "Signature"), + ("sign.fill.initials", "Initials"), +]; +const SIGN: &[&str] = &[ + SEP, + "sign.fill.signature.change", + "sign.fill.initials.change", + "sign.fill.signature.remove", + "sign.fill.initials.remove", + SEP, + "sign.digital", + "sign.certify", + "sign.certify_invisible", + "form.add.signature", + SEP, + "sign.validate", + "sign.panel", +]; +const WINDOW: &[&str] = &["view.next_tab", "view.previous_tab"]; +const HELP: &[&str] = + &["help.shortcuts", SEP, "help.app_page", "help.github", "help.website", "help.discord", SEP, "help.check_updates", SEP, "help.about"]; + +/// A registry shortcut as a craft-keymap chord: `command` is ⌘ (Ctrl elsewhere) and `mac_ctrl` +/// the Control key on macOS. `None` for a key craft-keymap doesn't name. +pub(crate) fn chord(s: Shortcut) -> Option { + let key = Key::parse(s.key)?; + Some(Chord::new(Mods { cmd: s.command, ctrl: s.mac_ctrl, alt: false, shift: s.shift }, key)) +} + +fn extra(id: &str) -> Option<&'static (&'static str, &'static str, Option)> { + EXTRA.iter().find(|(e, ..)| *e == id) +} + +/// The English label of an item's command, for the bar's mnemonics. +fn english_label(command: &CommandRef) -> Option { + if let Some(spec) = commands::command(&command.id) { + return Some(spec.label.to_owned()); + } + if let Some((_, label, _)) = extra(&command.id) { + return Some((*label).to_owned()); + } + FILL.iter().find(|(id, _)| *id == command.id).map(|(_, label)| (*label).to_owned()) +} + +fn english(labeled: Labeled<'_>) -> Option { + match labeled { + Labeled::Command(command) => english_label(command), + Labeled::Title(shown) => TITLES.iter().find(|t| tl!(t) == shown).map(|t| (*t).to_owned()), + } +} + +fn translate(english: &'static str) -> String { + tl!(english).to_owned() +} + +/// The layouts' own labels (About, Quit, Window…) through the app's translations. +pub(crate) fn layout_options() -> LayoutOptions<'static> { + LayoutOptions { label: &translate, ..LayoutOptions::new(APP_NAME) } +} + +impl PdfCraftApp { + /// The app's menus for this frame, before the platform layout: every item's label, shortcut, + /// enabled and checked state as the app is now. A modal dialog doesn't grey anything here; + /// that is the [`Gate`]'s job ([`PdfCraftApp::menu_gate`]). + pub fn menu_bar(&self, ctx: &egui::Context) -> MenuBar { + let mut used: Vec<&str> = Vec::new(); + let list = |ids: &[&'static str], used: &mut Vec<&'static str>| -> Vec { + ids.iter() + .map(|&id| { + if id == SEP { + return Node::Separator; + } + used.push(id); + self.menu_item(id, None) + }) + .collect() + }; + let file = { + let mut nodes = Vec::new(); + for node in list(FILE, &mut used) { + // Open Recent is the live recent list; Create, Export and Protect follow Combine, + // Optimize and Close. + let recent = matches!(&node, Node::Item(it) if it.command.id == "file.open_recent"); + if recent { + nodes.push(self.recent_menu()); + continue; + } + let after = match &node { + Node::Item(it) => it.command.id.clone(), + _ => String::new(), + }; + nodes.push(node); + match after.as_str() { + "page.combine" => nodes.push(Node::submenu(tl!("Create"), list(CREATE, &mut used))), + "optimize.advanced" => { + nodes.push(Node::submenu(tl!("Export"), list(EXPORT, &mut used))); + nodes.push(Node::Separator); + nodes.push(Node::submenu(tl!("Protect"), list(PROTECT, &mut used))); + } + _ => {} + } + } + nodes + }; + // Edit ▸ Pages follows Select All. + let mut edit = Vec::new(); + for node in list(EDIT, &mut used) { + let select_all = matches!(&node, Node::Item(it) if it.command.id == SELECT_ALL); + edit.push(node); + if select_all { + edit.push(Node::submenu(tl!("Pages"), list(PAGES, &mut used))); + } + } + let mut sign = vec![Node::submenu( + tl!("Fill & Sign"), + FILL.iter() + .map(|&(id, label)| { + used.push(id); + self.menu_item(id, Some(tl!(label))) + }) + .collect(), + )]; + sign.extend(list(SIGN, &mut used)); + let window = list(WINDOW, &mut used); + let help = list(HELP, &mut used); + let mut view = vec![ + Node::submenu(tl!("Zoom"), list(ZOOM, &mut used)), + Node::submenu(tl!("Rotate view"), list(&[ROTATE_CW, ROTATE_CCW], &mut used)), + Node::submenu(tl!("Page navigation"), list(NAVIGATION, &mut used)), + Node::submenu(tl!("Page display"), list(DISPLAY, &mut used)), + Node::Separator, + ]; + view.extend(list(PANES, &mut used)); + view.push(Node::submenu( + tl!("Side panels"), + PANELS + .iter() + .map(|(_, name, label)| { + Item::new(CommandRef::with_params(PANEL, serde_json::json!({ "panel": name })), tl!(label)).radio(false).into() + }) + .collect(), + )); + view.extend(list(PANE_COMMANDS, &mut used)); + view.push(Node::Separator); + view.extend(list(MODES, &mut used)); + view.push(Node::submenu(tl!("Display theme"), list(THEMES, &mut used))); + view.push(Node::Separator); + view.push(Node::submenu(tl!("Tools"), self.tools_menu(&used))); + view.extend(list(&["view.palette"], &mut used)); + let mut bar = MenuBar { + menus: vec![ + Menu::new(tl!("File"), craft_menu::tidy(file)).role(MenuRole::File), + Menu::new(tl!("Edit"), craft_menu::tidy(edit)).role(MenuRole::Edit), + Menu::new(tl!("View"), craft_menu::tidy(view)).role(MenuRole::View), + Menu::new(tl!("Sign"), craft_menu::tidy(sign)), + Menu::new(tl!("Window"), window).role(MenuRole::Window), + Menu::new(tl!("Help"), craft_menu::tidy(help)).role(MenuRole::Help), + ], + documents: self.menu_documents(), + }; + bar.tag_item(&CommandRef::new("help.about"), ItemRole::About); + bar.tag_item(&CommandRef::new("app.preferences"), ItemRole::Settings); + craft_menu::apply_state(&mut bar, |command| self.menu_state(ctx, command)); + bar + } + + /// One item: a registry command with its live label (Undo names the edit) and shortcut, or a + /// menu-only one. Radio and check items get their kind here, their state from + /// [`PdfCraftApp::menu_state`]. + fn menu_item(&self, id: &'static str, label: Option<&str>) -> Node { + let (english, shortcut) = match commands::command(id) { + Some(spec) => { + let current = commands::current_label(spec, &self.session, self.active_ids().map(|(_, doc)| doc)); + (crate::i18n::menu_label(id, ¤t), spec.shortcut.or((id == "edit.copy").then_some(crate::commands::COPY))) + } + None => match extra(id) { + Some((_, label, shortcut)) => (tl!(label).to_owned(), *shortcut), + None => (id.to_owned(), None), + }, + }; + let mut item = Item::new(id, label.map_or(english, str::to_owned)); + if let Some(c) = shortcut.and_then(chord) { + item = item.shortcut(c); + } + if matches!(id, "view.layout.single" | "view.layout.two_up") || THEMES.contains(&id) { + item = item.radio(false); + } + item.into() + } + + /// File ▸ Open Recent: the recent files, newest first, then Clear Recent Files. + fn recent_menu(&self) -> Node { + let mut items: Vec = self + .recent + .iter() + .enumerate() + .map(|(index, r)| Item::new(CommandRef::with_params("file.open_recent", serde_json::json!({ "index": index })), r.name.clone()).into()) + .collect(); + if items.is_empty() { + items.push(self.menu_item(NO_RECENT, None)); + } else { + items.push(Node::Separator); + items.push(self.menu_item("file.clear_recent", Some(tl!("Clear Recent Files")))); + } + let label = commands::command("file.open_recent").map_or("Open Recent", |c| c.label); + Node::submenu(crate::i18n::menu_label("file.open_recent", label), items) + } + + /// View ▸ Tools: the All tools groups, each a submenu of its ready tools that no other menu + /// lists, sections apart. + fn tools_menu(&self, used: &[&str]) -> Vec { + TOOL_GROUPS + .iter() + .filter(|g| g.availability == Availability::Ready) + .map(|group| { + let mut nodes = Vec::new(); + let mut seen: Vec<&str> = Vec::new(); + for section in group.sections { + nodes.push(Node::Separator); + for tool in section.items.iter().filter(|t| t.availability == Availability::Ready) { + let actions: &[&'static str] = if tool.actions.is_empty() { std::slice::from_ref(&tool.command) } else { tool.actions }; + for &id in actions { + if commands::command(id).is_none() || used.contains(&id) || seen.contains(&id) { + continue; + } + seen.push(id); + let label = if tool.actions.is_empty() { Some(tl!(tool.label)) } else { None }; + nodes.push(self.menu_item(id, label)); + } + } + } + Node::submenu(tl!(group.label), nodes) + }) + .collect() + } + + /// The open documents, in tab order, for the Window menu. + fn menu_documents(&self) -> Vec { + (0..self.views.len()) + .filter_map(|index| { + let name = crate::canvas::tab_label(self, index)?; + Some(Document::new(CommandRef::with_params(DOCUMENT, serde_json::json!({ "index": index })), name).active(self.active == Some(index))) + }) + .collect() + } + + /// What may run from the menus now: a dialog, a prompt or the command palette holds the + /// window. Edit's text items stay with a focused text field (craft-menu's gate). + pub fn menu_gate(&self, ctx: &egui::Context) -> Gate { + Gate { modal: self.modal_open() || self.palette_open, recording_shortcut: false, text_editing: ctx.text_edit_focused() } + } + + /// Whether a menu item's command can run now, and its checked or selected state. + pub fn menu_state(&self, ctx: &egui::Context, command: &CommandRef) -> ItemState { + let view = self.active.and_then(|i| self.views.get(i)); + let organize = view.is_some_and(|v| v.organize); + let has_doc = view.is_some(); + let enabled_id = |id: &str| commands::command(id).is_some_and(|s| self.command_enabled(s)); + let index = param_index(command); + let text = ctx.text_edit_focused() && matches!(command.id.as_str(), "edit.undo" | "edit.redo" | "edit.copy" | CUT | PASTE | SELECT_ALL); + let enabled = text + || match command.id.as_str() { + NO_RECENT => false, + "file.open_recent" => index.is_none_or(|i| i < self.recent.len()), + "file.clear_recent" => !self.recent.is_empty(), + DOCUMENT => index.is_some_and(|i| i < self.views.len()), + // macOS ⌘W closes the window when no document is open (#869). + "file.close" if !has_doc && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") => true, + "edit.copy" if organize => enabled_id("page.copy"), + "edit.copy" if view.is_some_and(|v| v.picked_image.is_some()) => { + self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_copying()) + } + CUT => organize && enabled_id("page.cut"), + PASTE => organize && self.page_clipboard.is_some() && enabled_id("page.paste"), + SELECT_ALL | DELETE => has_doc, + ACTUAL_SIZE | PAGE_LEVEL | FIT_WIDTH | FIT_HEIGHT | ZOOM_IN | ZOOM_OUT | ROTATE_CW | ROTATE_CCW => has_doc, + PANEL => panel(command).is_some(), + id => enabled_id(id), + }; + let checked = match command.id.as_str() { + "view.layout.single" => Some(view.is_some_and(|v| v.layout == PageLayout::Single)), + "view.layout.two_up" => Some(view.is_some_and(|v| v.layout == PageLayout::TwoUp)), + "view.continuous" => Some(view.is_some_and(|v| v.continuous())), + "view.page_gaps" => Some(view.is_some_and(|v| v.show_page_gaps)), + "view.layout.cover" => Some(view.is_some_and(|v| v.cover)), + "view.full_screen" => Some(self.full_screen), + "view.read_mode" => Some(self.mode == crate::Mode::Read), + "view.side_panel" => Some(self.right.is_some()), + "view.tools_pane" => Some(self.left_open), + "view.theme.system" => Some(self.theme_preference == ThemePreference::System), + "view.theme.light" => Some(self.theme_preference == ThemePreference::Light), + "view.theme.dark" => Some(self.theme_preference == ThemePreference::Dark), + PANEL => Some(panel(command).is_some_and(|p| self.right == Some(p))), + _ => None, + }; + ItemState { enabled, checked } + } + + /// Run a command chosen in either menu bar. Registry commands go through + /// [`PdfCraftApp::dispatch_command`], as their shortcuts do; the clipboard items act as + /// their keys would (⌘C is a Copy event, Delete a key press), so every place that handles the + /// key handles the click. A command that can't run now (a dialog opened since the menu was + /// drawn) does nothing. + pub fn run_menu_command(&mut self, ctx: &egui::Context, command: &CommandRef) { + // Both menu bars run Quit and Minimize themselves; Hide and Hide Others are AppKit's. + if Builtin::of(command).is_some() { + return; + } + if !self.menu_gate(ctx).allows_command(command) || !self.menu_state(ctx, command).enabled { + return; + } + let index = param_index(command); + let key = |key: egui::Key, modifiers: egui::Modifiers| { + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }) + }; + match command.id.as_str() { + "file.open_recent" if index.is_some() => { + if let Some(path) = index.and_then(|i| self.recent.get(i)).map(|r| r.path.clone()) { + self.open_recent(&path); + } + } + DOCUMENT => { + if let Some(i) = index.filter(|&i| i < self.views.len()) { + self.active = Some(i); + } + } + "edit.copy" => self.menu_input.push(egui::Event::Copy), + CUT => self.menu_input.push(egui::Event::Cut), + PASTE => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), + SELECT_ALL => self.menu_input.extend(key(egui::Key::A, egui::Modifiers::COMMAND)), + DELETE => self.menu_input.extend(key(egui::Key::Delete, egui::Modifiers::NONE)), + PANEL => self.choose_right_panel(panel(command)), + ACTUAL_SIZE | PAGE_LEVEL | FIT_WIDTH | FIT_HEIGHT | ZOOM_IN | ZOOM_OUT | ROTATE_CW | ROTATE_CCW => { + let Some(v) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; + match command.id.as_str() { + ACTUAL_SIZE => v.set_zoom(1.0), + PAGE_LEVEL => v.fit = Fit::Page, + FIT_WIDTH => v.fit = Fit::Width, + FIT_HEIGHT => { + v.fit = Fit::Height; + v.goto = Some((v.current, 0.0)); + } + ZOOM_IN => v.zoom_step(true), + ZOOM_OUT => v.zoom_step(false), + ROTATE_CW => v.rotate_view(true), + _ => v.rotate_view(false), + } + } + id => { + if let Some(spec) = commands::command(id) { + self.dispatch_command(ctx, spec.id); + } + } + } + ctx.request_repaint(); + } + + /// The menu bar drawn in the window (Windows, Linux, the web, and macOS when the native one + /// couldn't be installed), where the top bar starts. Runs the commands chosen. + pub(crate) fn menu_bar_ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let bar = craft_menu::layout(&self.menu_bar(&ctx), Target::current(), &layout_options()).bar; + let gate = self.menu_gate(&ctx); + // The mode tabs, search and buttons follow the bar in the same row: it takes its titles' + // width, less when the row is short, where the menus that don't fit move into its "⋯". + let fit = (ui.available_width() - ROW_AFTER_MENUS).max(MIN_MENUS_WIDTH); + let chosen = craft_menu::MenuBarUi::new(&bar) + .gate(gate) + .label(&translate) + .english(&english) + .id(egui::Id::unique("pdfcraft-menu-bar")) + .shrink_to_fit(fit) + .show(ui); + for command in chosen { + self.run_menu_command(&ctx, &command); + } + } + + /// Install the native menu bar (macOS), once, from the eframe app creator. `false` when + /// AppKit refused it: the in-window bar stays. + #[cfg(target_os = "macos")] + pub fn install_native_menu(&mut self, ctx: &egui::Context) -> bool { + crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.language)); + let bar = craft_menu::mac_layout(&self.menu_bar(ctx), &layout_options()).bar; + // The Window menu lists the open documents itself, so AppKit's window list stays off. + self.native_menu = craft_menu::mac::install_with(ctx, &bar, craft_menu::mac::MacOptions::default().window_list(false)); + self.native_menu.is_some() + } + + /// The native menu's clicks since the last frame, run like in-window ones. + pub(crate) fn native_menu_clicks(&mut self, ctx: &egui::Context) { + let gate = self.menu_gate(ctx); + let Some(native) = self.native_menu.as_mut() else { return }; + native.set_gate(gate); + for command in native.take_clicks(ctx) { + self.run_menu_command(ctx, &command); + } + } + + /// Bring the native menu in step with the app, after this frame's changes. + pub(crate) fn sync_native_menu(&mut self, ctx: &egui::Context) { + if self.native_menu.is_none() { + return; + } + let layout = craft_menu::mac_layout(&self.menu_bar(ctx), &layout_options()); + let gate = self.menu_gate(ctx); + if let Some(native) = self.native_menu.as_mut() { + native.set_gate(gate); + for clash in native.sync(layout) { + log::info!("menu shortcut {:?} kept by {} over {} ({})", clash.shortcut, clash.command.id, clash.system, clash.resolution); + } + } + } +} + +/// A command's `{"index": n}` parameter. +fn param_index(command: &CommandRef) -> Option { + command.params.get("index").and_then(serde_json::Value::as_u64).and_then(|i| usize::try_from(i).ok()) +} + +/// The side panel a [`PANEL`] command names. +fn panel(command: &CommandRef) -> Option { + let name = command.params.get("panel").and_then(serde_json::Value::as_str)?; + PANELS.iter().find(|(_, n, _)| *n == name).map(|(p, ..)| *p) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use craft_menu::{Backend, Dispatch, Event, ItemKind, NativeMenu}; + + use super::*; + + const PDF: &[u8] = include_bytes!("../tests/data/form.pdf"); + + fn app(docs: &[&str]) -> (PdfCraftApp, egui::Context) { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + for name in docs { + app.open_bytes(name, None, PDF.to_vec()).unwrap(); + } + (app, egui::Context::default()) + } + + fn linux(app: &PdfCraftApp, ctx: &egui::Context) -> MenuBar { + craft_menu::layout(&app.menu_bar(ctx), Target::Linux, &layout_options()).bar + } + + fn item<'b>(bar: &'b MenuBar, command: &CommandRef) -> &'b Item { + bar.find(command).unwrap_or_else(|| panic!("{} is in the menus", command.key())) + } + + #[test] + fn every_menu_command_is_listed_once_with_its_shortcut() { + let (app, ctx) = app(&["a.pdf"]); + let bar = linux(&app, &ctx); + let items = bar.items(); + let mut keys: Vec = items.iter().map(|it| it.command.key()).collect(); + keys.sort(); + let count = keys.len(); + keys.dedup(); + assert_eq!(keys.len(), count, "no command is listed twice"); + for spec in commands::COMMANDS.iter().filter(|s| s.menu.is_some()) { + if spec.id == "file.open_recent" { + continue; // A submenu of the recent files. + } + let it = item(&bar, &CommandRef::new(spec.id)); + let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(crate::commands::COPY)); + assert_eq!(it.shortcut, shortcut.and_then(chord), "{} has its registry shortcut", spec.id); + } + // Every item runs a registry command or one of the menu's own. + for it in items { + let id = it.command.id.as_str(); + assert!( + commands::command(id).is_some() || extra(id).is_some() || [PANEL, DOCUMENT].contains(&id) || Builtin::of(&it.command).is_some(), + "{id} runs something" + ); + } + } + + #[test] + fn check_and_radio_items_follow_the_app() { + let (mut app, ctx) = app(&["a.pdf"]); + let kind = |app: &PdfCraftApp, id: &str| item(&linux(app, &ctx), &CommandRef::new(id)).kind; + assert_eq!(kind(&app, "view.layout.single"), ItemKind::Radio(true)); + assert_eq!(kind(&app, "view.layout.two_up"), ItemKind::Radio(false)); + let continuous = app.views[0].continuous(); + assert_eq!(kind(&app, "view.continuous"), ItemKind::Check(continuous)); + app.run_menu_command(&ctx, &CommandRef::new("view.layout.two_up")); + app.run_menu_command(&ctx, &CommandRef::new("view.continuous")); + assert_eq!(kind(&app, "view.layout.single"), ItemKind::Radio(false)); + assert_eq!(kind(&app, "view.layout.two_up"), ItemKind::Radio(true)); + assert_eq!(kind(&app, "view.continuous"), ItemKind::Check(!continuous)); + let left = app.left_open; + assert_eq!(kind(&app, "view.tools_pane"), ItemKind::Check(left)); + app.run_menu_command(&ctx, &CommandRef::new("view.tools_pane")); + assert_eq!(kind(&app, "view.tools_pane"), ItemKind::Check(!left)); + let bookmarks = CommandRef::with_params(PANEL, serde_json::json!({ "panel": "bookmarks" })); + app.run_menu_command(&ctx, &bookmarks); + assert_eq!(app.right, Some(RightPanel::Bookmarks)); + assert_eq!(item(&linux(&app, &ctx), &bookmarks).kind, ItemKind::Radio(true)); + assert_eq!(kind(&app, "view.side_panel"), ItemKind::Check(true)); + } + + #[test] + fn a_dialog_greys_document_commands_and_drops_their_clicks() { + let (mut app, ctx) = app(&["a.pdf"]); + let rotate = CommandRef::new("page.rotate"); + assert!(app.menu_gate(&ctx).allows_command(&rotate)); + app.dialog = Some(crate::Dialog::About); + assert!(!app.menu_gate(&ctx).allows_command(&rotate), "the gate greys the menus under a dialog"); + let doc = app.views[0].id; + let before = app.session.get(doc).unwrap().info.pages[0].rotation; + app.run_menu_command(&ctx, &rotate); + assert_eq!(app.session.get(doc).unwrap().info.pages[0].rotation, before, "a click that arrives anyway does nothing"); + // Quit still asks the window to close, which asks about unsaved changes. + assert!(app.menu_gate(&ctx).allows_command(&Builtin::Quit.command())); + } + + #[test] + fn recent_files_and_open_documents_are_listed_once_each() { + let (mut app, ctx) = app(&["a.pdf", "b.pdf"]); + app.recent = ["one.pdf", "two.pdf"].map(|n| crate::RecentFile { name: n.into(), path: format!("/nowhere/{n}"), pages: 1, size: 0 }).into(); + let bar = linux(&app, &ctx); + for (i, name) in ["one.pdf", "two.pdf"].into_iter().enumerate() { + assert_eq!(item(&bar, &CommandRef::with_params("file.open_recent", serde_json::json!({ "index": i }))).label, name); + } + let window = bar.menu(MenuRole::Window).unwrap(); + let documents: Vec<&str> = window + .children + .iter() + .filter_map(|n| match n { + Node::Item(it) if it.command.id == DOCUMENT => Some(it.label.as_str()), + _ => None, + }) + .collect(); + assert_eq!(documents, ["a.pdf", "b.pdf"], "each open document once, in tab order"); + app.run_menu_command(&ctx, &CommandRef::with_params(DOCUMENT, serde_json::json!({ "index": 0 }))); + assert_eq!(app.active, Some(0)); + // The macOS layout lists them once too. + let mac = craft_menu::mac_layout(&app.menu_bar(&ctx), &layout_options()).bar; + let listed = mac.items().iter().filter(|it| it.command.id == DOCUMENT).count(); + assert_eq!(listed, 2); + app.recent.clear(); + let bar = linux(&app, &ctx); + assert!(!item(&bar, &CommandRef::new(NO_RECENT)).enabled, "an empty list shows a greyed placeholder"); + assert!(bar.find(&CommandRef::new("file.clear_recent")).is_none()); + } + + /// A native backend that reports what the test queues, through the driver's dispatch as the + /// macOS backend does. + #[derive(Clone, Default)] + struct FakeMenu(Rc, Vec)>>); + + impl FakeMenu { + fn click(&self, command: &CommandRef) { + let mut state = self.0.borrow_mut(); + let event = state.0.as_ref().map(|d| d.chosen(command, None, false, None)); + state.1.extend(event); + } + } + + impl Backend for FakeMenu { + fn sync(&mut self, _bar: &MenuBar) {} + fn drain(&mut self) -> Vec { + std::mem::take(&mut self.0.borrow_mut().1) + } + fn attach(&mut self, dispatch: Dispatch) { + self.0.borrow_mut().0 = Some(dispatch); + } + } + + #[test] + fn a_native_menu_click_runs_its_command_once() { + let (mut app, ctx) = app(&["a.pdf"]); + let fake = FakeMenu::default(); + app.native_menu = Some(NativeMenu::new(Box::new(fake.clone()))); + let doc = app.views[0].id; + let before = app.session.get(doc).unwrap().info.pages[0].rotation; + fake.click(&CommandRef::new("page.rotate")); + let mut raw = egui::RawInput::default(); + app.prepare_raw_input(&ctx, &mut raw); + app.native_menu_clicks(&ctx); + app.native_menu_clicks(&ctx); + assert_eq!(app.session.get(doc).unwrap().info.pages[0].rotation, (before + 90) % 360, "rotated once"); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 01cab03314..23513cc973 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -213,10 +213,6 @@ pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response if elided { resp.on_hover_text(placeholder) } else { resp } } -pub fn menu_item(ui: &mut egui::Ui, label: &str, shortcut: &str) -> Response { - ui.add(egui::Button::new(label).shortcut_text(shortcut)) -} - /// Corner radius of a checkbox's box. With the widgets' radius (`Tokens::radius`, 6) egui's 14 px /// box is a circle, the shape of a radio button, and View ▸ Page display has both. const CHECKBOX_RADIUS: u8 = 3; diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index 15c23c6296..1e3c36216f 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -293,15 +293,14 @@ fn palette_arrow_keys_select_results_and_enter_runs_the_selection() { assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages[0].rotation, 270); } -/// Check that choosing Delete pages from the Pages menu reduces the document page count. +/// Check that choosing Edit ▸ Pages ▸ Delete pages in the menu bar reduces the page count once. #[test] fn the_pages_menu_comes_from_the_registry() { let mut h = harness(); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("Pages").hover(); + support::open_menu(&mut h, "Edit"); + support::menu_row(&h, "Pages").hover(); h.run_steps(3); - h.get_by_label_contains("Delete pages").click(); + support::menu_row(&h, "Delete pages").click(); h.run_steps(3); let app = h.state(); assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages.len(), 2); @@ -327,14 +326,11 @@ fn the_open_recent_menu_lists_files_and_opens_one() { } }); h.run_steps(4); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File").hover(); - h.run_steps(3); - h.get_by_label("Open Recent").hover(); + support::open_menu(&mut h, "File"); + support::menu_row(&h, "Open Recent").hover(); h.run_steps(3); let original = h.state().views[0].id; - h.get_by_label_contains("recent.pdf").click(); + support::menu_row(&h, "recent.pdf").click(); support::wait_until(&mut h, "recent document", |app| app.views.len() == 2 && app.password_prompt.is_none()); let app = h.state(); let active = app.active.unwrap(); @@ -348,10 +344,7 @@ fn the_open_recent_menu_lists_files_and_opens_one() { #[test] fn the_open_recent_menu_is_disabled_while_the_list_is_empty() { let mut h = harness(); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File").hover(); - h.run_steps(3); + support::open_menu(&mut h, "File"); assert!( h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "Open Recent is disabled while no file has been opened" @@ -373,19 +366,13 @@ fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() { app }); h.run_steps(4); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File").hover(); + support::open_menu(&mut h, "File"); + support::menu_row(&h, "Open Recent").hover(); h.run_steps(3); - h.get_by_label("Open Recent").hover(); - h.run_steps(3); - h.get_by_label("Clear Recent Files").click(); + support::menu_row(&h, "Clear Recent Files").click(); h.run_steps(3); assert!(h.state().recent.is_empty(), "the list is empty"); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File").hover(); - h.run_steps(3); + support::open_menu(&mut h, "File"); assert!(h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "and Open Recent is disabled"); } @@ -458,13 +445,13 @@ fn localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings( assert!(!doc.dirty); } -/// Menu ▸ View open, with egui told the app runs on `os`. +/// View ▸ Zoom open in the menu bar, with egui told the app runs on `os`. fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> { let mut h = harness(); h.ctx.set_os(os); - h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("View").hover(); + support::open_menu(&mut h, "View"); + support::menu_row(&h, "Zoom").hover(); h.run_steps(3); h } @@ -473,21 +460,14 @@ fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> { fn view_menu_and_tooltips_write_shortcuts_for_the_platform() { // On Linux, View ▸ Zoom and Page navigation showed ⌘1, ⌘[ … (hard-coded) while the // registered items under them showed Ctrl+K; tooltips showed ⌘ too. + // The menu bar writes its shortcuts for the build's platform (craft-menu tests that). let h = view_menu(OperatingSystem::Nix); - h.get_by_label("Actual size Ctrl+1"); - h.get_by_label("Zoom to page level Ctrl+0"); - h.get_by_label("Rotate view counterclockwise Ctrl+Shift+−"); - h.get_by_label("Previous view Ctrl+["); - h.get_by_label("Find tools and commands… Ctrl+K"); h.get_by_label("Zoom in (Ctrl++)"); h.get_by_label("Print (Ctrl+P)"); assert_eq!(h.query_all_by_label_contains("⌘").count(), 0, "no ⌘ outside macOS"); assert_eq!(h.query_all_by_label_contains("Fit visible").count(), 1, "View lists Fit visible once"); - h.get_by_label("Fit visible Ctrl+3"); let h = view_menu(OperatingSystem::Mac); - h.get_by_label("Actual size ⌘1"); - h.get_by_label("Fit visible ⌘3"); h.get_by_label("Rotate view clockwise (⇧⌘+)"); assert_eq!(h.query_all_by_label_contains("Ctrl+").count(), 0, "no Ctrl+ on macOS"); } diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index fe96bb5951..e2b09931ea 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -515,12 +515,16 @@ fn language_switch_preserves_document_and_command_ids() { assert_eq!(ok(&mut h, &c, "ui.commands", json!({})), commands); let lang = i18n::Lang::from_code(code).unwrap(); - h.get_by_label(i18n::tr(lang, "Menu")).click(); + // A title without an ASCII letter shows its mnemonic after it ("ファイル(F)"). + let file = i18n::tr(lang, "File"); + h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(file))).click(); h.run_steps(2); - h.get_by_label(i18n::tr(lang, "File")).hover(); - h.run_steps(3); - h.get_by_label_contains(i18n::tr(lang, "Open…")); - ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); + // The mnemonic goes before an ellipsis: "開く(O)…". + let open = i18n::tr(lang, "Open…").trim_end_matches('…'); + let open_row = |n: &egui_kittest::kittest::AccessKitNode<'_>| { + n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(open)) + }; + assert!(h.query_all_by(open_row).next().is_some(), "File lists Open… in {code}"); ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); h.run_steps(2); } @@ -1013,18 +1017,17 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() { } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); - ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" })); + // The menu bar's Edit menu holds Preferences. Titles and rows carry their mnemonic + // ("編集(E)", "環境設定(P)…"), so match by the start of the label. + let menu_row = |text: &'static str| { + move |n: &egui_kittest::kittest::AccessKitNode<'_>| { + n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(text)) + } + }; + h.get_by(menu_row("編集")).click(); h.run_steps(2); - // The open menu's Edit entry and the left panel's Edit tool share the visible label. Pick - // the popup entry by its id, not by its visible text. - let edits = ok(&mut h, &c, "ui.inspect", json!({ "query": "編集", "role": "Button" })); - // The tool button reports its toggle state; the popup's menu button does not. - let edit = edits["widgets"].as_array().unwrap().iter().find(|w| w.get("toggled").is_none()).expect("the Edit menu entry"); - ok(&mut h, &c, "ui.click", json!({ "id": edit["id"] })); + h.get_by(menu_row("環境設定")).click(); h.run_steps(2); - let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "環境設定…" })); - let prefs = menu["widgets"].as_array().unwrap().iter().find(|w| w["clickable"] == true).expect("Preferences menu item"); - ok(&mut h, &c, "ui.click", json!({ "id": prefs["id"] })); for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")] { let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": current })); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 0895a8b5be..e5e104d674 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -1371,13 +1371,8 @@ fn edit_menu_names_the_step_to_undo() { let mut h = harness(2, |app| { app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); }); - h.get_by_label("Menu").click(); - h.run_steps(2); - // The popup's Edit entry and the left panel's Edit tool carry the same visible label. The - // tool reports a toggle state; the menu entry does not. - h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover(); // submenus open on hover; their labels carry the arrow - h.run_steps(3); - h.get_by_label_contains("Undo Rotate page").click(); // the label includes the shortcut + support::open_menu(&mut h, "Edit"); + support::menu_row(&h, "Undo Rotate page").click(); h.run_steps(3); let app = h.state(); let doc = app.session.get(app.views[0].id).unwrap(); @@ -3706,7 +3701,7 @@ fn editing_existing_text_in_place() { /// paragraph editor and retypes it as "Chapter One", leaving the editor open. fn edit_tab_with_an_open_paragraph(h: &mut Harness<'static, PdfCraftApp>) { use pdfcraft_ui_egui::{Mode, QuickTool}; - h.get_by_label("Edit").click(); + support::outside_menus(h, "Edit").click(); h.run_steps(3); assert_eq!((h.state().mode, h.state().quick_tool), (Mode::Edit, QuickTool::EditText)); open_the_first_line(h); diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index 8861310115..38ffdffd39 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -1,5 +1,6 @@ //! Header & footer, watermark and background dialogs in the real shell (egui_kittest). +use crate::support; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_engine::MarkKind; @@ -127,7 +128,7 @@ fn backgrounds_and_watermarks_from_a_file() { let mut h = harness(); h.state_mut().execute("edit.background"); h.run_steps(2); - h.get_by_label("File").click(); + support::outside_menus(&h, "File").click(); h.run_steps(2); h.get_by_label("No file chosen"); assert!(h.query_by(|n| n.label().as_deref() == Some("OK") && n.is_disabled()).is_some(), "OK waits for a file"); diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 806fa7153e..8d565ab696 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -813,16 +813,16 @@ fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() { }; open(&mut h); h.get_by_label("Run a JavaScript, then 1 more action"); - // The first Edit is the menu bar's; the rows' follow (Mouse Up, On Blur). - assert_eq!(h.get_all_by_label("Edit").count(), 3); - h.get_all_by_label("Edit").nth(1).unwrap().click(); + // The menu bar's Edit menu and the Edit tab come first; the rows' follow (Mouse Up, On Blur). + assert_eq!(h.get_all_by_label("Edit").count(), 4); + h.get_all_by_label("Edit").nth(2).unwrap().click(); h.run_steps(2); assert_eq!(h.state().field_props.as_ref().unwrap().editing, Some(FieldTrigger::MouseUp)); // While one script is open, no other Edit can overwrite it. - assert!(h.get_all_by_label("Edit").skip(1).all(|n| n.accesskit_node().is_disabled())); + assert!(h.get_all_by_label("Edit").skip(2).all(|n| n.accesskit_node().is_disabled())); h.get_by_label("Discard").click(); h.run_steps(2); - assert!(h.get_all_by_label("Edit").skip(1).all(|n| !n.accesskit_node().is_disabled())); + assert!(h.get_all_by_label("Edit").skip(2).all(|n| !n.accesskit_node().is_disabled())); // Replace the Mouse Up script with a link: the chained link stays after it. { let d = h.state_mut().field_props.as_mut().unwrap(); diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index 0050f82b19..62d97d4e58 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -1,5 +1,6 @@ //! Use a certificate in the real shell: draw a signature, create a digital ID, sign, validate. +use crate::support; use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; @@ -128,14 +129,14 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { field(&h, "Digital ID password").click(); h.run_steps(1); field(&h, "Digital ID password").type_text("nope"); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(3); h.get_by_label("The password is incorrect."); h.state_mut().sign_draft.as_mut().unwrap().password = "secret12".into(); field(&h, "Reason").click(); h.run_steps(1); field(&h, "Reason").type_text("I approve"); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); @@ -275,7 +276,7 @@ fn certifying_without_a_visible_signature() { h.get_by_label("Certify as \"Test Signer EC\""); h.get_by_label("Permitted actions after certifying"); h.state_mut().sign_draft.as_mut().unwrap().password = "test".into(); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); @@ -361,7 +362,7 @@ fn clicking_an_empty_signature_field_signs_it() { d.password = "test".into(); } h.run_steps(2); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap(); @@ -398,7 +399,7 @@ fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() { h.get_by_label("Fill & Sign signature").click(); h.run_steps(2); assert_eq!(h.state().sign_draft.as_ref().unwrap().graphic, SignGraphic::FillSign); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); @@ -418,7 +419,7 @@ fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() { h.state_mut().sign_draft.as_mut().unwrap().graphic = SignGraphic::Image(image.clone()); h.run_steps(2); h.get_by_label("Choose image…"); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); @@ -534,7 +535,7 @@ fn windows_store_identity_does_not_ask_for_a_file_password() { h.get_by_label("Sign as \"Store signer\""); assert_eq!(h.query_all_by_label("Digital ID password").count(), 0); h.get_by_label("The key is in the Windows certificate store, which may ask to allow PdfCraft to use it."); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(3); assert_eq!(h.state().dialog, Some(Dialog::Sign)); assert!(h.state().sign_draft.as_ref().unwrap().error.as_ref().unwrap().contains("Windows certificate store")); @@ -679,7 +680,7 @@ fn signing_preserves_destination_opening_attributes() { let d = h.state_mut().sign_draft.as_mut().unwrap(); (d.selected, d.step, d.password) = (Some(entry), SignStep::SignAs, "test".into()); h.run_steps(2); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); assert!(h.state().session.get(h.state().views[0].id).unwrap().signatures.iter().any(|s| s.signed)); assert_eq!(xattr::get(&dest, "com.apple.LaunchServices.OpenWith").unwrap(), Some(b"existing binding".to_vec())); diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index 174ec5cc0b..f419bb3296 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -51,6 +51,27 @@ pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl ); } +/// Open the in-window menu bar's `title` menu (File, Edit, View…). +pub fn open_menu(h: &mut Harness<'static, PdfCraftApp>, title: &str) { + use egui_kittest::kittest::Queryable; + h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(title)).click(); + h.run_steps(2); +} + +/// The menu row labelled `label` in an open menu. +pub fn menu_row<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> { + use egui_kittest::kittest::Queryable; + let label = label.to_owned(); + h.get_by(move |n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(label.as_str())) +} + +/// The widget labelled `label` outside the menu bar, whose titles (File, Edit, Sign…) may share it. +pub fn outside_menus<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> { + use egui_kittest::kittest::Queryable; + let label = label.to_owned(); + h.get_by(move |n| n.role() != egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(label.as_str())) +} + /// Whether the test `name` must skip because the interface fonts lack a glyph for a character it /// draws: one in `text` or in the catalog of the interface `language`. egui 0.37 test harnesses /// panic on a missing glyph, and Japanese, Chinese and Thai faces come only from the optional diff --git a/crates/ui-egui/tests/it/text_select.rs b/crates/ui-egui/tests/it/text_select.rs index 7f7358bfeb..e480f1c598 100644 --- a/crates/ui-egui/tests/it/text_select.rs +++ b/crates/ui-egui/tests/it/text_select.rs @@ -192,17 +192,14 @@ fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool { /// Check that both the Edit menu and the context menu copy the selected page text. #[test] fn selected_text_copy_edit_and_context_menus() { - use egui_kittest::kittest::{NodeT, Queryable}; + use egui_kittest::kittest::Queryable; let (mut h, at) = harness(|_| {}); click(&mut h, at); click(&mut h, at); h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand; h.run_steps(2); // Hide floating Copy so this test can only reach menu entries. - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover(); - h.run_steps(3); - h.get_by_label_contains("Copy").click(); + crate::support::open_menu(&mut h, "Edit"); + crate::support::menu_row(&h, "Copy").click(); let mut did_copy = false; for _ in 0..4 { h.step(); @@ -229,15 +226,13 @@ fn selected_text_copy_edit_and_context_menus() { #[test] fn escape_closes_an_open_menu_before_it_clears_the_selection() { - // #3535: one Escape closed the main menu and dropped the selection with it. - use egui_kittest::kittest::Queryable; + // #3535: one Escape closed the menu and dropped the selection with it. let (mut h, at) = harness(|_| {}); click(&mut h, at); click(&mut h, at); h.run_steps(2); assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick")); - h.get_by_label("Menu").click(); - h.run_steps(2); + crate::support::open_menu(&mut h, "View"); assert!(egui::Popup::is_any_open(&h.ctx), "the menu is open"); h.key_press(egui::Key::Escape); h.run_steps(2); diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index e5e930a971..a02c5c05ca 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -36,12 +36,9 @@ fn click_toolbar(h: &mut Harness<'static, PdfCraftApp>, label: &str) { } fn open_view_menu(h: &mut Harness<'static, PdfCraftApp>) { - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("View").hover(); - h.run_steps(3); + crate::support::open_menu(h, "View"); assert!(h.query_by_label("Switch light / dark theme").is_none()); - h.get_by_label("Display theme").hover(); + crate::support::menu_row(h, "Display theme").hover(); h.run_steps(3); for choice in ["Use system setting", "Light gray", "Dark gray"] { h.get_by_label(choice); diff --git a/crates/ui-egui/tests/it/trust_signer.rs b/crates/ui-egui/tests/it/trust_signer.rs index 27bd2f3c9e..c7a2ad739f 100644 --- a/crates/ui-egui/tests/it/trust_signer.rs +++ b/crates/ui-egui/tests/it/trust_signer.rs @@ -3,6 +3,7 @@ //! certificate whose CMS carries its issuer chain gives a signature whose `chain.last()` (the //! self-signed root) differs from its `certificate` (the signer); the click must trust the signer. +use crate::support; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_engine::SignatureStatus; @@ -58,7 +59,7 @@ fn one_click_trust_stores_the_displayed_signer_not_the_embedded_root() { d.password = "test".into(); } h.run_steps(2); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); // The signature validates as Unknown and carries a two-certificate chain whose top (the root) diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 12723274d5..cd5ce8dbb1 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -279,7 +279,7 @@ fn expanding_all_tools_from_read_mode_restores_the_right_panel() { fn expanding_all_tools_from_edit_puts_edit_text_down() { use pdfcraft_ui_egui::{LeftPanel, Mode, QuickTool}; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); assert_eq!(h.state().quick_tool, QuickTool::EditText); h.state_mut().set_option("tools", "full").unwrap(); @@ -1640,7 +1640,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() { use pdfcraft_ui_egui::{Mode, QuickTool}; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); assert_eq!(h.state().quick_tool, QuickTool::Select); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); assert_eq!(h.state().mode, Mode::Edit); assert_eq!(h.state().quick_tool, QuickTool::EditText); @@ -1649,7 +1649,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() { assert_eq!(h.state().mode, Mode::AllTools); assert_eq!(h.state().quick_tool, QuickTool::Select); // Another tool picked inside Edit is not taken away by leaving it. - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); h.state_mut().quick_tool = QuickTool::Hand; h.get_by_label("Convert").click(); @@ -1663,7 +1663,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() { fn organize_pages_from_the_edit_panel_opens_its_panel() { use pdfcraft_ui_egui::LeftPanel; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); assert_eq!(h.state().left, LeftPanel::Tool("edit")); assert!( @@ -1681,7 +1681,7 @@ fn organize_pages_from_the_edit_panel_opens_its_panel() { #[test] fn the_edit_panel_keeps_the_add_update_remove_menu() { let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); h.get_by_label("Watermark").click(); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 28eb6fd768..a83b4cb756 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1033,9 +1033,8 @@ fn view_menu_page_display_uses_the_same_commands() { use egui_kittest::kittest::Queryable; let mut h = harness(&[]); h.run_steps(4); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("View").hover(); + crate::support::open_menu(&mut h, "View"); + crate::support::menu_row(&h, "Page display").hover(); h.run_steps(3); h.get_by_label("Single page").click(); h.run_steps(4); @@ -2011,17 +2010,14 @@ fn long_view_menu_fits_a_short_window() { app }); h.run_steps(8); - h.get_by_label("Menu").click(); + crate::support::open_menu(&mut h, "View"); h.run_steps(2); - h.get_by_label("View").hover(); - h.run_steps(4); - let before = h.get_by_label("Side panels").rect().bottom(); - h.get_by_label("Fit to height ").hover(); + crate::support::menu_row(&h, "Rotate view").hover(); h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move)); h.run_steps(6); - let after = h.get_by_label("Side panels").rect().bottom(); - assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}"); - h.get_by_label("Side panels").hover(); + let after = crate::support::menu_row(&h, "Side panels").rect().bottom(); + assert!(after <= 400.0, "the menu scrolls Side panels into view: {after}"); + crate::support::menu_row(&h, "Side panels").hover(); h.run_steps(4); h.get_all_by_label("Bookmarks").last().unwrap().click(); h.run_steps(4); From 32a9af4d08327f3aaa557307c537b886e602d975 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 26 Oct 2026 16:01:29 -0400 Subject: [PATCH 027/246] proposal(redact): unlink unused Type 3 glyph programs and fonts on redaction (F-011) --- crates/redact/src/interp.rs | 7 +- crates/redact/src/lib.rs | 3 + crates/redact/src/sanitize.rs | 7 +- .../redact/src/tests/surviving_references.rs | 193 +++++ crates/redact/src/type3.rs | 692 ++++++++++++++++ crates/redact/src/type3_tables.rs | 775 ++++++++++++++++++ 6 files changed, 1675 insertions(+), 2 deletions(-) create mode 100644 crates/redact/src/type3.rs create mode 100644 crates/redact/src/type3_tables.rs diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index bd9da5215f..cef566042a 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -477,6 +477,7 @@ fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], de /// left as it is for them. Retired properties are likewise unlinked only when none of the /// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { + crate::type3::unlink_type3(doc)?; if doomed.is_empty() && retired_properties.is_empty() { return Ok(()); } @@ -677,7 +678,7 @@ fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option]) -> Result, CosError> { +pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { let parsed = Pieces::join(content).parse(); if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); @@ -1359,6 +1360,10 @@ fn xobject( out.error = Some(error); return None; } + if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content) { + out.error = Some(error); + return None; + } } if cleaned_res != res { dict.set(b"Resources".to_vec(), Object::Dict(cleaned_res)); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index bdf1db873e..6f69151d89 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -27,6 +27,8 @@ pub mod sanitize; mod tags; #[cfg(test)] mod tests; +pub(crate) mod type3; +pub(crate) mod type3_tables; use interp::{Mode, Scope, process}; @@ -493,6 +495,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result Re res.set(b"XObject".to_vec(), Object::Dict(xo)); } retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?); + crate::type3::prune_type3_from_resources(doc, &mut res, &page_content)?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); @@ -443,7 +444,11 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re } // What a page no longer draws must not stay reachable through an inherited or shared // /Resources either. - crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?; + // Only a removal rewrites the document; a count on a clone has nothing to unlink, and a + // cleanup error there would be mistaken for "nothing to remove". + if write { + crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?; + } Ok((total, retained_off_layers)) } diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index a297ded2a4..477f03f9a5 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -105,3 +105,196 @@ fn an_image_another_page_draws_through_a_form_without_resources_is_kept() { assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); assert!(full_save_holds(&doc, b"IMAGESECRET"), "the second page still draws the image through its form"); } + +/// A Type 3 font whose displayed glyphs are redacted has its unused glyph programs unlinked from +/// /CharProcs and exclusive graphical resources pruned, so they do not survive in a full save (F-011). +#[test] +fn redacted_type3_glyph_and_exclusive_resources_do_not_survive_full_save() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + // Text showing AB with Type 3 font /T3 + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + // Type 3 font with /CharProcs << /A 7 0 R /B 8 0 R >> and /Resources with images for both + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> /Resources << /XObject << /ImA 9 0 R /ImB 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do % SECRET_GLYPH_A_PAYLOAD"), + stream("", b"/ImB Do % SURVIVING_GLYPH_B_PAYLOAD"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBLIC_IMAGE_B"), + ]); + + // Redaction mark covers only 'A' (x: 10..15, y: 95..110) + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + + assert!(full_save_holds(&doc, b"SECRET_GLYPH_A_PAYLOAD")); + assert!(full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A")); + assert!(full_save_holds(&doc, b"SURVIVING_GLYPH_B_PAYLOAD")); + assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B")); + + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + + // After redaction, unreferenced glyph A and its exclusive image A must not survive full save + assert!(!full_save_holds(&doc, b"SECRET_GLYPH_A_PAYLOAD"), "redacted glyph A program survived"); + assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A"), "exclusive image for redacted glyph A survived"); + + // Surviving glyph B and its image B must survive + assert!(full_save_holds(&doc, b"SURVIVING_GLYPH_B_PAYLOAD"), "surviving glyph B was lost"); + assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "surviving glyph B image was lost"); + + // Reopen and inspect the Type 3 font dictionary + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let res = saved.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let fonts = saved.resolve(res.get(b"Font").unwrap()).as_dict().unwrap().clone(); + let t3 = saved.resolve(fonts.get(b"T3").unwrap()).as_dict().unwrap().clone(); + let procs = saved.resolve(t3.get(b"CharProcs").unwrap()).as_dict().unwrap().clone(); + assert!(!procs.contains(b"A"), "CharProcs still contains unreferenced glyph A"); + assert!(procs.contains(b"B"), "CharProcs lost surviving glyph B"); + + let t3_res = saved.resolve(t3.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let t3_xo = saved.resolve(t3_res.get(b"XObject").unwrap()).as_dict().unwrap().clone(); + assert!(!t3_xo.contains(b"ImA"), "Type 3 font resources still contains exclusive image ImA"); + assert!(t3_xo.contains(b"ImB"), "Type 3 font resources lost surviving image ImB"); +} + +/// When all glyphs of a Type 3 font are redacted, the entire font is removed from Resources (F-011). +#[test] +fn all_redacted_type3_glyphs_cause_font_removal_from_resources() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 7 0 R >> /Resources << /XObject << /ImA 8 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do % SECRET_GLYPH_ONLY_PAYLOAD"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_ONLY"), + ]); + + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + + assert!(!full_save_holds(&doc, b"SECRET_GLYPH_ONLY_PAYLOAD")); + assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_ONLY")); + + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + if let Some(res_val) = page.dict.get(b"Resources") + && let Some(res) = saved.resolve(res_val).as_dict() + && let Some(fonts_val) = res.get(b"Font") + { + let fonts = saved.resolve(fonts_val).as_dict().unwrap().clone(); + assert!(!fonts.contains(b"T3"), "Font /T3 was not removed from Resources"); + } +} + +/// In shared Type 3 fonts across multiple pages, glyphs used on another page are preserved (F-011). +#[test] +fn shared_type3_font_preserves_glyphs_used_by_another_page() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + // Page 1: draws 'A' (redacted) + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Resources 7 0 R >>".to_vec(), + // Page 2: draws 'B' (survives) + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + stream("", b"BT /T3 10 Tf 10 100 Td (B) Tj ET"), + // Shared Resources 7 0 R + b"<< /Font << /T3 8 0 R >> >>".to_vec(), + // Shared Type 3 font 8 0 R + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 9 0 R /B 10 0 R >> >>".to_vec(), + stream("", b"% SECRET_PAGE1_GLYPH_A"), + stream("", b"% PUBLIC_PAGE2_GLYPH_B"), + ]); + + // Redact only Page 1's 'A' + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + + // Glyph A was removed from Page 1 and used nowhere else -> unlinked! + assert!(!full_save_holds(&doc, b"SECRET_PAGE1_GLYPH_A")); + + // Glyph B is still used on Page 2 -> kept! + assert!(full_save_holds(&doc, b"PUBLIC_PAGE2_GLYPH_B")); + + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let pages = pdfcraft_model::pages(&saved); + let p2_res = saved.resolve(pages[1].dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let fonts = saved.resolve(p2_res.get(b"Font").unwrap()).as_dict().unwrap().clone(); + let t3 = saved.resolve(fonts.get(b"T3").unwrap()).as_dict().unwrap().clone(); + let procs = saved.resolve(t3.get(b"CharProcs").unwrap()).as_dict().unwrap().clone(); + assert!(!procs.contains(b"A")); + assert!(procs.contains(b"B")); +} + +/// Fails closed when Type 3 font cleanup cannot be safely established (F-011). +#[test] +fn unparseable_type3_font_encoding_fails_closed() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + // Unsupported encoding /UnknownCustomEncoding without Differences + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding /UnknownCustomEncoding /CharProcs << /A 7 0 R >> >>".to_vec(), + stream("", b"% SECRET_PAYLOAD_UNPARSEABLE"), + ]); + + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + + let res = apply(&mut doc, None); + assert!(res.is_err(), "redaction must fail closed when Type 3 cleanup cannot be established: {res:?}"); +} + +/// A Type 3 font selected through an ExtGState /Font entry is a use of the font for pruning: +/// its redacted glyph program does not survive a full save (F-011). +#[test] +fn type3_font_set_through_extgstate_has_its_redacted_glyph_removed() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"/GS1 gs BT 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> /ExtGState << /GS1 8 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 7 0 R >> >>".to_vec(), + stream("", b"% SECRET_EXTGSTATE_GLYPH_PAYLOAD"), + b"<< /Type /ExtGState /Font [6 0 R 10] >>".to_vec(), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_EXTGSTATE_GLYPH_PAYLOAD"), "redacted glyph program survived"); +} + +/// A Type 3 font used only inside a Form XObject is found by the scan and its redacted glyph +/// program does not survive a full save (F-011). +#[test] +fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"/Fm Do"), + b"<< /XObject << /Fm 8 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 7 0 R >> >>".to_vec(), + stream("", b"% SECRET_FORM_GLYPH_PAYLOAD"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 9 0 R", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived"); +} diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs new file mode 100644 index 0000000000..f431dab902 --- /dev/null +++ b/crates/redact/src/type3.rs @@ -0,0 +1,692 @@ +//! Unlink unused Type 3 font glyph programs and prune exclusive graphical resources (F-011). + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_content::Pieces; +use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object}; + +use crate::{RedactError, page_streams}; + +const MAX_DEPTH: usize = 64; +const MAX_HOLDERS: usize = 1_000_000; + +fn res_dict(doc: &Document, res: &Dict, key: &[u8]) -> Dict { + res.get(key).and_then(|o| doc.resolve(o).as_dict().cloned()).unwrap_or_default() +} + +/// Identifies a font in the document. Only fonts that are indirect objects are cleaned: a Type 3 +/// font held directly in a resource dictionary fails closed (see [`unlink_type3`]), because its +/// glyph programs could not be told apart or rewritten reliably. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub(crate) enum FontKey { + Ref(ObjRef), +} + +/// Build the mapping from character code (0..=255) to glyph name in a Type 3 font. +/// Fails closed if the encoding cannot be safely resolved or parsed. +pub(crate) fn type3_code_to_glyph(doc: &Document, font_dict: &Dict) -> Result<[Option>; 256], CosError> { + let Some(enc_obj) = font_dict.get(b"Encoding") else { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: missing Encoding".into() }); + }; + let resolved_enc = doc.resolve(enc_obj); + + let mut differences = HashMap::new(); + let base_name: Option>; + + match &*resolved_enc { + Object::Dict(enc_dict) => { + if let Some(diffs) = enc_dict.get(b"Differences").and_then(|d| doc.resolve(d).as_array().cloned()) { + let mut cur_code = 0i64; + for item in &diffs { + match &*doc.resolve(item) { + Object::Int(n) => cur_code = *n, + Object::Name(name) => { + if let Ok(c) = u8::try_from(cur_code) { + differences.insert(c, name.clone()); + } + cur_code = cur_code.saturating_add(1); + } + _ => {} + } + } + } + if let Some(base) = enc_dict.name(b"BaseEncoding") { + base_name = Some(base.to_vec()); + } else { + // ISO 32000 §9.6.6.1: default base encoding for Type 3 fonts is StandardEncoding. + base_name = Some(b"StandardEncoding".to_vec()); + } + } + Object::Name(name) => { + base_name = Some(name.clone()); + } + _ => { + return Err(CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: invalid Encoding object".into(), + }); + } + } + + let base_table: Option<&'static [Option<&'static [u8]>; 256]> = match base_name.as_deref() { + Some(b"StandardEncoding") => Some(&crate::type3_tables::STANDARD), + Some(b"WinAnsiEncoding") => Some(&crate::type3_tables::WIN_ANSI), + Some(b"MacRomanEncoding") => Some(&crate::type3_tables::MAC_ROMAN), + Some(b"MacExpertEncoding") => Some(&crate::type3_tables::MAC_EXPERT), + Some(other) => { + return Err(CosError::Syntax { + offset: 0, + detail: format!( + "redaction could not establish safe Type 3 font cleanup: unsupported BaseEncoding: {}", + String::from_utf8_lossy(other) + ), + }); + } + None => None, + }; + + let mut map: [Option>; 256] = std::array::from_fn(|_| None); + for (code, entry) in map.iter_mut().enumerate() { + let c = code as u8; + if let Some(diff_glyph) = differences.get(&c) { + *entry = Some(diff_glyph.clone()); + } else if let Some(table) = base_table + && let Some(base_glyph) = table[code] + { + *entry = Some(base_glyph.to_vec()); + } + } + + Ok(map) +} + +fn resolve_font_in_res(doc: &Document, res: &Dict, font_name: &[u8]) -> Option<(FontKey, Dict)> { + let fonts = res_dict(doc, res, b"Font"); + let font_obj = fonts.get(font_name)?; + // A direct Type 3 font is refused during discovery, so none reaches the scan. + let font_ref = font_obj.as_ref()?; + let dict = doc.resolve(font_obj).as_dict()?.clone(); + (dict.name(b"Subtype") == Some(b"Type3")).then_some((FontKey::Ref(font_ref), dict)) +} + +fn resolve_extgstate_font(doc: &Document, res: &Dict, gs_name: &[u8]) -> Option<(FontKey, Dict)> { + let extg = res_dict(doc, res, b"ExtGState"); + let state = doc.resolve(extg.get(gs_name)?).as_dict().cloned()?; + let font_entry = state.get(b"Font").and_then(|f| doc.resolve(f).as_array().cloned())?; + let font_obj = font_entry.first()?; + let font_ref = font_obj.as_ref()?; + let dict = doc.resolve(font_obj).as_dict()?.clone(); + (dict.name(b"Subtype") == Some(b"Type3")).then_some((FontKey::Ref(font_ref), dict)) +} + +#[derive(Default)] +struct UsedInGlyphs { + xobjects: HashSet>, + colorspaces: HashSet>, + patterns: HashSet>, + shadings: HashSet>, + extgstates: HashSet>, + fonts: HashSet>, + properties: HashSet>, +} + +fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Result<(), CosError> { + let parsed = pdfcraft_content::parse(data); + if parsed.skipped > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect glyph stream syntax".into() }); + } + for op in parsed.ops { + match op.op.as_slice() { + b"Do" => { + if let Some(n) = op.last_name() { + used.xobjects.insert(n.to_vec()); + } + } + b"cs" | b"CS" => { + if let Some(n) = op.name(0) { + used.colorspaces.insert(n.to_vec()); + } + } + b"scn" | b"SCN" => { + if let Some(n) = op.last_name() { + used.patterns.insert(n.to_vec()); + } + } + b"sh" => { + if let Some(n) = op.last_name() { + used.shadings.insert(n.to_vec()); + } + } + b"gs" => { + if let Some(n) = op.name(0) { + used.extgstates.insert(n.to_vec()); + } + } + b"Tf" => { + if let Some(n) = op.name(0) { + used.fonts.insert(n.to_vec()); + } + } + b"BDC" | b"DP" => { + if let Some(n) = op.name(1) { + used.properties.insert(n.to_vec()); + } + } + _ => {} + } + } + Ok(()) +} + +fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { + let mut changed = false; + + macro_rules! prune_category { + ($cat:expr, $used_set:expr) => { + if let Some(sub_obj) = font_res.get($cat) { + let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); + let names: Vec> = sub.iter().map(|(n, _)| n.to_vec()).collect(); + let mut cat_changed = false; + for n in names { + if !$used_set.contains(&n) { + sub.remove(&n); + cat_changed = true; + } + } + if cat_changed { + changed = true; + if sub.is_empty() { + font_res.remove($cat); + } else { + font_res.set($cat.to_vec(), Object::Dict(sub)); + } + } + } + }; + } + + prune_category!(b"XObject", used.xobjects); + prune_category!(b"ColorSpace", used.colorspaces); + prune_category!(b"Pattern", used.patterns); + prune_category!(b"Shading", used.shadings); + prune_category!(b"ExtGState", used.extgstates); + prune_category!(b"Font", used.fonts); + prune_category!(b"Properties", used.properties); + + changed +} + +struct ScanContext<'a> { + doc: &'a Document, + font_maps: &'a HashMap>; 256]>, + used_glyphs: &'a mut HashMap>>, + /// A form scanned with its inherited font and the resources it draws through: the same form + /// drawn in another context is scanned again. + seen_forms: &'a mut HashSet<(ObjRef, Option, ObjRef)>, +} + +fn scan_content_stream( + ctx: &mut ScanContext<'_>, + content: &[Vec], + holder_ref: ObjRef, + res: &Dict, + initial_font: Option, + depth: usize, +) -> Result<(), CosError> { + if depth > MAX_DEPTH { + return Err(CosError::Syntax { offset: 0, detail: "redaction exceeded max depth inspecting Type 3 font content".into() }); + } + let joined = Pieces::join(content); + let parsed = joined.parse(); + if parsed.skipped > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect content stream syntax".into() }); + } + + let mut stack: Vec> = Vec::new(); + let mut current_font = initial_font; + + for op in parsed.ops { + match op.op.as_slice() { + b"q" => stack.push(current_font), + b"Q" => { + if let Some(saved) = stack.pop() { + current_font = saved; + } + } + b"Do" => { + if let Some(name) = op.last_name() { + scan_xobject(ctx, res, holder_ref, name, current_font, depth)?; + } + } + _ => apply_text_op(ctx, res, &op, &mut current_font), + } + } + Ok(()) +} + +/// Apply an operator that changes or uses the current font: `Tf`, `gs`, and the text-showing +/// operators, which record the glyphs they draw. +fn apply_text_op(ctx: &mut ScanContext<'_>, res: &Dict, op: &pdfcraft_content::Op, current_font: &mut Option) { + match op.op.as_slice() { + b"Tf" => { + if let Some(name) = op.name(0) { + *current_font = resolve_font_in_res(ctx.doc, res, name).map(|(k, _)| k); + } + } + b"gs" => { + if let Some(name) = op.name(0) + && let Some((k, _)) = resolve_extgstate_font(ctx.doc, res, name) + { + *current_font = Some(k); + } + } + b"Tj" | b"'" | b"\"" => { + if let Some(s) = op.operands.last().and_then(Object::as_string) { + mark_shown(ctx, *current_font, &s.bytes); + } + } + b"TJ" => { + if let Some(arr) = op.operands.last().and_then(Object::as_array) { + for item in arr { + if let Some(s) = item.as_string() { + mark_shown(ctx, *current_font, &s.bytes); + } + } + } + } + _ => {} + } +} + +/// Record every glyph of `font` that the shown string `bytes` uses. +fn mark_shown(ctx: &mut ScanContext<'_>, font: Option, bytes: &[u8]) { + let Some(fk) = font else { return }; + let Some(map) = ctx.font_maps.get(&fk) else { return }; + for b in bytes { + if let Some(g) = &map[*b as usize] { + ctx.used_glyphs.entry(fk).or_default().insert(g.clone()); + } + } +} + +/// Scan the Form XObject `name` drawn by a `Do` operator, once per (form, font, drawing holder). +fn scan_xobject( + ctx: &mut ScanContext<'_>, + res: &Dict, + holder_ref: ObjRef, + name: &[u8], + current_font: Option, + depth: usize, +) -> Result<(), CosError> { + let xo = res_dict(ctx.doc, res, b"XObject"); + let Some(r) = xo.get(name).and_then(Object::as_ref) else { return Ok(()) }; + let obj = ctx.doc.get(r); + let Object::Stream(s) = &*obj else { return Ok(()) }; + if s.dict.name(b"Subtype") != Some(b"Form") { + return Ok(()); + } + let form_own_res = s.dict.get(b"Resources").and_then(|ro| ctx.doc.resolve(ro).as_dict().cloned()); + // A form with resources of its own draws through them; one without draws through its caller's. + let through = if form_own_res.is_some() { r } else { holder_ref }; + if !ctx.seen_forms.insert((r, current_font, through)) { + return Ok(()); + } + let data = s.decoded()?; + match form_own_res { + Some(form_res) => scan_content_stream(ctx, std::slice::from_ref(&data), r, &form_res, current_font, depth + 1), + None => scan_content_stream(ctx, std::slice::from_ref(&data), holder_ref, res, current_font, depth + 1), + } +} + +/// The font object an ExtGState's `/Font` entry names (its first element), if any. +fn gs_font_object(doc: &Document, state_obj: &Object) -> Option { + let state = doc.resolve(state_obj).as_dict()?.clone(); + let font_entry = doc.resolve(state.get(b"Font")?).as_array()?.clone(); + font_entry.into_iter().next() +} + +/// Prune Type 3 fonts from a rewritten page or form's `/Resources` dictionary if the rewritten +/// content no longer draws any text using that font name. +pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { + let mut fonts = res_dict(doc, res, b"Font"); + if fonts.is_empty() { + return Ok(()); + } + + // The operators of forms that draw through these resources count too: dropping a font a child + // form still shows would leave its text without one. + let ops = crate::interp::resource_ops(doc, res, content)?; + + let mut drawn_font_names = HashSet::new(); + for op in &ops { + if op.is("Tf") { + if let Some(name) = op.name(0) { + drawn_font_names.insert(name.to_vec()); + } + } else if op.is("gs") + && let Some(name) = op.name(0) + { + let extg = res_dict(doc, res, b"ExtGState"); + if let Some(font_obj) = extg.get(name).and_then(|state_obj| gs_font_object(doc, state_obj)) { + drawn_font_names.extend( + fonts + .iter() + .filter(|(_, fn_obj)| fn_obj.as_ref().is_some() && fn_obj.as_ref() == font_obj.as_ref()) + .map(|(fn_name, _)| fn_name.to_vec()), + ); + } + } + } + + let mut changed = false; + let font_names: Vec> = fonts.iter().map(|(n, _)| n.to_vec()).collect(); + for name in font_names { + if let Some(font_obj) = fonts.get(&name) { + let resolved = doc.resolve(font_obj); + if let Some(d) = resolved.as_dict() + && d.name(b"Subtype") == Some(b"Type3") + && !drawn_font_names.contains(&name) + { + fonts.remove(&name); + changed = true; + } + } + } + + if changed { + if fonts.is_empty() { + res.remove(b"Font"); + } else { + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } + } + + Ok(()) +} + +/// Unlink unused Type 3 glyph programs from `CharProcs`, prune their exclusive resources from the +/// font's `/Resources`, and remove completely unused Type 3 fonts across all reachable resource +/// holders in the document. Fails closed if safe cleanup cannot be established. +pub(crate) fn unlink_type3(doc: &mut Document) -> Result<(), RedactError> { + let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { + return Ok(()); + }; + + // Find all page-tree nodes with /Resources of their own, and the pages that draw through them. + let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); + let mut seen = HashSet::new(); + let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; + while let Some((node, mut holder)) = stack.pop() { + if !seen.insert(node) || seen.len() > MAX_HOLDERS { + continue; + } + let obj = doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + if d.contains(b"Resources") { + holders.push((node, Vec::new())); + holder = Some(holders.len() - 1); + } + let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); + if is_pages { + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); + } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { + h.1.push(node); + } + } + + let form_holders = reachable_form_holders(doc, &holders)?; + + // Discover all Type 3 fonts in holders and forms. + let mut type3_fonts: HashMap = HashMap::new(); + for (node, _) in &holders { + if let Some(res) = doc.get(*node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) { + collect_type3_fonts(doc, &res, &mut type3_fonts)?; + } + } + for (_, form_res) in &form_holders { + collect_type3_fonts(doc, form_res, &mut type3_fonts)?; + } + + if type3_fonts.is_empty() { + return Ok(()); + } + + // Build code-to-glyph mappings for each Type 3 font. Fails closed on error. + let mut font_maps: HashMap>; 256]> = HashMap::new(); + for (key, font_dict) in &type3_fonts { + let map = type3_code_to_glyph(doc, font_dict)?; + font_maps.insert(*key, map); + } + + // Scan all page contents and reachable forms to discover surviving glyph usage. + let page_list = pdfcraft_model::pages(doc); + let mut used_glyphs: HashMap>> = HashMap::new(); + let mut seen_drawn_forms = HashSet::new(); + + let mut ctx = ScanContext { doc, font_maps: &font_maps, used_glyphs: &mut used_glyphs, seen_forms: &mut seen_drawn_forms }; + + for (pi, p) in page_list.iter().enumerate() { + let (_, content) = page_streams(ctx.doc, &p.dict, pi)?; + let page_res = p + .dict + .get(b"Resources") + .and_then(|r| ctx.doc.resolve(r).as_dict().cloned()) + .or_else(|| { + holders.iter().find(|(_, users)| users.contains(&p.obj)).and_then(|(h, _)| { + ctx.doc.get(*h).as_dict().and_then(|d| d.get(b"Resources")).and_then(|ro| ctx.doc.resolve(ro).as_dict().cloned()) + }) + }) + .unwrap_or_default(); + + scan_content_stream(&mut ctx, &content, p.obj, &page_res, None, 0)?; + } + + // Also scan any surviving form that wasn't reached by page streams directly. + for (form_ref, form_res) in &form_holders { + if !ctx.seen_forms.iter().any(|(form, _, _)| form == form_ref) { + let Object::Stream(s) = &*ctx.doc.get(*form_ref) else { continue }; + let data = s.decoded()?; + scan_content_stream(&mut ctx, std::slice::from_ref(&data), *form_ref, form_res, None, 0)?; + } + } + + // For each Type 3 font: unlink unused glyphs from CharProcs and prune exclusive resources. + for (font_key, font_dict) in &type3_fonts { + let surviving = used_glyphs.get(font_key).cloned().unwrap_or_default(); + clean_type3_font(doc, &holders, &form_holders, font_key, font_dict, &surviving)?; + } + + Ok(()) +} + +/// Every reachable Form XObject with resources of its own, with those resources, found by +/// walking the resources of the holders and of the forms they draw. +fn reachable_form_holders(doc: &Document, holders: &[(ObjRef, Vec)]) -> Result, CosError> { + let mut form_holders: Vec<(ObjRef, Dict)> = Vec::new(); + let mut pending_res: Vec = holders + .iter() + .filter_map(|(r, _)| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|ro| doc.resolve(ro).as_dict().cloned())) + .collect(); + let mut seen_forms = HashSet::new(); + while let Some(res) = pending_res.pop() { + for (_, value) in res_dict(doc, &res, b"XObject").iter() { + let Some(reference) = value.as_ref() else { continue }; + if !seen_forms.insert(reference) { + continue; + } + if seen_forms.len() > MAX_HOLDERS { + return Err(CosError::Syntax { offset: 0, detail: "redaction exceeded max depth inspecting form resources".into() }); + } + let object = doc.get(reference); + let Object::Stream(form) = &*object else { continue }; + if form.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + if let Some(r) = form.dict.get(b"Resources").and_then(|ro| doc.resolve(ro).as_dict().cloned()) { + form_holders.push((reference, r.clone())); + pending_res.push(r); + } + } + } + Ok(form_holders) +} + +/// Record every Type 3 font named by a resource dictionary's `/Font` and `/ExtGState` entries. +/// A Type 3 font held directly in a resource dictionary can neither be named reliably nor have its +/// glyph programs rewritten, so redaction fails closed rather than leaving its glyphs unchecked. +fn collect_type3_fonts(doc: &Document, res: &Dict, t3_fonts: &mut HashMap) -> Result<(), CosError> { + let direct = || CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: a Type 3 font is not an indirect object".into(), + }; + let mut consider = |font_obj: &Object| -> Result<(), CosError> { + if let Some(dict) = doc.resolve(font_obj).as_dict().cloned() + && dict.name(b"Subtype") == Some(b"Type3") + { + let font_ref = font_obj.as_ref().ok_or_else(direct)?; + t3_fonts.entry(FontKey::Ref(font_ref)).or_insert(dict); + } + Ok(()) + }; + for (_, font_obj) in res_dict(doc, res, b"Font").iter() { + consider(font_obj)?; + } + for (_, state_obj) in res_dict(doc, res, b"ExtGState").iter() { + if let Some(font_obj) = gs_font_object(doc, state_obj) { + consider(&font_obj)?; + } + } + Ok(()) +} + +/// Unlink the glyph programs of one Type 3 font that no longer draw, then either remove the font +/// (when no glyph survives) or prune the resources only its surviving glyphs used. +fn clean_type3_font( + doc: &mut Document, + holders: &[(ObjRef, Vec)], + form_holders: &[(ObjRef, Dict)], + font_key: &FontKey, + font_dict: &Dict, + surviving: &HashSet>, +) -> Result<(), RedactError> { + let Some(char_procs_val) = font_dict.get(b"CharProcs") else { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: missing CharProcs".into() }.into()); + }; + let mut char_procs = doc.resolve(char_procs_val).as_dict().cloned().ok_or_else(|| CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: CharProcs is not a dict".into(), + })?; + + let mut procs_changed = false; + let glyph_names: Vec> = char_procs.iter().map(|(n, _)| n.to_vec()).collect(); + for g in glyph_names { + if !surviving.contains(&g) { + char_procs.remove(&g); + procs_changed = true; + } + } + + let FontKey::Ref(fr) = font_key; + if char_procs.is_empty() { + // The font has no surviving glyphs: clear its own glyph programs and resources, then + // remove font from /Font across all holders. + doc.update_dict(*fr, |d| { + d.remove(b"CharProcs"); + d.remove(b"Resources"); + })?; + return remove_font_from_holders(doc, holders, form_holders, font_key); + } + + // A CharProcs object can be shared with another Type 3 font, so never rewrite it in place: + // give this font its own filtered dictionary instead. + if procs_changed { + doc.update_dict(*fr, |d| { + d.set(b"CharProcs".to_vec(), Object::Dict(char_procs.clone())); + })?; + } + + // Clean exclusive resources from the Type 3 font's /Resources. + let Some(f_res_val) = font_dict.get(b"Resources") else { return Ok(()) }; + let mut f_res = doc.resolve(f_res_val).as_dict().cloned().unwrap_or_default(); + + let mut used_in_glyphs = UsedInGlyphs::default(); + for (_, proc_val) in char_procs.iter() { + let resolved = doc.resolve(proc_val); + let Object::Stream(s) = &*resolved else { + return Err(CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: CharProc is not a stream".into(), + } + .into()); + }; + let data = s.decoded()?; + collect_used_resources_in_stream(&data, &mut used_in_glyphs)?; + } + + if prune_type3_font_resources(doc, &mut f_res, &used_in_glyphs) { + // The pruned dictionary goes into the font object: a font's /Resources object is + // often the page's own, and rewriting that would strip the page of its resources. + doc.update_dict(*fr, |d| { + if f_res.is_empty() { + d.remove(b"Resources"); + } else { + d.set(b"Resources".to_vec(), Object::Dict(f_res.clone())); + } + })?; + } + Ok(()) +} + +fn remove_font_from_holders( + doc: &mut Document, + holders: &[(ObjRef, Vec)], + form_holders: &[(ObjRef, Dict)], + target_key: &FontKey, +) -> Result<(), RedactError> { + let clean_res = |doc: &Document, res: &mut Dict| -> bool { + let mut fonts = res_dict(doc, res, b"Font"); + let mut changed = false; + let names: Vec> = fonts.iter().map(|(n, _)| n.to_vec()).collect(); + for n in names { + if let Some(val) = fonts.get(&n) + && val.as_ref().map(FontKey::Ref) == Some(*target_key) + { + fonts.remove(&n); + changed = true; + } + } + if changed { + if fonts.is_empty() { + res.remove(b"Font"); + } else { + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } + } + changed + }; + + for (node, _) in holders { + if let Some(mut res) = doc.get(*node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) + && clean_res(doc, &mut res) + { + doc.update_dict(*node, |d| { + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + } + } + + for (form_ref, _) in form_holders { + let obj = doc.get(*form_ref); + if let Object::Stream(s) = &*obj + && let Some(mut res) = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) + && clean_res(doc, &mut res) + { + doc.update_dict(*form_ref, |d| { + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + } + } + + Ok(()) +} diff --git a/crates/redact/src/type3_tables.rs b/crates/redact/src/type3_tables.rs new file mode 100644 index 0000000000..62128772de --- /dev/null +++ b/crates/redact/src/type3_tables.rs @@ -0,0 +1,775 @@ +//! Predefined font encodings (ISO 32000 Annex D) mapping character codes to glyph names. + +//! Each encoding lists only the codes it maps; every other code is unmapped. + +type Entries = &'static [(u8, &'static [u8])]; + +const fn build(entries: Entries) -> [Option<&'static [u8]>; 256] { + let mut out: [Option<&'static [u8]>; 256] = [None; 256]; + let mut i = 0; + while i < entries.len() { + out[entries[i].0 as usize] = Some(entries[i].1); + i += 1; + } + out +} + +const STANDARD_ENTRIES: Entries = &[ + (32, b"space"), + (33, b"exclam"), + (34, b"quotedbl"), + (35, b"numbersign"), + (36, b"dollar"), + (37, b"percent"), + (38, b"ampersand"), + (39, b"quoteright"), + (40, b"parenleft"), + (41, b"parenright"), + (42, b"asterisk"), + (43, b"plus"), + (44, b"comma"), + (45, b"hyphen"), + (46, b"period"), + (47, b"slash"), + (48, b"zero"), + (49, b"one"), + (50, b"two"), + (51, b"three"), + (52, b"four"), + (53, b"five"), + (54, b"six"), + (55, b"seven"), + (56, b"eight"), + (57, b"nine"), + (58, b"colon"), + (59, b"semicolon"), + (60, b"less"), + (61, b"equal"), + (62, b"greater"), + (63, b"question"), + (64, b"at"), + (65, b"A"), + (66, b"B"), + (67, b"C"), + (68, b"D"), + (69, b"E"), + (70, b"F"), + (71, b"G"), + (72, b"H"), + (73, b"I"), + (74, b"J"), + (75, b"K"), + (76, b"L"), + (77, b"M"), + (78, b"N"), + (79, b"O"), + (80, b"P"), + (81, b"Q"), + (82, b"R"), + (83, b"S"), + (84, b"T"), + (85, b"U"), + (86, b"V"), + (87, b"W"), + (88, b"X"), + (89, b"Y"), + (90, b"Z"), + (91, b"bracketleft"), + (92, b"backslash"), + (93, b"bracketright"), + (94, b"asciicircum"), + (95, b"underscore"), + (96, b"quoteleft"), + (97, b"a"), + (98, b"b"), + (99, b"c"), + (100, b"d"), + (101, b"e"), + (102, b"f"), + (103, b"g"), + (104, b"h"), + (105, b"i"), + (106, b"j"), + (107, b"k"), + (108, b"l"), + (109, b"m"), + (110, b"n"), + (111, b"o"), + (112, b"p"), + (113, b"q"), + (114, b"r"), + (115, b"s"), + (116, b"t"), + (117, b"u"), + (118, b"v"), + (119, b"w"), + (120, b"x"), + (121, b"y"), + (122, b"z"), + (123, b"braceleft"), + (124, b"bar"), + (125, b"braceright"), + (126, b"asciitilde"), + (161, b"exclamdown"), + (162, b"cent"), + (163, b"sterling"), + (164, b"fraction"), + (165, b"yen"), + (166, b"florin"), + (167, b"section"), + (168, b"currency"), + (169, b"quotesingle"), + (170, b"quotedblleft"), + (171, b"guillemotleft"), + (172, b"guilsinglleft"), + (173, b"guilsinglright"), + (174, b"fi"), + (175, b"fl"), + (177, b"endash"), + (178, b"dagger"), + (179, b"daggerdbl"), + (180, b"periodcentered"), + (182, b"paragraph"), + (183, b"bullet"), + (184, b"quotesinglbase"), + (185, b"quotedblbase"), + (186, b"quotedblright"), + (187, b"guillemotright"), + (188, b"ellipsis"), + (189, b"perthousand"), + (191, b"questiondown"), + (193, b"grave"), + (194, b"acute"), + (195, b"circumflex"), + (196, b"tilde"), + (197, b"macron"), + (198, b"breve"), + (199, b"dotaccent"), + (200, b"dieresis"), + (202, b"ring"), + (203, b"cedilla"), + (205, b"hungarumlaut"), + (206, b"ogonek"), + (207, b"caron"), + (208, b"emdash"), + (225, b"AE"), + (227, b"ordfeminine"), + (232, b"Lslash"), + (233, b"Oslash"), + (234, b"OE"), + (235, b"ordmasculine"), + (241, b"ae"), + (245, b"dotlessi"), + (248, b"lslash"), + (249, b"oslash"), + (250, b"oe"), + (251, b"germandbls"), +]; + +pub(crate) static STANDARD: [Option<&[u8]>; 256] = build(STANDARD_ENTRIES); + +const WIN_ANSI_ENTRIES: Entries = &[ + (32, b"space"), + (33, b"exclam"), + (34, b"quotedbl"), + (35, b"numbersign"), + (36, b"dollar"), + (37, b"percent"), + (38, b"ampersand"), + (39, b"quotesingle"), + (40, b"parenleft"), + (41, b"parenright"), + (42, b"asterisk"), + (43, b"plus"), + (44, b"comma"), + (45, b"hyphen"), + (46, b"period"), + (47, b"slash"), + (48, b"zero"), + (49, b"one"), + (50, b"two"), + (51, b"three"), + (52, b"four"), + (53, b"five"), + (54, b"six"), + (55, b"seven"), + (56, b"eight"), + (57, b"nine"), + (58, b"colon"), + (59, b"semicolon"), + (60, b"less"), + (61, b"equal"), + (62, b"greater"), + (63, b"question"), + (64, b"at"), + (65, b"A"), + (66, b"B"), + (67, b"C"), + (68, b"D"), + (69, b"E"), + (70, b"F"), + (71, b"G"), + (72, b"H"), + (73, b"I"), + (74, b"J"), + (75, b"K"), + (76, b"L"), + (77, b"M"), + (78, b"N"), + (79, b"O"), + (80, b"P"), + (81, b"Q"), + (82, b"R"), + (83, b"S"), + (84, b"T"), + (85, b"U"), + (86, b"V"), + (87, b"W"), + (88, b"X"), + (89, b"Y"), + (90, b"Z"), + (91, b"bracketleft"), + (92, b"backslash"), + (93, b"bracketright"), + (94, b"asciicircum"), + (95, b"underscore"), + (96, b"grave"), + (97, b"a"), + (98, b"b"), + (99, b"c"), + (100, b"d"), + (101, b"e"), + (102, b"f"), + (103, b"g"), + (104, b"h"), + (105, b"i"), + (106, b"j"), + (107, b"k"), + (108, b"l"), + (109, b"m"), + (110, b"n"), + (111, b"o"), + (112, b"p"), + (113, b"q"), + (114, b"r"), + (115, b"s"), + (116, b"t"), + (117, b"u"), + (118, b"v"), + (119, b"w"), + (120, b"x"), + (121, b"y"), + (122, b"z"), + (123, b"braceleft"), + (124, b"bar"), + (125, b"braceright"), + (126, b"asciitilde"), + (128, b"Euro"), + (130, b"quotesinglbase"), + (131, b"florin"), + (132, b"quotedblbase"), + (133, b"ellipsis"), + (134, b"dagger"), + (135, b"daggerdbl"), + (136, b"circumflex"), + (137, b"perthousand"), + (138, b"Scaron"), + (139, b"guilsinglleft"), + (140, b"OE"), + (142, b"Zcaron"), + (145, b"quoteleft"), + (146, b"quoteright"), + (147, b"quotedblleft"), + (148, b"quotedblright"), + (149, b"bullet"), + (150, b"endash"), + (151, b"emdash"), + (152, b"tilde"), + (153, b"trademark"), + (154, b"scaron"), + (155, b"guilsinglright"), + (156, b"oe"), + (158, b"zcaron"), + (159, b"Ydieresis"), + (160, b"space"), + (161, b"exclamdown"), + (162, b"cent"), + (163, b"sterling"), + (164, b"currency"), + (165, b"yen"), + (166, b"brokenbar"), + (167, b"section"), + (168, b"dieresis"), + (169, b"copyright"), + (170, b"ordfeminine"), + (171, b"guillemotleft"), + (172, b"logicalnot"), + (173, b"hyphen"), + (174, b"registered"), + (175, b"macron"), + (176, b"degree"), + (177, b"plusminus"), + (178, b"twosuperior"), + (179, b"threesuperior"), + (180, b"acute"), + (181, b"mu"), + (182, b"paragraph"), + (183, b"periodcentered"), + (184, b"cedilla"), + (185, b"onesuperior"), + (186, b"ordmasculine"), + (187, b"guillemotright"), + (188, b"onequarter"), + (189, b"onehalf"), + (190, b"threequarters"), + (191, b"questiondown"), + (192, b"Agrave"), + (193, b"Aacute"), + (194, b"Acircumflex"), + (195, b"Atilde"), + (196, b"Adieresis"), + (197, b"Aring"), + (198, b"AE"), + (199, b"Ccedilla"), + (200, b"Egrave"), + (201, b"Eacute"), + (202, b"Ecircumflex"), + (203, b"Edieresis"), + (204, b"Igrave"), + (205, b"Iacute"), + (206, b"Icircumflex"), + (207, b"Idieresis"), + (208, b"Eth"), + (209, b"Ntilde"), + (210, b"Ograve"), + (211, b"Oacute"), + (212, b"Ocircumflex"), + (213, b"Otilde"), + (214, b"Odieresis"), + (215, b"multiply"), + (216, b"Oslash"), + (217, b"Ugrave"), + (218, b"Uacute"), + (219, b"Ucircumflex"), + (220, b"Udieresis"), + (221, b"Yacute"), + (222, b"Thorn"), + (223, b"germandbls"), + (224, b"agrave"), + (225, b"aacute"), + (226, b"acircumflex"), + (227, b"atilde"), + (228, b"adieresis"), + (229, b"aring"), + (230, b"ae"), + (231, b"ccedilla"), + (232, b"egrave"), + (233, b"eacute"), + (234, b"ecircumflex"), + (235, b"edieresis"), + (236, b"igrave"), + (237, b"iacute"), + (238, b"icircumflex"), + (239, b"idieresis"), + (240, b"eth"), + (241, b"ntilde"), + (242, b"ograve"), + (243, b"oacute"), + (244, b"ocircumflex"), + (245, b"otilde"), + (246, b"odieresis"), + (247, b"divide"), + (248, b"oslash"), + (249, b"ugrave"), + (250, b"uacute"), + (251, b"ucircumflex"), + (252, b"udieresis"), + (253, b"yacute"), + (254, b"thorn"), + (255, b"ydieresis"), +]; + +pub(crate) static WIN_ANSI: [Option<&[u8]>; 256] = build(WIN_ANSI_ENTRIES); + +const MAC_ROMAN_ENTRIES: Entries = &[ + (32, b"space"), + (33, b"exclam"), + (34, b"quotedbl"), + (35, b"numbersign"), + (36, b"dollar"), + (37, b"percent"), + (38, b"ampersand"), + (39, b"quotesingle"), + (40, b"parenleft"), + (41, b"parenright"), + (42, b"asterisk"), + (43, b"plus"), + (44, b"comma"), + (45, b"hyphen"), + (46, b"period"), + (47, b"slash"), + (48, b"zero"), + (49, b"one"), + (50, b"two"), + (51, b"three"), + (52, b"four"), + (53, b"five"), + (54, b"six"), + (55, b"seven"), + (56, b"eight"), + (57, b"nine"), + (58, b"colon"), + (59, b"semicolon"), + (60, b"less"), + (61, b"equal"), + (62, b"greater"), + (63, b"question"), + (64, b"at"), + (65, b"A"), + (66, b"B"), + (67, b"C"), + (68, b"D"), + (69, b"E"), + (70, b"F"), + (71, b"G"), + (72, b"H"), + (73, b"I"), + (74, b"J"), + (75, b"K"), + (76, b"L"), + (77, b"M"), + (78, b"N"), + (79, b"O"), + (80, b"P"), + (81, b"Q"), + (82, b"R"), + (83, b"S"), + (84, b"T"), + (85, b"U"), + (86, b"V"), + (87, b"W"), + (88, b"X"), + (89, b"Y"), + (90, b"Z"), + (91, b"bracketleft"), + (92, b"backslash"), + (93, b"bracketright"), + (94, b"asciicircum"), + (95, b"underscore"), + (96, b"grave"), + (97, b"a"), + (98, b"b"), + (99, b"c"), + (100, b"d"), + (101, b"e"), + (102, b"f"), + (103, b"g"), + (104, b"h"), + (105, b"i"), + (106, b"j"), + (107, b"k"), + (108, b"l"), + (109, b"m"), + (110, b"n"), + (111, b"o"), + (112, b"p"), + (113, b"q"), + (114, b"r"), + (115, b"s"), + (116, b"t"), + (117, b"u"), + (118, b"v"), + (119, b"w"), + (120, b"x"), + (121, b"y"), + (122, b"z"), + (123, b"braceleft"), + (124, b"bar"), + (125, b"braceright"), + (126, b"asciitilde"), + (128, b"Adieresis"), + (129, b"Aring"), + (130, b"Ccedilla"), + (131, b"Eacute"), + (132, b"Ntilde"), + (133, b"Odieresis"), + (134, b"Udieresis"), + (135, b"aacute"), + (136, b"agrave"), + (137, b"acircumflex"), + (138, b"adieresis"), + (139, b"atilde"), + (140, b"aring"), + (141, b"ccedilla"), + (142, b"eacute"), + (143, b"egrave"), + (144, b"ecircumflex"), + (145, b"edieresis"), + (146, b"iacute"), + (147, b"igrave"), + (148, b"icircumflex"), + (149, b"idieresis"), + (150, b"ntilde"), + (151, b"oacute"), + (152, b"ograve"), + (153, b"ocircumflex"), + (154, b"odieresis"), + (155, b"otilde"), + (156, b"uacute"), + (157, b"ugrave"), + (158, b"ucircumflex"), + (159, b"udieresis"), + (160, b"dagger"), + (161, b"degree"), + (162, b"cent"), + (163, b"sterling"), + (164, b"section"), + (165, b"bullet"), + (166, b"paragraph"), + (167, b"germandbls"), + (168, b"registered"), + (169, b"copyright"), + (170, b"trademark"), + (171, b"acute"), + (172, b"dieresis"), + (174, b"AE"), + (175, b"Oslash"), + (177, b"plusminus"), + (180, b"yen"), + (181, b"mu"), + (187, b"ordfeminine"), + (188, b"ordmasculine"), + (190, b"ae"), + (191, b"oslash"), + (192, b"questiondown"), + (193, b"exclamdown"), + (194, b"logicalnot"), + (196, b"florin"), + (199, b"guillemotleft"), + (200, b"guillemotright"), + (201, b"ellipsis"), + (202, b"space"), + (203, b"Agrave"), + (204, b"Atilde"), + (205, b"Otilde"), + (206, b"OE"), + (207, b"oe"), + (208, b"endash"), + (209, b"emdash"), + (210, b"quotedblleft"), + (211, b"quotedblright"), + (212, b"quoteleft"), + (213, b"quoteright"), + (214, b"divide"), + (216, b"ydieresis"), + (217, b"Ydieresis"), + (218, b"fraction"), + (219, b"currency"), + (220, b"guilsinglleft"), + (221, b"guilsinglright"), + (222, b"fi"), + (223, b"fl"), + (224, b"daggerdbl"), + (225, b"periodcentered"), + (226, b"quotesinglbase"), + (227, b"quotedblbase"), + (228, b"perthousand"), + (229, b"Acircumflex"), + (230, b"Ecircumflex"), + (231, b"Aacute"), + (232, b"Edieresis"), + (233, b"Egrave"), + (234, b"Iacute"), + (235, b"Icircumflex"), + (236, b"Idieresis"), + (237, b"Igrave"), + (238, b"Oacute"), + (239, b"Ocircumflex"), + (241, b"Ograve"), + (242, b"Uacute"), + (243, b"Ucircumflex"), + (244, b"Ugrave"), + (245, b"dotlessi"), + (246, b"circumflex"), + (247, b"tilde"), + (248, b"macron"), + (249, b"breve"), + (250, b"dotaccent"), + (251, b"ring"), + (252, b"cedilla"), + (253, b"hungarumlaut"), + (254, b"ogonek"), + (255, b"caron"), +]; + +pub(crate) static MAC_ROMAN: [Option<&[u8]>; 256] = build(MAC_ROMAN_ENTRIES); + +const MAC_EXPERT_ENTRIES: Entries = &[ + (32, b"space"), + (33, b"exclamsmall"), + (34, b"Hungarumlautsmall"), + (35, b"centoldstyle"), + (36, b"dollaroldstyle"), + (37, b"dollarsuperior"), + (38, b"ampersandsmall"), + (39, b"Acutesmall"), + (40, b"parenleftsuperior"), + (41, b"parenrightsuperior"), + (42, b"twodotenleader"), + (43, b"onedotenleader"), + (44, b"comma"), + (45, b"hyphen"), + (46, b"period"), + (47, b"fraction"), + (48, b"zerooldstyle"), + (49, b"oneoldstyle"), + (50, b"twooldstyle"), + (51, b"threeoldstyle"), + (52, b"fouroldstyle"), + (53, b"fiveoldstyle"), + (54, b"sixoldstyle"), + (55, b"sevenoldstyle"), + (56, b"eightoldstyle"), + (57, b"nineoldstyle"), + (58, b"colon"), + (59, b"semicolon"), + (61, b"threequartersemdash"), + (63, b"questionsmall"), + (68, b"Ethsmall"), + (71, b"onequarter"), + (72, b"onehalf"), + (73, b"threequarters"), + (74, b"oneeighth"), + (75, b"threeeighths"), + (76, b"fiveeighths"), + (77, b"seveneighths"), + (78, b"onethird"), + (79, b"twothirds"), + (86, b"ff"), + (87, b"fi"), + (88, b"fl"), + (89, b"ffi"), + (90, b"ffl"), + (91, b"parenleftinferior"), + (93, b"parenrightinferior"), + (94, b"Circumflexsmall"), + (95, b"hypheninferior"), + (96, b"Gravesmall"), + (97, b"Asmall"), + (98, b"Bsmall"), + (99, b"Csmall"), + (100, b"Dsmall"), + (101, b"Esmall"), + (102, b"Fsmall"), + (103, b"Gsmall"), + (104, b"Hsmall"), + (105, b"Ismall"), + (106, b"Jsmall"), + (107, b"Ksmall"), + (108, b"Lsmall"), + (109, b"Msmall"), + (110, b"Nsmall"), + (111, b"Osmall"), + (112, b"Psmall"), + (113, b"Qsmall"), + (114, b"Rsmall"), + (115, b"Ssmall"), + (116, b"Tsmall"), + (117, b"Usmall"), + (118, b"Vsmall"), + (119, b"Wsmall"), + (120, b"Xsmall"), + (121, b"Ysmall"), + (122, b"Zsmall"), + (123, b"colonmonetary"), + (124, b"onefitted"), + (125, b"rupiah"), + (126, b"Tildesmall"), + (129, b"asuperior"), + (130, b"centsuperior"), + (135, b"Aacutesmall"), + (136, b"Agravesmall"), + (137, b"Acircumflexsmall"), + (138, b"Adieresissmall"), + (139, b"Atildesmall"), + (140, b"Aringsmall"), + (141, b"Ccedillasmall"), + (142, b"Eacutesmall"), + (143, b"Egravesmall"), + (144, b"Ecircumflexsmall"), + (145, b"Edieresissmall"), + (146, b"Iacutesmall"), + (147, b"Igravesmall"), + (148, b"Icircumflexsmall"), + (149, b"Idieresissmall"), + (150, b"Ntildesmall"), + (151, b"Oacutesmall"), + (152, b"Ogravesmall"), + (153, b"Ocircumflexsmall"), + (154, b"Odieresissmall"), + (155, b"Otildesmall"), + (156, b"Uacutesmall"), + (157, b"Ugravesmall"), + (158, b"Ucircumflexsmall"), + (159, b"Udieresissmall"), + (161, b"eightsuperior"), + (162, b"fourinferior"), + (163, b"threeinferior"), + (164, b"sixinferior"), + (165, b"eightinferior"), + (166, b"seveninferior"), + (167, b"Scaronsmall"), + (169, b"centinferior"), + (170, b"twoinferior"), + (172, b"Dieresissmall"), + (174, b"Caronsmall"), + (175, b"osuperior"), + (176, b"fiveinferior"), + (178, b"commainferior"), + (179, b"periodinferior"), + (180, b"Yacutesmall"), + (182, b"dollarinferior"), + (185, b"Thornsmall"), + (187, b"nineinferior"), + (188, b"zeroinferior"), + (189, b"Zcaronsmall"), + (190, b"AEsmall"), + (191, b"Oslashsmall"), + (192, b"questiondownsmall"), + (193, b"oneinferior"), + (194, b"Lslashsmall"), + (201, b"Cedillasmall"), + (207, b"OEsmall"), + (208, b"figuredash"), + (209, b"hyphensuperior"), + (214, b"exclamdownsmall"), + (216, b"Ydieresissmall"), + (218, b"onesuperior"), + (219, b"twosuperior"), + (220, b"threesuperior"), + (221, b"foursuperior"), + (222, b"fivesuperior"), + (223, b"sixsuperior"), + (224, b"sevensuperior"), + (225, b"ninesuperior"), + (226, b"zerosuperior"), + (228, b"esuperior"), + (229, b"rsuperior"), + (230, b"tsuperior"), + (233, b"isuperior"), + (234, b"ssuperior"), + (235, b"dsuperior"), + (241, b"lsuperior"), + (242, b"Ogoneksmall"), + (243, b"Brevesmall"), + (244, b"Macronsmall"), + (245, b"bsuperior"), + (246, b"nsuperior"), + (247, b"msuperior"), + (248, b"commasuperior"), + (249, b"periodsuperior"), + (250, b"Dotaccentsmall"), + (251, b"Ringsmall"), +]; + +pub(crate) static MAC_EXPERT: [Option<&[u8]>; 256] = build(MAC_EXPERT_ENTRIES); From f94668f1c8c78c8cf024a2b29375336607a1f76d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 26 Oct 2026 16:01:32 -0400 Subject: [PATCH 028/246] fix(sonar): clear css:S9441 and S3776 in forms actions --- assets/demo/showcase.html | 42 +++++++++++------------ crates/forms/src/actions.rs | 67 +++++++++++++++++++++++-------------- 2 files changed, 63 insertions(+), 46 deletions(-) diff --git a/assets/demo/showcase.html b/assets/demo/showcase.html index b26f24565a..3b14ea3b47 100644 --- a/assets/demo/showcase.html +++ b/assets/demo/showcase.html @@ -14,27 +14,27 @@ (highlight, sticky note, stamp, ...), so annotation geometry follows the layout. --> \n\n\n", + esc(title) + ); + for it in items(pages) { + match it { + Item::Para(runs, lvl) => { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + let mut t = String::new(); + for r in &runs { + let mut run = esc(&r.text); + if r.block.italic { + run = format!("{run}"); + } + if r.block.bold && lvl == 0 { + run = format!("{run}"); + } + // The paragraph has its first run's colour; a run in another colour says so. + let colour = rgb8(r.block.color); + if colour != rgb8(first.color) { + let [red, green, blue] = colour; + run = format!("{run}"); + } + t.push_str(&run); + } + let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + match lvl { + 0 => s.push_str(&format!("{t}

\n")), + l => s.push_str(&format!("{t}\n")), + } + } + Item::Table(t) => { + s.push_str("\n"); + for row in &t.rows { + s.push_str(""); + for c in row { + let mut body = esc(&c.text); + if c.italic { + body = format!("{body}"); + } + if c.bold { + body = format!("{body}"); + } + let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; + let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + s.push_str(&format!("{body}")); + } + s.push_str("\n"); + } + s.push_str("
\n"); + } + Item::Img(im) => { + let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; + s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); + } + Item::PageBreak => s.push_str("
\n"), + } + } + s.push_str("\n\n"); + s +} diff --git a/crates/export/src/as_rtf.rs b/crates/export/src/as_rtf.rs new file mode 100644 index 0000000000..d7495df135 --- /dev/null +++ b/crates/export/src/as_rtf.rs @@ -0,0 +1,127 @@ +use super::as_docx::rgb8; +use super::as_html::allowed; +use super::layout::{Block, Item, Page, count_lookup_steps, items}; +use super::*; + +fn rtf_text(s: &str) -> String { + let mut o = String::new(); + for c in s.chars().filter(|c| allowed(*c)) { + match c { + '\\' | '{' | '}' => { + o.push('\\'); + o.push(c); + } + c if (c as u32) < 128 => o.push(c), + c => { + // \uN with a ? fallback; UTF-16 units as signed 16-bit numbers. + let mut buf = [0u16; 2]; + for u in c.encode_utf16(&mut buf) { + o.push_str(&format!("\\u{}?", *u as i16)); + } + } + } + } + o +} + +/// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks +/// between pages (images are left out). +pub fn rtf(pages: &[Page]) -> String { + let items = items(pages); + // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. + let mut colors: Vec<[u8; 3]> = Vec::new(); + let mut slot: HashMap<[u8; 3], usize> = HashMap::new(); + let mut note = |c: [f64; 3]| { + let c = rgb8(c); + count_lookup_steps(1); + if c != [0, 0, 0] && !slot.contains_key(&c) { + slot.insert(c, colors.len()); + colors.push(c); + } + }; + for it in &items { + match it { + Item::Para(runs, _) => runs.iter().for_each(|r| note(r.block.color)), + Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), + Item::Img(_) | Item::PageBreak => {} + } + } + // `\cfN` for a colour (nothing for black, which keeps the default). + let cf = |c: [f64; 3]| { + let c = rgb8(c); + count_lookup_steps(1); + slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) + }; + let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); + if !colors.is_empty() { + s.push_str("{\\colortbl;"); + for [r, g, b] in &colors { + s.push_str(&format!("\\red{r}\\green{g}\\blue{b};")); + } + s.push_str("}\n"); + } + for it in items { + match it { + Item::Para(runs, _) => { + // The first run's formatting is the paragraph's; each later run is a group that + // turns on or off what differs from it. + let fmt = |b: &Block, later: bool| { + let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); + if b.bold { + fmt.push_str("\\b"); + } else if later { + fmt.push_str("\\b0"); + } + if b.italic { + fmt.push_str("\\i"); + } else if later { + fmt.push_str("\\i0"); + } + let colour = cf(b.color); + fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour }); + fmt + }; + let mut runs = runs.iter(); + let Some(first) = runs.next() else { continue }; + s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text))); + for r in runs { + s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text))); + } + s.push_str("\\par}\n"); + } + Item::Table(t) => { + let mut edges = t.cols.clone(); + edges.push(t.rect[2]); + let cellx: Vec = edges.iter().skip(1).map(|e| (e * 20.0).round() as i64).collect(); + for row in &t.rows { + s.push_str("\\trowd\\trgaph108"); + // The row's cell boundaries come first: each cell ends at the right edge of + // the last grid column it spans. + let mut column = 0; + for c in row { + column += c.span.max(1); + if let Some(x) = cellx.get(column.min(cellx.len()).saturating_sub(1)) { + s.push_str(&format!("\\cellx{x}")); + } + } + for c in row { + let mut fmt = format!("\\intbl\\fs{}", (c.size * 2.0).round() as i64); + if c.bold { + fmt.push_str("\\b"); + } + if c.italic { + fmt.push_str("\\i"); + } + fmt.push_str(&cf(c.color)); + s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text))); + } + s.push_str("\\row\n"); + } + } + Item::Img(_) => {} + Item::PageBreak => s.push_str("\\page\n"), + } + } + s.push('}'); + s +} diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs new file mode 100644 index 0000000000..8a06050898 --- /dev/null +++ b/crates/export/src/layout.rs @@ -0,0 +1,468 @@ +use super::*; + +/// A paragraph of a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Block { + pub text: String, + /// [x0, y0, x1, y1] on the displayed page in points (y up). + pub rect: [f64; 4], + /// Font size in points. + pub size: f64, + pub bold: bool, + pub italic: bool, + /// Text fill colour as sRGB components in 0..=1 (black when unknown). + pub color: [f64; 3], + /// Counterclockwise baseline angle in user space (degrees). + pub rotation: f64, + /// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`, + /// which `text` leaves out. A block that continues this one on its line is a run of the same + /// paragraph, and these say whether a space separates them (#4158). + pub space_around: [bool; 2], +} + +/// An image of a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + /// "png" or "jpg". + pub ext: &'static str, + /// Shared: a picture drawn in many places is held once. + pub bytes: std::sync::Arc<[u8]>, + pub rect: [f64; 4], + /// Width and height before rotation, in points. + pub size: [f64; 2], + /// Counterclockwise rotation in user space (degrees). + pub rotation: f64, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Page { + pub width: f64, + pub height: f64, + pub blocks: Vec, + pub images: Vec, + /// The colour the whole page is painted before its content (a page background), as sRGB + /// components in 0..=1; `None` for a page with no background of its own. + pub background: Option<[f64; 3]>, +} + +/// One cell of a detected table. `span` is how many grid columns the cell covers (a header +/// row that stretches over sub-columns). +#[derive(Clone, Debug, PartialEq)] +pub struct Cell { + pub text: String, + pub size: f64, + pub bold: bool, + pub italic: bool, + /// Text fill colour as sRGB components in 0..=1. + pub color: [f64; 3], + pub span: usize, +} + +/// A table detected from the page's text layout: rows of cells, and the left edge of each +/// grid column (user space, y up) so the writers can size the columns. +#[derive(Clone, Debug, PartialEq)] +pub struct Table { + pub rect: [f64; 4], + pub cols: Vec, + pub rows: Vec>, +} + +/// What goes into the output, in order. +pub(crate) enum Item<'a> { + /// A paragraph's runs (at least one) and its heading level. + Para(Vec>, u8), + /// Owned: tables are detected per page inside [`items`], so they cannot borrow. + Table(Table), + Img(&'a Image), + PageBreak, +} + +#[cfg(test)] +thread_local! { + /// Entries read to find a font size's or a colour's slot, for the tests that keep it constant. + pub(crate) static LOOKUP_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` entries read by a lookup (tests only). +#[inline] +pub(crate) fn count_lookup_steps(_n: usize) { + #[cfg(test)] + LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n))); +} + +/// The body text size: the size most of the document's characters are set in. +pub(crate) fn body_size(pages: &[Page]) -> f64 { + // Sizes in first-seen order (which breaks ties), and where each one is in it. + let mut by: Vec<(i64, usize)> = Vec::new(); + let mut slot: HashMap = HashMap::new(); + for b in pages.iter().flat_map(|p| &p.blocks) { + let k = (b.size * 2.0).round() as i64; + count_lookup_steps(1); + let i = *slot.entry(k).or_insert_with(|| { + by.push((k, 0)); + by.len() - 1 + }); + if let Some(e) = by.get_mut(i) { + e.1 = e.1.saturating_add(b.text.len()); + } + } + by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) +} + +/// Heading level for a block: 1 for ≥ 1.6× the body size, 2 for ≥ 1.25×, 0 for body text. +fn level(b: &Block, body: f64) -> u8 { + let short = b.text.len() < 200; + if short && b.size >= body * 1.6 { + 1 + } else if short && (b.size >= body * 1.25 || (b.bold && b.size >= body && b.text.len() < 80 && !b.text.ends_with('.'))) { + 2 + } else { + 0 + } +} + +/// How far two cell left edges may drift (points) and still be the same grid column. +const COL_TOL: f64 = 4.0; + +/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or +/// more columns doesn't open. It also bounds a table's size: every row is materialized to all +/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make +/// (blocks / 2)² cells. +pub(crate) const MAX_COLS: usize = 63; + +/// Two blocks in a row this close (as a fraction of their font size) are one line of running +/// text that changed font or colour, such as a link inside a sentence, not two table cells. +const INLINE_GAP: f64 = 0.5; + +/// How many blocks ending near a block's start are looked at to find the one it continues; a +/// page laid out so that many end at the same place stays quick. +const CONTINUATION_PROBE: usize = 64; + +/// Per block, whether it continues another on the same line: it starts where that one ends (up to +/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running +/// on (a link inside a sentence, a superscript), not a cell of its own. +fn continuations(blocks: &[Block]) -> Vec { + continued_from(blocks).into_iter().map(|from| from.is_some()).collect() +} + +/// Per block, the block it continues on the same line (see [`continuations`]): of those it could +/// continue, the one that ends nearest to where it starts. +fn continued_from(blocks: &[Block]) -> Vec> { + let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); + by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); + blocks + .iter() + .enumerate() + .map(|(bi, b)| { + let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); + by_end + .get(k..) + .unwrap_or_default() + .iter() + .take_while(|(end, _)| *end <= b.rect[0] + 1.0) + .take(CONTINUATION_PROBE) + .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) + .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) + .map(|(ai, _)| ai) + }) + .collect() +} + +/// A gap between two runs wider than this (as a fraction of the font size) is a word space the +/// text left out. It is the gap the reading-block grouping uses for the same purpose. +const WORD_GAP: f64 = 0.12; + +/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space +/// before the next run when the source had one there or the page shows a gap, but the text has +/// none). +pub(crate) struct Run<'a> { + pub(crate) block: &'a Block, + pub(crate) text: String, +} + +/// A page's paragraphs, each a list of runs from left to right along its line. A block that +/// continues another on the same line ([`continued_from`]: an italic case name, a link, a +/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its +/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join +/// nothing. Paragraphs come in the order of their first runs in `blocks`. +pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec>> { + let skipped = |i: usize| skip.get(i).copied().unwrap_or(false); + let from = continued_from(blocks); + // Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s + // order, NaN included), so that one is visited first and has its paragraph already, and the + // links cannot form a cycle. + let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect(); + order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); + // Each block's paragraph, named by its first block, and each paragraph's blocks in order. + let mut head: Vec = (0..blocks.len()).collect(); + let mut members: Vec> = vec![Vec::new(); blocks.len()]; + for &(x0, bi) in &order { + let Some(b) = blocks.get(bi) else { continue }; + let joined = from + .get(bi) + .copied() + .flatten() + .filter(|ai| !skipped(*ai)) + .filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1)) + .and_then(|ai| head.get(ai).copied()); + let h = joined.unwrap_or(bi); + if let Some(slot) = head.get_mut(bi) { + *slot = h; + } + if let Some(m) = members.get_mut(h) { + m.push(bi); + } + } + members + .into_iter() + .filter(|m| !m.is_empty()) + .map(|m| { + let mut runs: Vec> = Vec::with_capacity(m.len()); + for b in m.into_iter().filter_map(|bi| blocks.get(bi)) { + if let Some(prev) = runs.last_mut() + && !prev.text.ends_with(char::is_whitespace) + && !b.text.starts_with(char::is_whitespace) + && (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP) + { + prev.text.push(' '); + } + runs.push(Run { block: b, text: b.text.clone() }); + } + runs + }) + .collect() +} + +/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold +/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run +/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. +fn para_level(runs: &[Run], body: f64) -> u8 { + let Some(first) = runs.first() else { return 0 }; + let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3; + runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0) +} + +/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. +fn is_cell_like(b: &Block) -> bool { + (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() +} + +/// Detect tables in a page's blocks from their text layout: columns are left edges shared by +/// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and, +/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). +pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) { + let mut consumed = vec![false; blocks.len()]; + // Candidate columns: cluster the left edges of cell-like blocks. + let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) + for (x, i) in xs { + match cols.last_mut() { + Some((e, m)) if (x - *e).abs() <= COL_TOL => { + *e = (*e * m.len() as f64 + x) / (m.len() + 1) as f64; + m.push(i); + } + _ => cols.push((x, vec![i])), + } + } + let cols: Vec<(f64, Vec)> = cols.into_iter().filter(|(_, m)| m.len() >= 2).collect(); + if cols.len() < 2 { + return (Vec::new(), consumed); + } + // Each block's column (an index into `cols`), so lookups stay linear on pages with many blocks. + let mut col_of: Vec> = vec![None; blocks.len()]; + for (ci, (_, members)) in cols.iter().enumerate() { + for &i in members { + if let Some(c) = col_of.get_mut(i) { + *c = Some(ci); + } + } + } + // The column whose edge is within tolerance of `x` (edges are in ascending order). + let column_at = |x: f64| -> Option { + let k = cols.partition_point(|(e, _)| *e < x - COL_TOL); + cols.get(k).filter(|(e, _)| (*e - x).abs() <= COL_TOL).map(|_| k) + }; + // Assign every cell-like block to its nearest column (within tolerance). + let mut rows: Vec> = Vec::new(); // (column index, block index), top to bottom + let mut order: Vec = (0..blocks.len()).collect(); + order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); + for &bi in &order { + let b = &blocks[bi]; + if !is_cell_like(b) { + continue; + } + let Some(ci) = column_at(b.rect[0]) else { continue }; + let joins = rows + .last() + .and_then(|row| row.first()) + .map(|(_, fi)| { + let rb = &blocks[*fi]; + let overlap = rb.rect[3].min(b.rect[3]) - rb.rect[1].max(b.rect[1]); + overlap > 0.5 * (rb.rect[3] - rb.rect[1]).min(b.rect[3] - b.rect[1]) + }) + .unwrap_or(false); + if joins { + if let Some(row) = rows.last_mut() { + row.push((ci, bi)); + } + } else { + rows.push(vec![(ci, bi)]); + } + } + // Runs of consecutive rows that have two or more distinct columns are tables. + let mut out = Vec::new(); + let flush = |run: &mut Vec>, out: &mut Vec
, consumed: &mut Vec| { + if run.len() < 2 { + run.clear(); + return; + } + let mut used: Vec = run.iter().flatten().map(|(_, i)| *i).collect(); + used.sort_unstable(); + used.dedup(); + let mut edges: Vec = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); + edges.sort_by(f64::total_cmp); + edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); + let ncols = edges.len(); + let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS; + if !ok { + run.clear(); + return; + } + let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL; + let mut rows_out = Vec::new(); + let mut slots: Vec)>> = vec![None; ncols]; + for row in run.drain(..) { + for slot in slots.iter_mut() { + *slot = None; + } + for (_ci, bi) in row { + let b = &blocks[bi]; + let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; + let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; + let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span }; + match slots.get_mut(slot) { + Some(Some((c, sources))) => { + c.text.push(' '); + c.text.push_str(&cell.text); + sources.push(bi); + } + Some(empty @ None) => *empty = Some((cell, vec![bi])), + None => continue, + } + } + let mut materialized = Vec::with_capacity(ncols); + let mut column = 0; + while column < ncols { + match slots.get_mut(column).and_then(Option::take) { + Some((mut cell, sources)) => { + // A spanning cell can skip later slots. Only consume source blocks + // when their text is emitted, leaving covered text in paragraph flow. + for bi in sources { + if let Some(taken) = consumed.get_mut(bi) { + *taken = true; + } + } + cell.span = cell.span.min(ncols - column).max(1); + column += cell.span; + materialized.push(cell); + } + None => { + column += 1; + materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 }); + } + } + } + while materialized.last().is_some_and(|cell| cell.text.is_empty()) && materialized.iter().any(|cell| cell.span > 1) { + materialized.pop(); + } + rows_out.push(materialized); + } + let rect = [ + edges[0], + used.iter().map(|&i| blocks[i].rect[1]).fold(f64::MAX, f64::min), + right, + used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max), + ]; + out.push(Table { rect, cols: edges, rows: rows_out }); + }; + // Runs of consecutive multi-column rows are tables. Rows on either side of a run that + // hold a single cell wide enough to span two columns (a header band, a totals row) join + // the table; other single-column rows (captions, following text) stay paragraphs. + let spans_run = |edges: &[f64], bi: usize| -> bool { + let b = &blocks[bi]; + edges.iter().any(|e| (*e - b.rect[0]).abs() <= COL_TOL) && edges.iter().any(|e| *e > b.rect[0] + COL_TOL && *e < b.rect[2] - COL_TOL) + }; + let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec { + let mut es: Vec = + run.iter().flatten().filter_map(|(_, i)| col_of.get(*i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); + es.sort_by(f64::total_cmp); + es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); + es + }; + let cont = continuations(blocks); + let qual: Vec = rows + .iter() + .map(|row| { + // A block that continues another adds no column of its own, and the cells of a table row + // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). + let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); + let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); + spans.sort_by(|a, b| a[0].total_cmp(&b[0])); + let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); + let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); + cs.sort_unstable(); + cs.dedup(); + apart && cs.len() >= 2 + }) + .collect(); + let mut i = 0; + while i < rows.len() { + if !qual[i] { + i += 1; + continue; + } + let start = i; + while i < rows.len() && qual[i] { + i += 1; + } + let end = i; // run = rows[start..end] + let mut pieces: Vec> = Vec::new(); + if start > 0 && rows[start - 1].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[start - 1][0].1) { + pieces.push(rows[start - 1].clone()); + } + pieces.extend(rows[start..end].iter().cloned()); + if end < rows.len() && rows[end].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[end][0].1) { + pieces.push(rows[end].clone()); + } + flush(&mut pieces, &mut out, &mut consumed); + } + (out, consumed) +} + +pub(crate) fn items(pages: &[Page]) -> Vec> { + let body = body_size(pages); + let mut out = Vec::new(); + for (i, p) in pages.iter().enumerate() { + if i > 0 { + out.push(Item::PageBreak); + } + let (tables, consumed) = tables(&p.blocks); + // Blocks and images by their top edge, top to bottom. + // A paragraph sits where its first run does: its other runs follow along the same line. + let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) + .into_iter() + .filter_map(|runs| { + let first = runs.first()?.block; + let lvl = para_level(&runs, body); + Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) + }) + .collect(); + parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); + parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); + parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); + out.extend(parts.into_iter().map(|x| x.2)); + } + out +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index dac5ec5172..cf32ea7930 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -18,1052 +18,24 @@ mod zip; pub use pptx::pptx; pub use zip::Zip; -/// A paragraph of a page. -#[derive(Clone, Debug, PartialEq)] -pub struct Block { - pub text: String, - /// [x0, y0, x1, y1] on the displayed page in points (y up). - pub rect: [f64; 4], - /// Font size in points. - pub size: f64, - pub bold: bool, - pub italic: bool, - /// Text fill colour as sRGB components in 0..=1 (black when unknown). - pub color: [f64; 3], - /// Counterclockwise baseline angle in user space (degrees). - pub rotation: f64, - /// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`, - /// which `text` leaves out. A block that continues this one on its line is a run of the same - /// paragraph, and these say whether a space separates them (#4158). - pub space_around: [bool; 2], -} - -/// An image of a page. -#[derive(Clone, Debug, PartialEq)] -pub struct Image { - /// "png" or "jpg". - pub ext: &'static str, - /// Shared: a picture drawn in many places is held once. - pub bytes: std::sync::Arc<[u8]>, - pub rect: [f64; 4], - /// Width and height before rotation, in points. - pub size: [f64; 2], - /// Counterclockwise rotation in user space (degrees). - pub rotation: f64, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Page { - pub width: f64, - pub height: f64, - pub blocks: Vec, - pub images: Vec, - /// The colour the whole page is painted before its content (a page background), as sRGB - /// components in 0..=1; `None` for a page with no background of its own. - pub background: Option<[f64; 3]>, -} - -/// One cell of a detected table. `span` is how many grid columns the cell covers (a header -/// row that stretches over sub-columns). -#[derive(Clone, Debug, PartialEq)] -pub struct Cell { - pub text: String, - pub size: f64, - pub bold: bool, - pub italic: bool, - /// Text fill colour as sRGB components in 0..=1. - pub color: [f64; 3], - pub span: usize, -} - -/// A table detected from the page's text layout: rows of cells, and the left edge of each -/// grid column (user space, y up) so the writers can size the columns. -#[derive(Clone, Debug, PartialEq)] -pub struct Table { - pub rect: [f64; 4], - pub cols: Vec, - pub rows: Vec>, -} - -/// What goes into the output, in order. -enum Item<'a> { - /// A paragraph's runs (at least one) and its heading level. - Para(Vec>, u8), - /// Owned: tables are detected per page inside [`items`], so they cannot borrow. - Table(Table), - Img(&'a Image), - PageBreak, -} +mod as_docx; +mod as_html; +mod as_rtf; +mod layout; +pub use as_docx::docx; +pub(crate) use as_docx::hex; #[cfg(test)] -thread_local! { - /// Entries read to find a font size's or a colour's slot, for the tests that keep it constant. - static LOOKUP_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// Counts `n` entries read by a lookup (tests only). -#[inline] -fn count_lookup_steps(_n: usize) { - #[cfg(test)] - LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n))); -} - -/// The body text size: the size most of the document's characters are set in. -fn body_size(pages: &[Page]) -> f64 { - // Sizes in first-seen order (which breaks ties), and where each one is in it. - let mut by: Vec<(i64, usize)> = Vec::new(); - let mut slot: HashMap = HashMap::new(); - for b in pages.iter().flat_map(|p| &p.blocks) { - let k = (b.size * 2.0).round() as i64; - count_lookup_steps(1); - let i = *slot.entry(k).or_insert_with(|| { - by.push((k, 0)); - by.len() - 1 - }); - if let Some(e) = by.get_mut(i) { - e.1 = e.1.saturating_add(b.text.len()); - } - } - by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) -} - -/// Heading level for a block: 1 for ≥ 1.6× the body size, 2 for ≥ 1.25×, 0 for body text. -fn level(b: &Block, body: f64) -> u8 { - let short = b.text.len() < 200; - if short && b.size >= body * 1.6 { - 1 - } else if short && (b.size >= body * 1.25 || (b.bold && b.size >= body && b.text.len() < 80 && !b.text.ends_with('.'))) { - 2 - } else { - 0 - } -} - -/// How far two cell left edges may drift (points) and still be the same grid column. -const COL_TOL: f64 = 4.0; -/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or -/// more columns doesn't open. It also bounds a table's size: every row is materialized to all -/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make -/// (blocks / 2)² cells. -const MAX_COLS: usize = 63; -/// Two blocks in a row this close (as a fraction of their font size) are one line of running -/// text that changed font or colour, such as a link inside a sentence, not two table cells. -const INLINE_GAP: f64 = 0.5; -/// How many blocks ending near a block's start are looked at to find the one it continues; a -/// page laid out so that many end at the same place stays quick. -const CONTINUATION_PROBE: usize = 64; -/// Per block, whether it continues another on the same line: it starts where that one ends (up to -/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running -/// on (a link inside a sentence, a superscript), not a cell of its own. -fn continuations(blocks: &[Block]) -> Vec { - continued_from(blocks).into_iter().map(|from| from.is_some()).collect() -} - -/// Per block, the block it continues on the same line (see [`continuations`]): of those it could -/// continue, the one that ends nearest to where it starts. -fn continued_from(blocks: &[Block]) -> Vec> { - let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); - by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); - blocks - .iter() - .enumerate() - .map(|(bi, b)| { - let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); - by_end - .get(k..) - .unwrap_or_default() - .iter() - .take_while(|(end, _)| *end <= b.rect[0] + 1.0) - .take(CONTINUATION_PROBE) - .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) - .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) - .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) - .map(|(ai, _)| ai) - }) - .collect() -} - -/// A gap between two runs wider than this (as a fraction of the font size) is a word space the -/// text left out. It is the gap the reading-block grouping uses for the same purpose. -const WORD_GAP: f64 = 0.12; - -/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space -/// before the next run when the source had one there or the page shows a gap, but the text has -/// none). -pub(crate) struct Run<'a> { - pub(crate) block: &'a Block, - pub(crate) text: String, -} - -/// A page's paragraphs, each a list of runs from left to right along its line. A block that -/// continues another on the same line ([`continued_from`]: an italic case name, a link, a -/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its -/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join -/// nothing. Paragraphs come in the order of their first runs in `blocks`. -pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec>> { - let skipped = |i: usize| skip.get(i).copied().unwrap_or(false); - let from = continued_from(blocks); - // Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s - // order, NaN included), so that one is visited first and has its paragraph already, and the - // links cannot form a cycle. - let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect(); - order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); - // Each block's paragraph, named by its first block, and each paragraph's blocks in order. - let mut head: Vec = (0..blocks.len()).collect(); - let mut members: Vec> = vec![Vec::new(); blocks.len()]; - for &(x0, bi) in &order { - let Some(b) = blocks.get(bi) else { continue }; - let joined = from - .get(bi) - .copied() - .flatten() - .filter(|ai| !skipped(*ai)) - .filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1)) - .and_then(|ai| head.get(ai).copied()); - let h = joined.unwrap_or(bi); - if let Some(slot) = head.get_mut(bi) { - *slot = h; - } - if let Some(m) = members.get_mut(h) { - m.push(bi); - } - } - members - .into_iter() - .filter(|m| !m.is_empty()) - .map(|m| { - let mut runs: Vec> = Vec::with_capacity(m.len()); - for b in m.into_iter().filter_map(|bi| blocks.get(bi)) { - if let Some(prev) = runs.last_mut() - && !prev.text.ends_with(char::is_whitespace) - && !b.text.starts_with(char::is_whitespace) - && (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP) - { - prev.text.push(' '); - } - runs.push(Run { block: b, text: b.text.clone() }); - } - runs - }) - .collect() -} - -/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold -/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run -/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. -fn para_level(runs: &[Run], body: f64) -> u8 { - let Some(first) = runs.first() else { return 0 }; - let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3; - runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0) -} - -/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. -fn is_cell_like(b: &Block) -> bool { - (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() -} - -/// Detect tables in a page's blocks from their text layout: columns are left edges shared by -/// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and, -/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). -pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { - let mut consumed = vec![false; blocks.len()]; - // Candidate columns: cluster the left edges of cell-like blocks. - let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); - xs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) - for (x, i) in xs { - match cols.last_mut() { - Some((e, m)) if (x - *e).abs() <= COL_TOL => { - *e = (*e * m.len() as f64 + x) / (m.len() + 1) as f64; - m.push(i); - } - _ => cols.push((x, vec![i])), - } - } - let cols: Vec<(f64, Vec)> = cols.into_iter().filter(|(_, m)| m.len() >= 2).collect(); - if cols.len() < 2 { - return (Vec::new(), consumed); - } - // Each block's column (an index into `cols`), so lookups stay linear on pages with many blocks. - let mut col_of: Vec> = vec![None; blocks.len()]; - for (ci, (_, members)) in cols.iter().enumerate() { - for &i in members { - if let Some(c) = col_of.get_mut(i) { - *c = Some(ci); - } - } - } - // The column whose edge is within tolerance of `x` (edges are in ascending order). - let column_at = |x: f64| -> Option { - let k = cols.partition_point(|(e, _)| *e < x - COL_TOL); - cols.get(k).filter(|(e, _)| (*e - x).abs() <= COL_TOL).map(|_| k) - }; - // Assign every cell-like block to its nearest column (within tolerance). - let mut rows: Vec> = Vec::new(); // (column index, block index), top to bottom - let mut order: Vec = (0..blocks.len()).collect(); - order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); - for &bi in &order { - let b = &blocks[bi]; - if !is_cell_like(b) { - continue; - } - let Some(ci) = column_at(b.rect[0]) else { continue }; - let joins = rows - .last() - .and_then(|row| row.first()) - .map(|(_, fi)| { - let rb = &blocks[*fi]; - let overlap = rb.rect[3].min(b.rect[3]) - rb.rect[1].max(b.rect[1]); - overlap > 0.5 * (rb.rect[3] - rb.rect[1]).min(b.rect[3] - b.rect[1]) - }) - .unwrap_or(false); - if joins { - if let Some(row) = rows.last_mut() { - row.push((ci, bi)); - } - } else { - rows.push(vec![(ci, bi)]); - } - } - // Runs of consecutive rows that have two or more distinct columns are tables. - let mut out = Vec::new(); - let flush = |run: &mut Vec>, out: &mut Vec
, consumed: &mut Vec| { - if run.len() < 2 { - run.clear(); - return; - } - let mut used: Vec = run.iter().flatten().map(|(_, i)| *i).collect(); - used.sort_unstable(); - used.dedup(); - let mut edges: Vec = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - edges.sort_by(f64::total_cmp); - edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); - let ncols = edges.len(); - let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS; - if !ok { - run.clear(); - return; - } - let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL; - let mut rows_out = Vec::new(); - let mut slots: Vec)>> = vec![None; ncols]; - for row in run.drain(..) { - for slot in slots.iter_mut() { - *slot = None; - } - for (_ci, bi) in row { - let b = &blocks[bi]; - let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; - let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; - let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span }; - match slots.get_mut(slot) { - Some(Some((c, sources))) => { - c.text.push(' '); - c.text.push_str(&cell.text); - sources.push(bi); - } - Some(empty @ None) => *empty = Some((cell, vec![bi])), - None => continue, - } - } - let mut materialized = Vec::with_capacity(ncols); - let mut column = 0; - while column < ncols { - match slots.get_mut(column).and_then(Option::take) { - Some((mut cell, sources)) => { - // A spanning cell can skip later slots. Only consume source blocks - // when their text is emitted, leaving covered text in paragraph flow. - for bi in sources { - if let Some(taken) = consumed.get_mut(bi) { - *taken = true; - } - } - cell.span = cell.span.min(ncols - column).max(1); - column += cell.span; - materialized.push(cell); - } - None => { - column += 1; - materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 }); - } - } - } - while materialized.last().is_some_and(|cell| cell.text.is_empty()) && materialized.iter().any(|cell| cell.span > 1) { - materialized.pop(); - } - rows_out.push(materialized); - } - let rect = [ - edges[0], - used.iter().map(|&i| blocks[i].rect[1]).fold(f64::MAX, f64::min), - right, - used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max), - ]; - out.push(Table { rect, cols: edges, rows: rows_out }); - }; - // Runs of consecutive multi-column rows are tables. Rows on either side of a run that - // hold a single cell wide enough to span two columns (a header band, a totals row) join - // the table; other single-column rows (captions, following text) stay paragraphs. - let spans_run = |edges: &[f64], bi: usize| -> bool { - let b = &blocks[bi]; - edges.iter().any(|e| (*e - b.rect[0]).abs() <= COL_TOL) && edges.iter().any(|e| *e > b.rect[0] + COL_TOL && *e < b.rect[2] - COL_TOL) - }; - let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec { - let mut es: Vec = - run.iter().flatten().filter_map(|(_, i)| col_of.get(*i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - es.sort_by(f64::total_cmp); - es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); - es - }; - let cont = continuations(blocks); - let qual: Vec = rows - .iter() - .map(|row| { - // A block that continues another adds no column of its own, and the cells of a table row - // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). - let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); - let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); - spans.sort_by(|a, b| a[0].total_cmp(&b[0])); - let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); - let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); - cs.sort_unstable(); - cs.dedup(); - apart && cs.len() >= 2 - }) - .collect(); - let mut i = 0; - while i < rows.len() { - if !qual[i] { - i += 1; - continue; - } - let start = i; - while i < rows.len() && qual[i] { - i += 1; - } - let end = i; // run = rows[start..end] - let mut pieces: Vec> = Vec::new(); - if start > 0 && rows[start - 1].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[start - 1][0].1) { - pieces.push(rows[start - 1].clone()); - } - pieces.extend(rows[start..end].iter().cloned()); - if end < rows.len() && rows[end].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[end][0].1) { - pieces.push(rows[end].clone()); - } - flush(&mut pieces, &mut out, &mut consumed); - } - (out, consumed) -} - -fn items(pages: &[Page]) -> Vec> { - let body = body_size(pages); - let mut out = Vec::new(); - for (i, p) in pages.iter().enumerate() { - if i > 0 { - out.push(Item::PageBreak); - } - let (tables, consumed) = tables(&p.blocks); - // Blocks and images by their top edge, top to bottom. - // A paragraph sits where its first run does: its other runs follow along the same line. - let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) - .into_iter() - .filter_map(|runs| { - let first = runs.first()?.block; - let lvl = para_level(&runs, body); - Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) - }) - .collect(); - parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); - parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); - parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); - out.extend(parts.into_iter().map(|x| x.2)); - } - out -} - -/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, -/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them -/// (fonts without a usable `/ToUnicode`), and Word refuses a whole document over one (#72). -fn allowed(c: char) -> bool { - matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') -} - -pub(crate) fn esc(s: &str) -> String { - let mut o = String::with_capacity(s.len()); - for c in s.chars().filter(|c| allowed(*c)) { - match c { - '&' => o.push_str("&"), - '<' => o.push_str("<"), - '>' => o.push_str(">"), - '"' => o.push_str("""), - c => o.push(c), - } - } - o -} - -fn base64(data: &[u8]) -> String { - const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut s = String::with_capacity(data.len().div_ceil(3) * 4); - for c in data.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; - s.push(A[(n >> 18) as usize & 63] as char); - s.push(A[(n >> 12) as usize & 63] as char); - s.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); - s.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' }); - } - s -} - -/// One HTML file: headings and paragraphs, tables as real `
` elements, images inline, a -/// rule between pages. -pub fn html(pages: &[Page], title: &str) -> String { - let mut s = format!( - "\n\n\n\n{}\n\n\n\n", - esc(title) - ); - for it in items(pages) { - match it { - Item::Para(runs, lvl) => { - let Some(first) = runs.first().map(|r| r.block) else { continue }; - let mut t = String::new(); - for r in &runs { - let mut run = esc(&r.text); - if r.block.italic { - run = format!("{run}"); - } - if r.block.bold && lvl == 0 { - run = format!("{run}"); - } - // The paragraph has its first run's colour; a run in another colour says so. - let colour = rgb8(r.block.color); - if colour != rgb8(first.color) { - let [red, green, blue] = colour; - run = format!("{run}"); - } - t.push_str(&run); - } - let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); - match lvl { - 0 => s.push_str(&format!("{t}

\n")), - l => s.push_str(&format!("{t}\n")), - } - } - Item::Table(t) => { - s.push_str("
\n"); - for row in &t.rows { - s.push_str(""); - for c in row { - let mut body = esc(&c.text); - if c.italic { - body = format!("{body}"); - } - if c.bold { - body = format!("{body}"); - } - let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); - s.push_str(&format!("{body}")); - } - s.push_str("\n"); - } - s.push_str("
\n"); - } - Item::Img(im) => { - let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; - s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); - } - Item::PageBreak => s.push_str("
\n"), - } - } - s.push_str("\n\n"); - s -} - -/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). -pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { - // The value is clamped to 0..=255 before the cast, so it cannot truncate. - c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }) -} - -/// The colour as `RRGGBB`, or `None` for black (the default every format already uses). -pub(crate) fn hex(c: [f64; 3]) -> Option { - let [r, g, b] = rgb8(c); - (r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}")) -} - -/// A formatted text run (shared by paragraphs and table cells). -fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String { - let mut rpr = String::new(); - if bold { - rpr.push_str(""); - } - if italic { - rpr.push_str(""); - } - if let Some(h) = hex(color) { - rpr.push_str(&format!("")); - } - let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 }; - rpr.push_str(&format!("")); - format!("{rpr}{}", esc(text)) -} - -/// One Word table: explicit absent borders (alignment alone cannot prove PDF rules), a grid sized -/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes. -fn docx_table(t: &Table) -> String { - let mut edges = t.cols.clone(); - edges.push(t.rect[2]); - let widths: Vec = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect(); - let mut s = String::from(""); - for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] { - s.push_str(&format!("")); - } - s.push_str(""); - for w in &widths { - s.push_str(&format!("")); - } - s.push_str(""); - for row in &t.rows { - s.push_str(""); - let mut column = 0usize; - for c in row { - let span = if c.span > 1 { format!("", c.span) } else { String::new() }; - let w: i64 = widths.iter().skip(column).take(c.span).sum(); - s.push_str(&format!( - "{span}{}", - run_xml(&c.text, c.size, c.bold, c.italic, c.color) - )); - column = column.saturating_add(c.span); - } - // Pad the grid so every row covers all columns (Word rejects short rows). - for _ in column..t.cols.len() { - s.push_str(""); - } - s.push_str(""); - } - s.push_str(""); - s -} - -/// Pages with rotated text, or text that actually overlaps an image or an oversized -/// (watermark-like) text block, cannot be put in paragraph flow without changing their layout. -/// Keep those pages as editable, absolutely positioned text boxes. Every other page, including -/// one with a logo or a big title that sits clear of the text, flows as paragraphs and tables. -fn positioned(page: &Page) -> bool { - if page.blocks.iter().any(|b| b.rotation.abs() > 0.1) { - return true; - } - let body = body_size(std::slice::from_ref(page)).max(1.0); - let overlays = page - .images - .iter() - .map(|im| (im.rect, None)) - .chain(page.blocks.iter().enumerate().filter(|(_, b)| b.size >= 2.0 * body).map(|(i, b)| (b.rect, Some(i)))); - // Bound the pairwise test on hostile pages (many overlays times many blocks): past the - // budget, keep the default flowing layout. - let mut budget: usize = 1_000_000; - for (r, own) in overlays { - for (i, b) in page.blocks.iter().enumerate() { - if budget == 0 { - return false; - } - budget -= 1; - if Some(i) != own && overlaps(r, b.rect) { - return true; - } - } - } - false -} - -/// Whether two [x0, y0, x1, y1] boxes share more than a hairline (0.5 pt each way); boxes that -/// only touch, or hostile non-finite coordinates, do not count. -fn overlaps(a: [f64; 4], b: [f64; 4]) -> bool { - let w = a[2].min(b[2]) - a[0].max(b[0]); - let h = a[3].min(b[3]) - a[1].max(b[1]); - w.is_finite() && h.is_finite() && w > 0.5 && h > 0.5 -} - -/// Bound every value written into a shape's style, including hostile PDF coordinates. -fn layout_number(n: f64) -> f64 { - if n.is_finite() { n.clamp(-1584.0, 1584.0) } else { 0.0 } -} - -/// Most images a Word export embeds, and most image bytes (the zip writer has 32-bit sizes and -/// a 16-bit entry count; a hostile PDF must not be able to overflow them). -const MAX_MEDIA: usize = 1000; -const MAX_MEDIA_BYTES: usize = 512 << 20; - -/// Whether `im` can still be embedded: it has data, and the package stays within its limits. -fn room_for(media: &[(String, &Image)], im: &Image) -> bool { - !im.bytes.is_empty() - && media.len() < MAX_MEDIA - && media.iter().map(|(_, m)| m.bytes.len()).fold(im.bytes.len(), usize::saturating_add) <= MAX_MEDIA_BYTES -} - -/// Points as EMU (12700 per point); non-finite values are 0. -fn emu(pt: f64) -> i64 { - if pt.is_finite() { (pt * 12700.0).round() as i64 } else { 0 } -} - -/// The `a:graphic` of one picture (DrawingML), rotated by `rotation` degrees when given. -fn picture_xml(n: usize, name: &str, (cx, cy): (i64, i64), rotation: Option) -> String { - let rot = rotation.map_or(String::new(), |r| format!(" rot=\"{}\"", (r * 60000.0).round() as i64)); - format!( - "\ -\ -\ -\ -\ -" - ) -} - -fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, shape_id: &mut usize) -> String { - // One 1 pt paragraph anchors the whole page. Shapes never reserve body space, so even a - // page-sized watermark cannot push the invoice footer onto another page. - let mut s = String::from(""); - let body = body_size(std::slice::from_ref(page)).max(1.0); - for b in &page.blocks { - *shape_id = shape_id.saturating_add(1); - let rotation = layout_number(-b.rotation).rem_euclid(360.0); - let (sin, cos) = rotation.to_radians().sin_cos(); - let (bw, bh) = (layout_number(b.rect[2] - b.rect[0]).max(1.0), layout_number(b.rect[3] - b.rect[1]).max(1.0)); - let h = if b.rotation.abs() > 0.1 { layout_number(b.size).max(1.0) } else { bh }; - // Recover the unrotated width from the axis-aligned box. Use the larger axis so - // quarter turns and 45-degree watermarks do not divide by (nearly) zero. - let w = if b.rotation.abs() <= 0.1 { - bw - } else if cos.abs() >= sin.abs() { - (bw - h * sin.abs()) / cos.abs() - } else { - (bh - h * cos.abs()) / sin.abs() - }; - let w = layout_number(w).max(1.0); - let (x, y) = (layout_number(b.rect[0] + (bw - w) / 2.0), layout_number(page.height - b.rect[3] + (bh - h) / 2.0)); - let z = if b.size >= 2.0 * body { -2 } else { 1 }; - let style = format!( - "position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:{z};mso-position-horizontal-relative:page;mso-position-vertical-relative:page" - ); - s.push_str(&format!( - "{}", - shape_id, run_xml(&b.text, b.size, b.bold, b.italic, b.color) - )); - } - for im in &page.images { - if !room_for(media, im) { - continue; - } - *shape_id = shape_id.saturating_add(1); - let n = media.len().saturating_add(1); - let (w, h) = (layout_number(im.size[0]).max(1.0), layout_number(im.size[1]).max(1.0)); - let (x, y) = (layout_number((im.rect[0] + im.rect[2] - w) / 2.0), layout_number(page.height - (im.rect[1] + im.rect[3] + h) / 2.0)); - let rotation = layout_number(-im.rotation).rem_euclid(360.0); - let name = format!("image{n}.{}", im.ext); - let pic = picture_xml(n, &name, (emu(w), emu(h)), Some(rotation)); - // A DrawingML anchor, the way Word itself stores a picture placed behind text: a VML - // rectangle with an image does not show the picture. - s.push_str(&format!( - "\ -{}{}\ -", - n.saturating_add(1), - emu(x), - emu(y), - emu(w), - emu(h), - )); - s.push_str(&pic); - s.push_str(""); - media.push((name, im)); - } - s.push_str(""); - s -} - -fn word_page_size(page: &Page) -> (i64, i64) { - let twips = |pt: f64| if pt.is_finite() { (pt * 20.0).round().clamp(144.0, 31680.0) as i64 } else { 12240 }; - (twips(page.width), twips(page.height)) -} - -fn section(page: &Page) -> String { - let (pw, ph) = word_page_size(page); - let (mx, my) = ((pw / 8).min(1440), (ph / 8).min(1440)); - format!( - "", - my / 2, - my / 2 - ) -} - -/// The page colour of a Word document: Word has one background for the whole document, so it -/// is written only when every page has the same, non-white background (#2992). -fn word_background(pages: &[Page]) -> Option { - let first = rgb8(pages.first()?.background?); - let same = pages.iter().all(|p| p.background.map(rgb8) == Some(first)); - (same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2])) -} - -/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables; -/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout. -pub fn docx(pages: &[Page], title: &str) -> Vec { - let mut body = String::new(); - let mut media: Vec<(String, &Image)> = Vec::new(); - // Word needs a paragraph between adjacent tables and after the last one in the body. - let mut after_table = false; - let mut shape_id = 0usize; - for (page_index, page) in pages.iter().enumerate() { - let (pw, _) = word_page_size(page); - let text_w = (pw - 2 * (pw / 8).min(1440)) as f64 / 20.0; - if positioned(page) { - body.push_str(&positioned_page(page, &mut media, &mut shape_id)); - after_table = false; - } else { - for it in items(std::slice::from_ref(page)) { - match &it { - Item::Para(runs, lvl) => { - let style = match lvl { - 1 => "", - 2 => "", - _ => "", - }; - body.push_str(""); - body.push_str(style); - for r in runs { - body.push_str(&run_xml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); - } - body.push_str(""); - } - Item::Table(t) => { - if after_table { - body.push_str(""); - } - body.push_str(&docx_table(t)); - } - Item::Img(im) => { - if room_for(&media, im) { - let n = media.len() + 1; - let name = format!("image{n}.{}", im.ext); - // Size on the page, at most the text width. - let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); - let k = (text_w / w).min(1.0); - let (cx, cy) = (emu(w * k), emu(h * k)); - body.push_str(&format!("")); - body.push_str(&picture_xml(n, &name, (cx, cy), None)); - body.push_str(""); - media.push((name, im)); - } - } - Item::PageBreak => body.push_str(""), - } - after_table = matches!(it, Item::Table(_)); - } - } - // Section breaks carry this source page's size and create exactly one next page. - // The last section must be a direct child of the body (OOXML 17.6.17). - if page_index + 1 < pages.len() { - body.push_str(&format!( - "{}", - section(page) - )); - } else { - if after_table || page.blocks.is_empty() && page.images.is_empty() { - body.push_str(""); - } - body.push_str(§ion(page)); - } - after_table = false; - } - // Word shows a document background only when its settings say so (`settings.xml` below). - let page_colour = word_background(pages); - let background = page_colour.as_deref().map(|c| format!("")).unwrap_or_default(); - let doc = format!( - "\n\ -{background}{body}\ -" - ); - let mut types = String::from( - "\n\ -\ -\ -\ -\ -", - ); - if page_colour.is_some() { - types.push_str( - "", - ); - } - types.push_str(""); - let rels = "\n\ -\ -"; - let mut doc_rels = String::from( - "\n\ -", - ); - for (i, (name, _)) in media.iter().enumerate() { - doc_rels.push_str(&format!( - "", - i + 1 - )); - } - if page_colour.is_some() { - doc_rels.push_str(""); - } - doc_rels.push_str(""); - let styles = "\n\ -\ -\ -\ -"; - let core = format!( - "\n{}", - esc(title) - ); - let mut z = Zip::default(); - z.add("[Content_Types].xml", types.as_bytes(), true); - z.add("_rels/.rels", rels.as_bytes(), true); - z.add("docProps/core.xml", core.as_bytes(), true); - z.add("word/document.xml", doc.as_bytes(), true); - z.add("word/styles.xml", styles.as_bytes(), true); - z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true); - if page_colour.is_some() { - z.add("word/settings.xml", b"\n", true); - } - for (name, im) in &media { - z.add(&format!("word/media/{name}"), &im.bytes, false); - } - z.finish() -} - -fn rtf_text(s: &str) -> String { - let mut o = String::new(); - for c in s.chars().filter(|c| allowed(*c)) { - match c { - '\\' | '{' | '}' => { - o.push('\\'); - o.push(c); - } - c if (c as u32) < 128 => o.push(c), - c => { - // \uN with a ? fallback; UTF-16 units as signed 16-bit numbers. - let mut buf = [0u16; 2]; - for u in c.encode_utf16(&mut buf) { - o.push_str(&format!("\\u{}?", *u as i16)); - } - } - } - } - o -} - -/// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks -/// between pages (images are left out). -pub fn rtf(pages: &[Page]) -> String { - let items = items(pages); - // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. - let mut colors: Vec<[u8; 3]> = Vec::new(); - let mut slot: HashMap<[u8; 3], usize> = HashMap::new(); - let mut note = |c: [f64; 3]| { - let c = rgb8(c); - count_lookup_steps(1); - if c != [0, 0, 0] && !slot.contains_key(&c) { - slot.insert(c, colors.len()); - colors.push(c); - } - }; - for it in &items { - match it { - Item::Para(runs, _) => runs.iter().for_each(|r| note(r.block.color)), - Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), - Item::Img(_) | Item::PageBreak => {} - } - } - // `\cfN` for a colour (nothing for black, which keeps the default). - let cf = |c: [f64; 3]| { - let c = rgb8(c); - count_lookup_steps(1); - slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) - }; - let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); - if !colors.is_empty() { - s.push_str("{\\colortbl;"); - for [r, g, b] in &colors { - s.push_str(&format!("\\red{r}\\green{g}\\blue{b};")); - } - s.push_str("}\n"); - } - for it in items { - match it { - Item::Para(runs, _) => { - // The first run's formatting is the paragraph's; each later run is a group that - // turns on or off what differs from it. - let fmt = |b: &Block, later: bool| { - let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); - if b.bold { - fmt.push_str("\\b"); - } else if later { - fmt.push_str("\\b0"); - } - if b.italic { - fmt.push_str("\\i"); - } else if later { - fmt.push_str("\\i0"); - } - let colour = cf(b.color); - fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour }); - fmt - }; - let mut runs = runs.iter(); - let Some(first) = runs.next() else { continue }; - s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text))); - for r in runs { - s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text))); - } - s.push_str("\\par}\n"); - } - Item::Table(t) => { - let mut edges = t.cols.clone(); - edges.push(t.rect[2]); - let cellx: Vec = edges.iter().skip(1).map(|e| (e * 20.0).round() as i64).collect(); - for row in &t.rows { - s.push_str("\\trowd\\trgaph108"); - // The row's cell boundaries come first: each cell ends at the right edge of - // the last grid column it spans. - let mut column = 0; - for c in row { - column += c.span.max(1); - if let Some(x) = cellx.get(column.min(cellx.len()).saturating_sub(1)) { - s.push_str(&format!("\\cellx{x}")); - } - } - for c in row { - let mut fmt = format!("\\intbl\\fs{}", (c.size * 2.0).round() as i64); - if c.bold { - fmt.push_str("\\b"); - } - if c.italic { - fmt.push_str("\\i"); - } - fmt.push_str(&cf(c.color)); - s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text))); - } - s.push_str("\\row\n"); - } - } - Item::Img(_) => {} - Item::PageBreak => s.push_str("\\page\n"), - } - } - s.push('}'); - s -} +use as_docx::{MAX_MEDIA, docx_table}; +pub(crate) use as_html::esc; +pub use as_html::html; +#[cfg(test)] +use as_html::{allowed, base64}; +pub use as_rtf::rtf; +pub use layout::{Block, Cell, Image, Page, Table}; +#[cfg(test)] +use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; +pub(crate) use layout::{Run, paragraphs, tables}; #[cfg(test)] mod tests; diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 044ed236dc..d735eb4c20 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -441,16 +441,7 @@ fn word_opens_tables_up_to_its_column_limit() { } fn block(t: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block { - Block { - text: t.into(), - rect: [x0, y0, x1, y1], - size: 12.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - space_around: [false; 2], - } + Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0, space_around: [false; 2] } } /// #2806: a sentence whose link is set in another colour or font runs on as separate blocks From 0af6cf24186e446688177c8f08544f86592795a8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:11:26 +0000 Subject: [PATCH 082/246] refactor(print): split lib.rs into error, settings, sheets, imposition New modules: error.rs, settings.rs, sheets.rs, imposition.rs. --- crates/print/src/error.rs | 84 +++ crates/print/src/imposition.rs | 348 +++++++++++ crates/print/src/lib.rs | 1035 +------------------------------- crates/print/src/settings.rs | 250 ++++++++ crates/print/src/sheets.rs | 350 +++++++++++ 5 files changed, 1046 insertions(+), 1021 deletions(-) create mode 100644 crates/print/src/error.rs create mode 100644 crates/print/src/imposition.rs create mode 100644 crates/print/src/settings.rs create mode 100644 crates/print/src/sheets.rs diff --git a/crates/print/src/error.rs b/crates/print/src/error.rs new file mode 100644 index 0000000000..4b1fe95f21 --- /dev/null +++ b/crates/print/src/error.rs @@ -0,0 +1,84 @@ +/// What can go wrong when printing. +/// +/// The three things a *person* can be wrong about — there is nothing to print, a page number past +/// the end, a range that is not a page — each have their own variant with the numbers apart from +/// the words, so an interface can say them in its own language with [`PrintError::said`]. The rest +/// are faults rather than mistakes and carry their text as it is. +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum PrintError { + #[error("there are no pages to print")] + NoPages, + /// A page number past the end of the document: the number asked for, and the page count. + #[error("page {0} is out of range (1–{1})")] + OutOfRange(usize, usize), + /// A range token that is neither a page number nor a page label. + #[error("{0:?} is not a page number or label")] + NotAPage(String), + #[error("{0}")] + Invalid(String), + #[error("{0}")] + Spool(String), + #[error(transparent)] + Cos(#[from] pdfcraft_cos::CosError), +} + +/// How an interface says one of our wordings in its own language, holes and all: given the English +/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. +pub type Words<'a> = &'a dyn Fn(&str) -> String; + +/// The [`Words`] that translate nothing: the wording as it is written here. +pub fn in_english(wording: &str) -> String { + wording.to_owned() +} + +/// What a person can get wrong about what to print, in English, with their holes still in them. +/// +/// An interface that reports these in another language translates these three, which is the whole +/// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. +pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; + +impl PrintError { + /// This error as one sentence, said in the language `words` translates into. + /// + /// The wording is translated and the numbers go in afterwards, in a single pass, so what the + /// person typed cannot be read as a hole. With [`in_english`] this is the error's own + /// `Display`, which is what the command line and scripts report. + pub fn said(&self, words: Words) -> String { + match self { + PrintError::NoPages => words("there are no pages to print"), + PrintError::OutOfRange(n, count) => { + say(&words("page {n} is out of range (1–{count})"), &[("{n}", n.to_string()), ("{count}", count.to_string())]) + } + // The token goes in quoted, the way it is typed, as the error itself prints it. + PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), + fault => fault.to_string(), + } + } +} + +/// A wording with its holes filled. +/// +/// A hole is a name in braces, such as `{n}`; what is not in `values` is left as it is written. +/// Values go in during a single pass, so a value with braces in it is never read as a hole. +fn say(wording: &str, values: &[(&str, String)]) -> String { + let mut out = String::with_capacity(wording.len() + 8); + let mut rest = wording; + while let Some(open) = rest.find('{') { + let (before, from_brace) = rest.split_at(open); + out.push_str(before); + // `find` counts from the brace and `}` is one byte, so this is a char boundary. + let Some(close) = from_brace.find('}') else { + // A brace with nothing closing it is just text. + out.push_str(from_brace); + return out; + }; + let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; + match values.iter().find(|(name, _)| *name == hole) { + Some((_, value)) => out.push_str(value), + None => out.push_str(hole), + } + rest = from_brace.get(close + 1..).unwrap_or_default(); + } + out.push_str(rest); + out +} diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs new file mode 100644 index 0000000000..1257bcf25e --- /dev/null +++ b/crates/print/src/imposition.rs @@ -0,0 +1,348 @@ +use super::error::PrintError; +use super::settings::{Content, Settings}; +use super::sheets::{Sheet, layout}; +use super::*; + +// Model-derived transforms and clipping bounds need their full PDF decimal precision. +fn geometry_number(value: f64) -> String { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() +} + +fn n(v: f64) -> String { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +/// The page's content streams decoded against `budget` and joined; streams that don't decode +/// are left out. `None` when the budget ran out, so part of the content is missing. +pub(crate) fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { + let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; + let mut out = Vec::new(); + for data in budget.decode_contents(doc, contents) { + match data { + Ok(data) => { + out.extend_from_slice(&data); + out.push(b'\n'); + } + Err(_) if budget.remaining() == 0 => return None, + Err(_) => {} + } + } + Some(out) +} + +/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that +/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For +/// printing pages as pictures, where a renderer draws each page as it stands. +pub fn keep_printed(doc: &mut Document, content: Content) { + for page in pdfcraft_model::pages(doc) { + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); + // Fails only when the page object isn't a dictionary, and such a page draws nothing. + let _ = doc.update_dict(page.obj, |d| { + if content == Content::FormFieldsOnly { + d.remove(b"Contents"); + } + if d.contains(b"Annots") { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + }); + } +} + +/// The standard annotation types of ISO 32000-2 Table 171. +const STANDARD_ANNOTATION_TYPES: [&[u8]; 28] = [ + b"Text", + b"Link", + b"FreeText", + b"Line", + b"Square", + b"Circle", + b"Polygon", + b"PolyLine", + b"Highlight", + b"Underline", + b"Squiggly", + b"StrikeOut", + b"Caret", + b"Stamp", + b"Ink", + b"Popup", + b"FileAttachment", + b"Sound", + b"Movie", + b"Screen", + b"Widget", + b"PrinterMark", + b"TrapNet", + b"Watermark", + b"3D", + b"Redact", + b"Projection", + b"RichMedia", +]; + +/// Does annotation `d` print under `content`? +pub(crate) fn prints(doc: &Document, d: &Dict, content: Content) -> bool { + const INVISIBLE: i64 = 1; + const HIDDEN: i64 = 2; + const PRINT: i64 = 4; + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & PRINT == 0 || flags & HIDDEN != 0 { + return false; + } + // `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10), so resolve it first. + let subtype_obj = d.get(b"Subtype").map(|s| doc.resolve(s)); + let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); + match subtype { + b"Widget" => true, + b"Link" | b"Popup" => false, + b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, + // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table + // 171) is not printed even with its Print flag set. + _ if flags & INVISIBLE != 0 && !STANDARD_ANNOTATION_TYPES.contains(&subtype) => false, + _ => content == Content::DocumentAndMarkups, + } +} + +/// The normal appearance stream to draw for an annotation (honouring `/AS`). +pub(crate) fn appearance(doc: &Document, d: &Dict) -> Option { + let ap = doc.resolve(d.get(b"AP")?); + let normal = ap.as_dict()?.get(b"N")?.clone(); + match &*doc.resolve(&normal) { + Object::Stream(_) => normal.as_ref(), + Object::Dict(states) => { + let state = d.name(b"AS")?; + states.get(state)?.as_ref() + } + _ => None, + } +} + +/// Algorithm 8.1: the matrix placing an appearance form (BBox × Matrix) onto the annotation rect. +pub(crate) fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option { + let nums = + |k: &[u8]| -> Option> { Some(doc.resolve(form.get(k)?).as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()) }; + let bbox = nums(b"BBox").filter(|b| b.len() == 4)?; + let m = nums(b"Matrix").filter(|m| m.len() == 6).map(|m| Matrix([m[0], m[1], m[2], m[3], m[4], m[5]])).unwrap_or_default(); + let b = m.bbox([bbox[0], bbox[1], bbox[2], bbox[3]]); + let (bw, bh) = (b[2] - b[0], b[3] - b[1]); + if bw <= 0.0 || bh <= 0.0 { + return None; + } + let a = Matrix([ + (rect[2] - rect[0]) / bw, + 0.0, + 0.0, + (rect[3] - rect[1]) / bh, + rect[0] - b[0] * (rect[2] - rect[0]) / bw, + rect[1] - b[1] * (rect[3] - rect[1]) / bh, + ]); + // The Do that draws the form applies its /Matrix itself, so only the placement goes here. + Some(a) +} + +/// A Form XObject holding page `index` as it prints (user space; BBox = crop box). +/// `pages` are the document's pages, listed once by the caller: listing them for each page +/// printed would make printing quadratic in the page count. +pub(crate) fn page_form( + doc: &mut Document, + pages: &[pdfcraft_model::Page], + index: usize, + content: Content, + promoted: &mut std::collections::HashMap, +) -> Result { + let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; + let crop = page.crop(doc); + let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut body = if content == Content::FormFieldsOnly { + Vec::new() + } else { + decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) + .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? + }; + // Wrap the page content so a state it leaves behind can't affect the annotations. + if !body.is_empty() { + let mut wrapped = b"q\n".to_vec(); + wrapped.extend_from_slice(&body); + wrapped.extend_from_slice(b"\nQ\n"); + body = wrapped; + } + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + let mut k = 0usize; + for a in annots { + let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; + if !prints(doc, &d, content) { + continue; + } + let rect: Vec = + d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); + if rect.len() != 4 { + continue; + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let Some(ap) = appearance(doc, &d) else { continue }; + let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); + let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; + let ap = if !form.contains(b"Subtype") + && let Object::Stream(stream) = &*doc.get(ap) + { + // Some existing annotation appearances omit Subtype, but Do dispatch needs a Form XObject. + // Keep the original intact: other document objects can share this appearance. + if let Some(&cached) = promoted.get(&ap) { + cached + } else { + let mut stream = stream.clone(); + stream.dict.set(b"Subtype".to_vec(), Object::name("Form")); + let copy = doc.add(Object::Stream(stream)); + promoted.insert(ap, copy); + copy + } + } else { + ap + }; + // Keep the page's own XObjects, including names previous print operations generated. + // Check the growing map so each appearance also keeps the name just assigned to it. + let name = loop { + let name = format!("PCPrA{k}"); + k = k.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation appearance resources".into()))?; + if !xobjects.contains(name.as_bytes()) { + break name; + } + }; + let [a0, b0, c0, d0, e0, f0] = m.0; + body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); + xobjects.set(name.into_bytes(), Object::Ref(ap)); + } + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"Resources".to_vec(), Object::Dict(res)); + if let Some(g) = page.dict.get(b"Group") { + d.set(b"Group".to_vec(), g.clone()); + } + Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) +} + +#[cfg(test)] +thread_local! { + /// Pages listed by [`source_pages`] on this thread. + pub(crate) static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The source document's pages, for printing. +fn source_pages(doc: &Document) -> Vec { + let pages = pdfcraft_model::pages(doc); + #[cfg(test)] + PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); + pages +} + +/// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a +/// printer or is the user's own copy); callers must check the print permission first. +pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { + let pages = source_pages(src); + let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); + let sheets = layout(&sizes, settings)?; + let mut doc = src.clone(); + // One form per source page used (a page may appear on several sheets: posters). + let mut forms: std::collections::HashMap = std::collections::HashMap::new(); + let mut promoted = std::collections::HashMap::new(); + let views: Vec<[f64; 6]> = pages.iter().map(|p| p.view_matrix(src)).collect(); + let pages_root = doc.add(Object::Null); + let mut kids = Vec::new(); + for sheet in &sheets { + let mut c = String::new(); + let mut xo = Dict::new(); + for (i, pl) in sheet.placed.iter().enumerate() { + let form = match forms.get(&pl.page) { + Some(f) => *f, + None => { + let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; + forms.insert(pl.page, f); + f + } + }; + let name = format!("P{i}"); + xo.set(name.clone().into_bytes(), Object::Ref(form)); + // Display space → sheet, then clip to the visible part, then user → display. + let [a, b, cc, d, e, f] = pl.matrix.0; + let to_display = Matrix(views[pl.page]).invert().unwrap_or_default(); + let [ua, ub, uc, ud, ue, uf] = to_display.0; + let [x0, y0, x1, y1] = pl.clip; + c.push_str(&format!( + "q {} {} {} {} {} {} cm {} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do Q\n", + geometry_number(a), + geometry_number(b), + geometry_number(cc), + geometry_number(d), + geometry_number(e), + geometry_number(f), + geometry_number(x0), + geometry_number(y0), + geometry_number(x1 - x0), + geometry_number(y1 - y0), + geometry_number(ua), + geometry_number(ub), + geometry_number(uc), + geometry_number(ud), + geometry_number(ue), + geometry_number(uf) + )); + } + if !sheet.borders.is_empty() || !sheet.lines.is_empty() { + c.push_str("q 0 G 0.5 w\n"); + for r in &sheet.borders { + c.push_str(&format!("{} {} {} {} re S\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1]))); + } + for l in &sheet.lines { + c.push_str(&format!("{} {} m {} {} l S\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); + } + c.push_str("Q\n"); + } + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages_root)); + p.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, sheet.size.0, sheet.size.1].iter().map(|v| Object::Real(*v)).collect())); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + kids.push(Object::Ref(doc.add(Object::Dict(p)))); + } + let mut tree = Dict::new(); + tree.set(b"Type".to_vec(), Object::name("Pages")); + tree.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + tree.set(b"Kids".to_vec(), Object::Array(kids)); + doc.set(pages_root, Object::Dict(tree)); + let mut cat = Dict::new(); + cat.set(b"Type".to_vec(), Object::name("Catalog")); + cat.set(b"Pages".to_vec(), Object::Ref(pages_root)); + // Keep the layers' configuration, or a layer that is off in the document would print: the + // pages' marked content still refers to its groups, and without this nothing says they are off. + let layers = src.trailer().get(b"Root").and_then(|r| src.resolve(r).as_dict().and_then(|root| root.get(b"OCProperties").cloned())); + if let Some(layers) = layers { + cat.set(b"OCProperties".to_vec(), layers); + } + let root = doc.add(Object::Dict(cat)); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); + doc.trailer_mut().remove(b"Info"); + if doc.trailer().contains(b"Encrypt") { + doc.remove_encryption(); + } + Ok(write_full(&doc, &SaveOptions::default())?) +} + +/// Which source pages land on each sheet (for previews and the sheet count). +pub fn sheet_pages(sheets: &[Sheet]) -> Vec> { + sheets.iter().map(|s| s.placed.iter().map(|p| p.page).collect()).collect() +} diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index b731aff00f..067112b2e7 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -27,1026 +27,19 @@ mod tests; pub use range::{Subset, select_listed, select_pages}; -/// What can go wrong when printing. -/// -/// The three things a *person* can be wrong about — there is nothing to print, a page number past -/// the end, a range that is not a page — each have their own variant with the numbers apart from -/// the words, so an interface can say them in its own language with [`PrintError::said`]. The rest -/// are faults rather than mistakes and carry their text as it is. -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum PrintError { - #[error("there are no pages to print")] - NoPages, - /// A page number past the end of the document: the number asked for, and the page count. - #[error("page {0} is out of range (1–{1})")] - OutOfRange(usize, usize), - /// A range token that is neither a page number nor a page label. - #[error("{0:?} is not a page number or label")] - NotAPage(String), - #[error("{0}")] - Invalid(String), - #[error("{0}")] - Spool(String), - #[error(transparent)] - Cos(#[from] pdfcraft_cos::CosError), -} - -/// How an interface says one of our wordings in its own language, holes and all: given the English -/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. -pub type Words<'a> = &'a dyn Fn(&str) -> String; - -/// The [`Words`] that translate nothing: the wording as it is written here. -pub fn in_english(wording: &str) -> String { - wording.to_owned() -} - -/// What a person can get wrong about what to print, in English, with their holes still in them. -/// -/// An interface that reports these in another language translates these three, which is the whole -/// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. -pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; - -impl PrintError { - /// This error as one sentence, said in the language `words` translates into. - /// - /// The wording is translated and the numbers go in afterwards, in a single pass, so what the - /// person typed cannot be read as a hole. With [`in_english`] this is the error's own - /// `Display`, which is what the command line and scripts report. - pub fn said(&self, words: Words) -> String { - match self { - PrintError::NoPages => words("there are no pages to print"), - PrintError::OutOfRange(n, count) => { - say(&words("page {n} is out of range (1–{count})"), &[("{n}", n.to_string()), ("{count}", count.to_string())]) - } - // The token goes in quoted, the way it is typed, as the error itself prints it. - PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), - fault => fault.to_string(), - } - } -} - -/// A wording with its holes filled. -/// -/// A hole is a name in braces, such as `{n}`; what is not in `values` is left as it is written. -/// Values go in during a single pass, so a value with braces in it is never read as a hole. -fn say(wording: &str, values: &[(&str, String)]) -> String { - let mut out = String::with_capacity(wording.len() + 8); - let mut rest = wording; - while let Some(open) = rest.find('{') { - let (before, from_brace) = rest.split_at(open); - out.push_str(before); - // `find` counts from the brace and `}` is one byte, so this is a char boundary. - let Some(close) = from_brace.find('}') else { - // A brace with nothing closing it is just text. - out.push_str(from_brace); - return out; - }; - let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; - match values.iter().find(|(name, _)| *name == hole) { - Some((_, value)) => out.push_str(value), - None => out.push_str(hole), - } - rest = from_brace.get(close + 1..).unwrap_or_default(); - } - out.push_str(rest); - out -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Orientation { - #[default] - Auto, - Portrait, - Landscape, -} - -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub enum SizeMode { - #[default] - Fit, - Actual, - Shrink, - /// Percent. - Custom(f64), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageOrder { - #[default] - Horizontal, - HorizontalReversed, - Vertical, - VerticalReversed, - /// Single-sided sheets: cut into cell piles, then stack them in row-major order. - CutStack, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum BookletSubset { - #[default] - BothSides, - FrontOnly, - BackOnly, -} - -/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as -/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro, -/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for -/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned -/// sideways; front side only or back side only is one pass of a manual duplex and prints -/// single-sided. -pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { - match subset { - BookletSubset::BothSides => spool::Duplex::ShortEdge, - BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off, - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Binding { - #[default] - Left, - Right, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Layout { - Size(SizeMode), - Multiple { - cols: usize, - rows: usize, - /// Source pages on each sheet, at most `cols × rows`: three per sheet uses a 2 × 2 grid - /// with one cell left blank. - per_sheet: usize, - order: PageOrder, - border: bool, - auto_rotate: bool, - }, - Booklet { - subset: BookletSubset, - binding: Binding, - }, - /// Tile scale (percent), overlap (points), cut marks. - Poster { - scale: f64, - overlap: f64, - cut_marks: bool, - }, - /// Shipping label: each page cut to its [`Settings::crops`] box, turned so its text reads - /// upright on the sheet and fitted with [`LABEL_MARGIN`]; `flip` turns it a further 180°. - Label { - flip: bool, - }, -} - -impl Default for Layout { - fn default() -> Self { - Layout::Size(SizeMode::Fit) - } -} - -impl Layout { - /// Acrobat's pages-per-sheet choices as a grid (columns × rows on a portrait sheet). - pub fn multiple(per_sheet: usize) -> Layout { - let (cols, rows) = match per_sheet { - 2 => (1, 2), - 4 => (2, 2), - 6 => (2, 3), - 9 => (3, 3), - 16 => (4, 4), - n => { - let c = (n as f64).sqrt().ceil().max(1.0) as usize; - (c, n.div_ceil(c).max(1)) - } - }; - Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true } - } -} - -/// Acrobat's Comments & Forms choices. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Content { - /// Page content and form fields. - Document, - /// Page content, form fields and every printable comment. - #[default] - DocumentAndMarkups, - /// Page content, form fields and stamps. - DocumentAndStamps, - /// Form fields only. - FormFieldsOnly, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Settings { - /// Source pages (0-based) in print order; see [`select_pages`]. - pub pages: Vec, - /// Paper (width, height) in points, portrait. - pub paper: (f64, f64), - pub orientation: Orientation, - pub layout: Layout, - pub content: Content, - /// For [`Layout::Label`], per source page: the area to keep (display space) and whether its - /// text runs bottom-to-top (so a quarter turn clockwise makes it upright). `None` or a - /// missing entry keeps the whole page. - pub crops: Vec>, - /// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this - /// resolution before it is placed on the paper. The engine does the rendering; `impose` is - /// then given the image-only document. - pub raster: Option, -} - -/// How pages are rendered when printing as image. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Raster { - /// Dots per inch, 72–600 (Acrobat offers 150, 300 and 600). - pub dpi: f64, - /// Render the pictures in grey, so the print-ready PDF itself is grayscale. - pub grayscale: bool, -} - -impl Default for Raster { - fn default() -> Self { - Raster { dpi: 300.0, grayscale: false } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Crop { - pub rect: [f64; 4], - pub text_up: bool, -} - -impl Default for Settings { - fn default() -> Self { - Settings { - pages: Vec::new(), - paper: PAPERS[0].1, - orientation: Orientation::Auto, - layout: Layout::default(), - content: Content::default(), - raster: None, - crops: Vec::new(), - } - } -} - -/// Common paper sizes (points, portrait). -pub const PAPERS: [(&str, (f64, f64)); 21] = [ - ("US Letter", (612.0, 792.0)), - ("US Legal", (612.0, 1008.0)), - ("Tabloid", (792.0, 1224.0)), - ("A3", (841.89, 1190.55)), - ("A4", (595.28, 841.89)), - ("A5", (419.53, 595.28)), - ("A6", (297.64, 419.53)), - ("ARCH A (9 x 12 in)", (648.0, 864.0)), - ("ARCH B (12 x 18 in)", (864.0, 1296.0)), - ("ARCH C (18 x 24 in)", (1296.0, 1728.0)), - ("ARCH D (24 x 36 in)", (1728.0, 2592.0)), - ("ARCH E1 (30 x 42 in)", (2160.0, 3024.0)), - ("ARCH E (36 x 48 in)", (2592.0, 3456.0)), - ("ANSI C (17 x 22 in)", (1224.0, 1584.0)), - ("ANSI D (22 x 34 in)", (1584.0, 2448.0)), - ("ANSI E (34 x 44 in)", (2448.0, 3168.0)), - ("A2", (1190.55, 1683.78)), - ("A1", (1683.78, 2383.94)), - ("A0", (2383.94, 3370.39)), - ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), - ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), -]; - -/// `PAPERS` index of the 4 × 6 in shipping label. -pub const LABEL_PAPER: usize = 19; - -/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out -/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes -/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). -/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the -/// spec and the IANA registry have them: a driverless printer may take only the names it -/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions -/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752). -pub fn cups_media(index: usize) -> Option<&'static str> { - Some(match PAPERS.get(index)?.0 { - "US Letter" => "Letter", - "US Legal" => "Legal", - "Tabloid" => "Tabloid", - "A3" => "A3", - "A4" => "A4", - "A5" => "A5", - "A6" => "iso_a6_105x148mm", - "ARCH A (9 x 12 in)" => "na_arch-a_9x12in", - "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", - "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", - "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", - "ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in", - "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", - "ANSI C (17 x 22 in)" => "na_c_17x22in", - "ANSI D (22 x 34 in)" => "na_d_22x34in", - "ANSI E (34 x 44 in)" => "na_e_34x44in", - "A2" => "iso_a2_420x594mm", - "A1" => "iso_a1_594x841mm", - "A0" => "iso_a0_841x1189mm", - "US #10 Envelope (4.125 x 9.5 in)" => "Env10", - _ if index == LABEL_PAPER => "w288h432", - // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's - // paper stands, as it did for every size before #1583. - _ => return None, - }) -} - -/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print -/// dialog's jobs and `doc_print`'s alike. -pub fn media_option(index: usize) -> Option<(String, String)> { - cups_media(index).map(|m| ("media".to_string(), m.to_string())) -} - -/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid -/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects -/// are followed a few levels deep. -pub fn text_reads_up(doc: &Document, index: usize) -> bool { - let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false }; - let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); - let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut score = 0i64; - let mut budget = DecodeBudget::page(); - // Content past the budget is left out: the answer is a guess from the text read so far. - let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); - text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); - score > 0 -} - -fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { - let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); - for op in pdfcraft_content::parse(data).ops { - match op.op.as_slice() { - // Bounded like the renderer's graphics-state stack: deeper saves are ignored. - b"q" if stack.len() < 256 => stack.push(ctm), - b"Q" => ctm = stack.pop().unwrap_or(base), - b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm), - b"BT" => tm = Matrix::IDENTITY, - b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(), - b"Tj" | b"TJ" | b"'" | b"\"" => { - let [a, b, ..] = tm.then(&ctm).0; - *score += i64::from(b > a.abs()) - i64::from(-b > a.abs()); - } - b"Do" if depth < 4 => { - let Some(name) = op.name(0) else { continue }; - let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue }; - let Object::Stream(form) = &*doc.resolve(&xobj) else { continue }; - if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { - continue; - } - let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); - let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); - if !budget.enter_form() { - continue; - } - if let Ok(body) = budget.decode(form) { - text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); - } - } - _ => {} - } - } -} - -/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed -/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet. -pub const LABEL_MARGIN: f64 = 8.0; - -/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by -/// `quarter` quarter turns counter-clockwise (0–3). -fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement { - let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]); - let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) }; - let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0); - let [a, b, c, d, e, f] = match quarter % 4 { - 0 => [s, 0.0, 0.0, s, x, y], - 1 => [0.0, s, -s, 0.0, x + pw, y], - 2 => [-s, 0.0, 0.0, -s, x + pw, y + ph], - _ => [0.0, -s, s, 0.0, x, y + ph], - }; - // Move the crop's corner to the origin first. - Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop } -} - -/// Match a displayed PDF page to a standard sheet, independent of orientation. -/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes. -pub fn matching_paper(size: (f64, f64)) -> Option { - if !size.0.is_finite() || !size.1.is_finite() { - return None; - } - let size = (size.0.min(size.1), size.0.max(size.1)); - PAPERS.iter().position(|(_, p)| (p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5) -} - -/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet. -pub const MARGIN: f64 = 18.0; - -/// One page as placed on a sheet: the source page, the matrix from its *display space* to the -/// sheet, and the visible part of it in display space (clipping). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Placement { - pub page: usize, - pub matrix: Matrix, - pub clip: [f64; 4], -} - -/// One output sheet. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Sheet { - pub size: (f64, f64), - pub placed: Vec, - /// Page borders (Multiple) and cut marks (Poster), in sheet space: rectangles and lines. - pub borders: Vec<[f64; 4]>, - pub lines: Vec<[f64; 4]>, -} - -fn oriented(paper: (f64, f64), o: Orientation, landscape_wanted: bool) -> (f64, f64) { - let (w, h) = (paper.0.min(paper.1), paper.0.max(paper.1)); - match o { - Orientation::Portrait => (w, h), - Orientation::Landscape => (h, w), - Orientation::Auto => { - if landscape_wanted { - (h, w) - } else { - (w, h) - } - } - } -} - -/// Place a page of display size `(dw, dh)` inside `cell` (sheet space), scaled by `s` -/// (centred), optionally rotated 90° (counter-clockwise). -fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: bool) -> Placement { - let (cw, ch) = (cell[2] - cell[0], cell[3] - cell[1]); - let (pw, ph) = if rotate { (dh * s, dw * s) } else { (dw * s, dh * s) }; - let (x, y) = (cell[0] + (cw - pw) / 2.0, cell[1] + (ch - ph) / 2.0); - let matrix = if rotate { Matrix([0.0, s, -s, 0.0, x + pw, y]) } else { Matrix([s, 0.0, 0.0, s, x, y]) }; - Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } -} - -fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { - if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } -} - -/// Lay out the sheets for `settings` given each source page's display size. -pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { - let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); - if pages.is_empty() { - return Err(PrintError::NoPages); - } - let paper = settings.paper; - if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { - return Err(PrintError::Invalid("invalid paper size".into())); - } - let mut sheets = Vec::new(); - match settings.layout { - Layout::Size(mode) => { - for &p in &pages { - let d = sizes[p]; - let size = oriented(paper, settings.orientation, d.0 > d.1); - let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); - let s = match mode { - SizeMode::Fit => fit, - SizeMode::Actual => 1.0, - SizeMode::Shrink => fit.min(1.0), - SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, - SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), - }; - sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); - } - } - Layout::Label { flip } => { - let size = oriented(paper, settings.orientation, false); - let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; - for &p in &pages { - let d = sizes[p]; - let crop = settings.crops.get(p).copied().flatten(); - let rect = crop - .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) - .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) - .unwrap_or([0.0, 0.0, d.0, d.1]); - let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); - let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); - let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { - (false, _) => 0, - (true, true) => 3, - (true, false) => 1, - }; - if flip { - quarter = (quarter + 2) % 4; - } - let s = if turn { - fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) - } else { - fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) - }; - sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }); - } - } - Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { - let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); - let Some(cells) = cells else { - return Err(PrintError::Invalid("invalid number of pages per sheet".into())); - }; - // The grid can have more cells than the pages asked for on each sheet; the rest stay blank. - let per_sheet = per_sheet.min(cells); - if per_sheet == 0 { - return Err(PrintError::Invalid("invalid number of pages per sheet".into())); - } - // A grid with more columns than rows wants a landscape sheet, and the reverse. - let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; - let page_landscape = first.0 > first.1; - let (gc, gr, sheet_landscape) = if cols == rows { - (cols, rows, page_landscape) - } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { - // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. - (rows, cols, true) - } else { - (cols, rows, false) - }; - let size = oriented(paper, settings.orientation, sheet_landscape); - let gap = 6.0; - let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); - let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64); - if cw <= 0.0 || ch <= 0.0 { - return Err(PrintError::Invalid("the page grid does not fit on the paper".into())); - } - let sheet_count = pages.len().div_ceil(per_sheet); - for sheet_index in 0..sheet_count { - let mut sheet = Sheet { size, ..Sheet::default() }; - if order == PageOrder::CutStack { - // Marks align across every sheet, including unoccupied cells. Cut in the - // middle of each gutter; marks stay in the printable area, clear of cells. - for col in 1..gc { - let x = MARGIN + col as f64 * (cw + gap) - gap / 2.0; - sheet.lines.extend([[x, MARGIN, x, MARGIN + 8.0], [x, size.1 - MARGIN - 8.0, x, size.1 - MARGIN]]); - } - for row in 1..gr { - let y = size.1 - MARGIN - row as f64 * (ch + gap) + gap / 2.0; - sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]); - } - } - for k in 0..per_sheet { - // A cell's pile gets consecutive pages. Missing pages leave blank cells, - // rather than shifting later piles left on the final sheets. - let index = if order == PageOrder::CutStack { - k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index)) - } else { - sheet_index.checked_mul(per_sheet).and_then(|n| n.checked_add(k)) - }; - let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue }; - - let (col, row) = match order { - PageOrder::Horizontal | PageOrder::CutStack => (k % gc, k / gc), - PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc), - PageOrder::Vertical => (k / gr, k % gr), - PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr), - }; - let x0 = MARGIN + col as f64 * (cw + gap); - let y1 = size.1 - MARGIN - row as f64 * (ch + gap); - let cell = [x0, y1 - ch, x0 + cw, y1]; - let d = sizes.get(p).copied().ok_or_else(|| PrintError::Invalid("page size is missing".into()))?; - if !(d.0.is_finite() && d.1.is_finite() && d.0 > 0.0 && d.1 > 0.0) { - return Err(PrintError::Invalid("invalid page size".into())); - } - let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0; - let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch)); - if !s.is_finite() || s <= 0.0 { - return Err(PrintError::Invalid("page size cannot be scaled to the grid".into())); - } - let pl = place_in(p, d, cell, s, rotate); - if border { - let b = pl.matrix.bbox(pl.clip); - sheet.borders.push(b); - } - sheet.placed.push(pl); - } - sheets.push(sheet); - } - } - Layout::Booklet { subset, binding } => { - // Pad to a multiple of 4 with blank pages (None). - let n4 = pages.len().div_ceil(4) * 4; - let at = |i: usize| pages.get(i).copied(); - let size = oriented(paper, Orientation::Landscape, true); - let half = (size.0 - 2.0 * MARGIN) / 2.0; - let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; - for k in 0..n4 / 4 { - let front = (at(n4 - 1 - 2 * k), at(2 * k)); - let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); - let mut sides = Vec::new(); - if subset != BookletSubset::BackOnly { - sides.push(front); - } - if subset != BookletSubset::FrontOnly { - sides.push(back); - } - for (l, r) in sides { - let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; - let mut sheet = Sheet { size, ..Sheet::default() }; - for (p, cell) in [(l, cells[0]), (r, cells[1])] { - if let Some(p) = p { - let d = sizes[p]; - let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); - sheet.placed.push(place_in(p, d, cell, s, false)); - } - } - sheets.push(sheet); - } - } - } - Layout::Poster { scale, overlap, cut_marks } => { - if !(scale.is_finite() && scale > 0.0 && overlap.is_finite() && overlap >= 0.0) { - return Err(PrintError::Invalid("invalid tile scale or overlap".into())); - } - let s = scale / 100.0; - for &p in &pages { - let d = sizes[p]; - let size = oriented(paper, settings.orientation, d.0 > d.1); - // Each tile shows `step` of the scaled page plus `overlap` shared with the next. - let (tw, th) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); - if overlap * 2.0 >= tw.min(th) { - return Err(PrintError::Invalid("the overlap is larger than the tile".into())); - } - let (sw, sh) = (d.0 * s, d.1 * s); - let (stepx, stepy) = (tw - overlap, th - overlap); - let nx = (((sw - overlap) / stepx).ceil() as usize).max(1); - let ny = (((sh - overlap) / stepy).ceil() as usize).max(1); - // The counts saturate at usize::MAX for a huge scale, so their product can overflow. - if nx.checked_mul(ny).is_none_or(|n| n > 1024) { - return Err(PrintError::Invalid("the poster would need more than 1024 sheets".into())); - } - // Tiles from the top-left, row by row. - for ty in 0..ny { - for tx in 0..nx { - // The window into the scaled page (scaled display space, y up). - let wx0 = tx as f64 * stepx; - let wy1 = sh - ty as f64 * stepy; - let matrix = Matrix([s, 0.0, 0.0, s, MARGIN - wx0, size.1 - MARGIN - wy1]); - let clip = [wx0 / s, (wy1 - th) / s, (wx0 + tw) / s, wy1 / s]; - let mut sheet = Sheet { size, placed: vec![Placement { page: p, matrix, clip }], ..Sheet::default() }; - if cut_marks { - let (x0, y0, x1, y1) = (MARGIN, MARGIN, size.0 - MARGIN, size.1 - MARGIN); - for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { - let dx = if x == x0 { -1.0 } else { 1.0 }; - let dy = if y == y0 { -1.0 } else { 1.0 }; - sheet.lines.push([x + dx * 2.0, y, x + dx * (MARGIN - 2.0), y]); - sheet.lines.push([x, y + dy * 2.0, x, y + dy * (MARGIN - 2.0)]); - } - } - sheets.push(sheet); - } - } - } - } - } - Ok(sheets) -} - -// Model-derived transforms and clipping bounds need their full PDF decimal precision. -fn geometry_number(value: f64) -> String { - let mut bytes = Vec::new(); - pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); - String::from_utf8_lossy(&bytes).into_owned() -} - -fn n(v: f64) -> String { - let s = format!("{v:.4}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -/// The page's content streams decoded against `budget` and joined; streams that don't decode -/// are left out. `None` when the budget ran out, so part of the content is missing. -fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { - let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; - let mut out = Vec::new(); - for data in budget.decode_contents(doc, contents) { - match data { - Ok(data) => { - out.extend_from_slice(&data); - out.push(b'\n'); - } - Err(_) if budget.remaining() == 0 => return None, - Err(_) => {} - } - } - Some(out) -} - -/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that -/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For -/// printing pages as pictures, where a renderer draws each page as it stands. -pub fn keep_printed(doc: &mut Document, content: Content) { - for page in pdfcraft_model::pages(doc) { - let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); - let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); - // Fails only when the page object isn't a dictionary, and such a page draws nothing. - let _ = doc.update_dict(page.obj, |d| { - if content == Content::FormFieldsOnly { - d.remove(b"Contents"); - } - if d.contains(b"Annots") { - d.set(b"Annots".to_vec(), Object::Array(kept)); - } - }); - } -} - -/// The standard annotation types of ISO 32000-2 Table 171. -const STANDARD_ANNOTATION_TYPES: [&[u8]; 28] = [ - b"Text", - b"Link", - b"FreeText", - b"Line", - b"Square", - b"Circle", - b"Polygon", - b"PolyLine", - b"Highlight", - b"Underline", - b"Squiggly", - b"StrikeOut", - b"Caret", - b"Stamp", - b"Ink", - b"Popup", - b"FileAttachment", - b"Sound", - b"Movie", - b"Screen", - b"Widget", - b"PrinterMark", - b"TrapNet", - b"Watermark", - b"3D", - b"Redact", - b"Projection", - b"RichMedia", -]; - -/// Does annotation `d` print under `content`? -fn prints(doc: &Document, d: &Dict, content: Content) -> bool { - const INVISIBLE: i64 = 1; - const HIDDEN: i64 = 2; - const PRINT: i64 = 4; - let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - if flags & PRINT == 0 || flags & HIDDEN != 0 { - return false; - } - // `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10), so resolve it first. - let subtype_obj = d.get(b"Subtype").map(|s| doc.resolve(s)); - let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); - match subtype { - b"Widget" => true, - b"Link" | b"Popup" => false, - b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, - // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table - // 171) is not printed even with its Print flag set. - _ if flags & INVISIBLE != 0 && !STANDARD_ANNOTATION_TYPES.contains(&subtype) => false, - _ => content == Content::DocumentAndMarkups, - } -} - -/// The normal appearance stream to draw for an annotation (honouring `/AS`). -fn appearance(doc: &Document, d: &Dict) -> Option { - let ap = doc.resolve(d.get(b"AP")?); - let normal = ap.as_dict()?.get(b"N")?.clone(); - match &*doc.resolve(&normal) { - Object::Stream(_) => normal.as_ref(), - Object::Dict(states) => { - let state = d.name(b"AS")?; - states.get(state)?.as_ref() - } - _ => None, - } -} - -/// Algorithm 8.1: the matrix placing an appearance form (BBox × Matrix) onto the annotation rect. -fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option { - let nums = - |k: &[u8]| -> Option> { Some(doc.resolve(form.get(k)?).as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()) }; - let bbox = nums(b"BBox").filter(|b| b.len() == 4)?; - let m = nums(b"Matrix").filter(|m| m.len() == 6).map(|m| Matrix([m[0], m[1], m[2], m[3], m[4], m[5]])).unwrap_or_default(); - let b = m.bbox([bbox[0], bbox[1], bbox[2], bbox[3]]); - let (bw, bh) = (b[2] - b[0], b[3] - b[1]); - if bw <= 0.0 || bh <= 0.0 { - return None; - } - let a = Matrix([ - (rect[2] - rect[0]) / bw, - 0.0, - 0.0, - (rect[3] - rect[1]) / bh, - rect[0] - b[0] * (rect[2] - rect[0]) / bw, - rect[1] - b[1] * (rect[3] - rect[1]) / bh, - ]); - // The Do that draws the form applies its /Matrix itself, so only the placement goes here. - Some(a) -} - -/// A Form XObject holding page `index` as it prints (user space; BBox = crop box). -/// `pages` are the document's pages, listed once by the caller: listing them for each page -/// printed would make printing quadratic in the page count. -fn page_form( - doc: &mut Document, - pages: &[pdfcraft_model::Page], - index: usize, - content: Content, - promoted: &mut std::collections::HashMap, -) -> Result { - let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; - let crop = page.crop(doc); - let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut body = if content == Content::FormFieldsOnly { - Vec::new() - } else { - decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) - .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? - }; - // Wrap the page content so a state it leaves behind can't affect the annotations. - if !body.is_empty() { - let mut wrapped = b"q\n".to_vec(); - wrapped.extend_from_slice(&body); - wrapped.extend_from_slice(b"\nQ\n"); - body = wrapped; - } - let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); - let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - let mut k = 0usize; - for a in annots { - let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; - if !prints(doc, &d, content) { - continue; - } - let rect: Vec = - d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - if rect.len() != 4 { - continue; - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let Some(ap) = appearance(doc, &d) else { continue }; - let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); - let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; - let ap = if !form.contains(b"Subtype") - && let Object::Stream(stream) = &*doc.get(ap) - { - // Some existing annotation appearances omit Subtype, but Do dispatch needs a Form XObject. - // Keep the original intact: other document objects can share this appearance. - if let Some(&cached) = promoted.get(&ap) { - cached - } else { - let mut stream = stream.clone(); - stream.dict.set(b"Subtype".to_vec(), Object::name("Form")); - let copy = doc.add(Object::Stream(stream)); - promoted.insert(ap, copy); - copy - } - } else { - ap - }; - // Keep the page's own XObjects, including names previous print operations generated. - // Check the growing map so each appearance also keeps the name just assigned to it. - let name = loop { - let name = format!("PCPrA{k}"); - k = k.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation appearance resources".into()))?; - if !xobjects.contains(name.as_bytes()) { - break name; - } - }; - let [a0, b0, c0, d0, e0, f0] = m.0; - body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); - xobjects.set(name.into_bytes(), Object::Ref(ap)); - } - if !xobjects.is_empty() { - res.set(b"XObject".to_vec(), Object::Dict(xobjects)); - } - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("XObject")); - d.set(b"Subtype".to_vec(), Object::name("Form")); - d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); - d.set(b"Resources".to_vec(), Object::Dict(res)); - if let Some(g) = page.dict.get(b"Group") { - d.set(b"Group".to_vec(), g.clone()); - } - Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) -} +mod error; +mod imposition; +mod settings; +mod sheets; +pub use error::{PrintError, WORDINGS, Words, in_english}; #[cfg(test)] -thread_local! { - /// Pages listed by [`source_pages`] on this thread. - static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// The source document's pages, for printing. -fn source_pages(doc: &Document) -> Vec { - let pages = pdfcraft_model::pages(doc); - #[cfg(test)] - PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); - pages -} - -/// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a -/// printer or is the user's own copy); callers must check the print permission first. -pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { - let pages = source_pages(src); - let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); - let sheets = layout(&sizes, settings)?; - let mut doc = src.clone(); - // One form per source page used (a page may appear on several sheets: posters). - let mut forms: std::collections::HashMap = std::collections::HashMap::new(); - let mut promoted = std::collections::HashMap::new(); - let views: Vec<[f64; 6]> = pages.iter().map(|p| p.view_matrix(src)).collect(); - let pages_root = doc.add(Object::Null); - let mut kids = Vec::new(); - for sheet in &sheets { - let mut c = String::new(); - let mut xo = Dict::new(); - for (i, pl) in sheet.placed.iter().enumerate() { - let form = match forms.get(&pl.page) { - Some(f) => *f, - None => { - let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; - forms.insert(pl.page, f); - f - } - }; - let name = format!("P{i}"); - xo.set(name.clone().into_bytes(), Object::Ref(form)); - // Display space → sheet, then clip to the visible part, then user → display. - let [a, b, cc, d, e, f] = pl.matrix.0; - let to_display = Matrix(views[pl.page]).invert().unwrap_or_default(); - let [ua, ub, uc, ud, ue, uf] = to_display.0; - let [x0, y0, x1, y1] = pl.clip; - c.push_str(&format!( - "q {} {} {} {} {} {} cm {} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do Q\n", - geometry_number(a), - geometry_number(b), - geometry_number(cc), - geometry_number(d), - geometry_number(e), - geometry_number(f), - geometry_number(x0), - geometry_number(y0), - geometry_number(x1 - x0), - geometry_number(y1 - y0), - geometry_number(ua), - geometry_number(ub), - geometry_number(uc), - geometry_number(ud), - geometry_number(ue), - geometry_number(uf) - )); - } - if !sheet.borders.is_empty() || !sheet.lines.is_empty() { - c.push_str("q 0 G 0.5 w\n"); - for r in &sheet.borders { - c.push_str(&format!("{} {} {} {} re S\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1]))); - } - for l in &sheet.lines { - c.push_str(&format!("{} {} m {} {} l S\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); - } - c.push_str("Q\n"); - } - let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))); - let mut res = Dict::new(); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Page")); - p.set(b"Parent".to_vec(), Object::Ref(pages_root)); - p.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, sheet.size.0, sheet.size.1].iter().map(|v| Object::Real(*v)).collect())); - p.set(b"Resources".to_vec(), Object::Dict(res)); - p.set(b"Contents".to_vec(), Object::Ref(contents)); - kids.push(Object::Ref(doc.add(Object::Dict(p)))); - } - let mut tree = Dict::new(); - tree.set(b"Type".to_vec(), Object::name("Pages")); - tree.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); - tree.set(b"Kids".to_vec(), Object::Array(kids)); - doc.set(pages_root, Object::Dict(tree)); - let mut cat = Dict::new(); - cat.set(b"Type".to_vec(), Object::name("Catalog")); - cat.set(b"Pages".to_vec(), Object::Ref(pages_root)); - // Keep the layers' configuration, or a layer that is off in the document would print: the - // pages' marked content still refers to its groups, and without this nothing says they are off. - let layers = src.trailer().get(b"Root").and_then(|r| src.resolve(r).as_dict().and_then(|root| root.get(b"OCProperties").cloned())); - if let Some(layers) = layers { - cat.set(b"OCProperties".to_vec(), layers); - } - let root = doc.add(Object::Dict(cat)); - doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); - doc.trailer_mut().remove(b"Info"); - if doc.trailer().contains(b"Encrypt") { - doc.remove_encryption(); - } - Ok(write_full(&doc, &SaveOptions::default())?) -} - -/// Which source pages land on each sheet (for previews and the sheet count). -pub fn sheet_pages(sheets: &[Sheet]) -> Vec> { - sheets.iter().map(|s| s.placed.iter().map(|p| p.page).collect()).collect() -} +use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, page_form, prints}; +pub use imposition::{impose, keep_printed, sheet_pages}; +pub use settings::{ + Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, + cups_media, media_option, +}; +#[cfg(test)] +use sheets::text_direction; +pub use sheets::{LABEL_MARGIN, MARGIN, Placement, Sheet, layout, matching_paper, text_reads_up}; diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs new file mode 100644 index 0000000000..7c9554f25e --- /dev/null +++ b/crates/print/src/settings.rs @@ -0,0 +1,250 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Orientation { + #[default] + Auto, + Portrait, + Landscape, +} + +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum SizeMode { + #[default] + Fit, + Actual, + Shrink, + /// Percent. + Custom(f64), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageOrder { + #[default] + Horizontal, + HorizontalReversed, + Vertical, + VerticalReversed, + /// Single-sided sheets: cut into cell piles, then stack them in row-major order. + CutStack, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum BookletSubset { + #[default] + BothSides, + FrontOnly, + BackOnly, +} + +/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as +/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro, +/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for +/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned +/// sideways; front side only or back side only is one pass of a manual duplex and prints +/// single-sided. +pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { + match subset { + BookletSubset::BothSides => spool::Duplex::ShortEdge, + BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Binding { + #[default] + Left, + Right, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Layout { + Size(SizeMode), + Multiple { + cols: usize, + rows: usize, + /// Source pages on each sheet, at most `cols × rows`: three per sheet uses a 2 × 2 grid + /// with one cell left blank. + per_sheet: usize, + order: PageOrder, + border: bool, + auto_rotate: bool, + }, + Booklet { + subset: BookletSubset, + binding: Binding, + }, + /// Tile scale (percent), overlap (points), cut marks. + Poster { + scale: f64, + overlap: f64, + cut_marks: bool, + }, + /// Shipping label: each page cut to its [`Settings::crops`] box, turned so its text reads + /// upright on the sheet and fitted with [`LABEL_MARGIN`]; `flip` turns it a further 180°. + Label { + flip: bool, + }, +} + +impl Default for Layout { + fn default() -> Self { + Layout::Size(SizeMode::Fit) + } +} + +impl Layout { + /// Acrobat's pages-per-sheet choices as a grid (columns × rows on a portrait sheet). + pub fn multiple(per_sheet: usize) -> Layout { + let (cols, rows) = match per_sheet { + 2 => (1, 2), + 4 => (2, 2), + 6 => (2, 3), + 9 => (3, 3), + 16 => (4, 4), + n => { + let c = (n as f64).sqrt().ceil().max(1.0) as usize; + (c, n.div_ceil(c).max(1)) + } + }; + Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true } + } +} + +/// Acrobat's Comments & Forms choices. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Content { + /// Page content and form fields. + Document, + /// Page content, form fields and every printable comment. + #[default] + DocumentAndMarkups, + /// Page content, form fields and stamps. + DocumentAndStamps, + /// Form fields only. + FormFieldsOnly, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Settings { + /// Source pages (0-based) in print order; see [`select_pages`]. + pub pages: Vec, + /// Paper (width, height) in points, portrait. + pub paper: (f64, f64), + pub orientation: Orientation, + pub layout: Layout, + pub content: Content, + /// For [`Layout::Label`], per source page: the area to keep (display space) and whether its + /// text runs bottom-to-top (so a quarter turn clockwise makes it upright). `None` or a + /// missing entry keeps the whole page. + pub crops: Vec>, + /// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this + /// resolution before it is placed on the paper. The engine does the rendering; `impose` is + /// then given the image-only document. + pub raster: Option, +} + +/// How pages are rendered when printing as image. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Raster { + /// Dots per inch, 72–600 (Acrobat offers 150, 300 and 600). + pub dpi: f64, + /// Render the pictures in grey, so the print-ready PDF itself is grayscale. + pub grayscale: bool, +} + +impl Default for Raster { + fn default() -> Self { + Raster { dpi: 300.0, grayscale: false } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Crop { + pub rect: [f64; 4], + pub text_up: bool, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + pages: Vec::new(), + paper: PAPERS[0].1, + orientation: Orientation::Auto, + layout: Layout::default(), + content: Content::default(), + raster: None, + crops: Vec::new(), + } + } +} + +/// Common paper sizes (points, portrait). +pub const PAPERS: [(&str, (f64, f64)); 21] = [ + ("US Letter", (612.0, 792.0)), + ("US Legal", (612.0, 1008.0)), + ("Tabloid", (792.0, 1224.0)), + ("A3", (841.89, 1190.55)), + ("A4", (595.28, 841.89)), + ("A5", (419.53, 595.28)), + ("A6", (297.64, 419.53)), + ("ARCH A (9 x 12 in)", (648.0, 864.0)), + ("ARCH B (12 x 18 in)", (864.0, 1296.0)), + ("ARCH C (18 x 24 in)", (1296.0, 1728.0)), + ("ARCH D (24 x 36 in)", (1728.0, 2592.0)), + ("ARCH E1 (30 x 42 in)", (2160.0, 3024.0)), + ("ARCH E (36 x 48 in)", (2592.0, 3456.0)), + ("ANSI C (17 x 22 in)", (1224.0, 1584.0)), + ("ANSI D (22 x 34 in)", (1584.0, 2448.0)), + ("ANSI E (34 x 44 in)", (2448.0, 3168.0)), + ("A2", (1190.55, 1683.78)), + ("A1", (1683.78, 2383.94)), + ("A0", (2383.94, 3370.39)), + ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), + ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), +]; + +/// `PAPERS` index of the 4 × 6 in shipping label. +pub const LABEL_PAPER: usize = 19; + +/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out +/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes +/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). +/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the +/// spec and the IANA registry have them: a driverless printer may take only the names it +/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions +/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752). +pub fn cups_media(index: usize) -> Option<&'static str> { + Some(match PAPERS.get(index)?.0 { + "US Letter" => "Letter", + "US Legal" => "Legal", + "Tabloid" => "Tabloid", + "A3" => "A3", + "A4" => "A4", + "A5" => "A5", + "A6" => "iso_a6_105x148mm", + "ARCH A (9 x 12 in)" => "na_arch-a_9x12in", + "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", + "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", + "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", + "ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in", + "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", + "ANSI C (17 x 22 in)" => "na_c_17x22in", + "ANSI D (22 x 34 in)" => "na_d_22x34in", + "ANSI E (34 x 44 in)" => "na_e_34x44in", + "A2" => "iso_a2_420x594mm", + "A1" => "iso_a1_594x841mm", + "A0" => "iso_a0_841x1189mm", + "US #10 Envelope (4.125 x 9.5 in)" => "Env10", + _ if index == LABEL_PAPER => "w288h432", + // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's + // paper stands, as it did for every size before #1583. + _ => return None, + }) +} + +/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print +/// dialog's jobs and `doc_print`'s alike. +pub fn media_option(index: usize) -> Option<(String, String)> { + cups_media(index).map(|m| ("media".to_string(), m.to_string())) +} diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs new file mode 100644 index 0000000000..ce80adc3eb --- /dev/null +++ b/crates/print/src/sheets.rs @@ -0,0 +1,350 @@ +use super::error::PrintError; +use super::imposition::decoded_contents; +use super::settings::{Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, Settings, SizeMode}; +use super::*; + +/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid +/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects +/// are followed a few levels deep. +pub fn text_reads_up(doc: &Document, index: usize) -> bool { + let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false }; + let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); + let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut score = 0i64; + let mut budget = DecodeBudget::page(); + // Content past the budget is left out: the answer is a guess from the text read so far. + let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); + text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); + score > 0 +} + +pub(crate) fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { + let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); + for op in pdfcraft_content::parse(data).ops { + match op.op.as_slice() { + // Bounded like the renderer's graphics-state stack: deeper saves are ignored. + b"q" if stack.len() < 256 => stack.push(ctm), + b"Q" => ctm = stack.pop().unwrap_or(base), + b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm), + b"BT" => tm = Matrix::IDENTITY, + b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(), + b"Tj" | b"TJ" | b"'" | b"\"" => { + let [a, b, ..] = tm.then(&ctm).0; + *score += i64::from(b > a.abs()) - i64::from(-b > a.abs()); + } + b"Do" if depth < 4 => { + let Some(name) = op.name(0) else { continue }; + let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue }; + let Object::Stream(form) = &*doc.resolve(&xobj) else { continue }; + if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { + continue; + } + let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); + let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); + if !budget.enter_form() { + continue; + } + if let Ok(body) = budget.decode(form) { + text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); + } + } + _ => {} + } + } +} + +/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed +/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet. +pub const LABEL_MARGIN: f64 = 8.0; + +/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by +/// `quarter` quarter turns counter-clockwise (0–3). +fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement { + let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]); + let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) }; + let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0); + let [a, b, c, d, e, f] = match quarter % 4 { + 0 => [s, 0.0, 0.0, s, x, y], + 1 => [0.0, s, -s, 0.0, x + pw, y], + 2 => [-s, 0.0, 0.0, -s, x + pw, y + ph], + _ => [0.0, -s, s, 0.0, x, y + ph], + }; + // Move the crop's corner to the origin first. + Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop } +} + +/// Match a displayed PDF page to a standard sheet, independent of orientation. +/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes. +pub fn matching_paper(size: (f64, f64)) -> Option { + if !size.0.is_finite() || !size.1.is_finite() { + return None; + } + let size = (size.0.min(size.1), size.0.max(size.1)); + PAPERS.iter().position(|(_, p)| (p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5) +} + +/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet. +pub const MARGIN: f64 = 18.0; + +/// One page as placed on a sheet: the source page, the matrix from its *display space* to the +/// sheet, and the visible part of it in display space (clipping). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub page: usize, + pub matrix: Matrix, + pub clip: [f64; 4], +} + +/// One output sheet. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Sheet { + pub size: (f64, f64), + pub placed: Vec, + /// Page borders (Multiple) and cut marks (Poster), in sheet space: rectangles and lines. + pub borders: Vec<[f64; 4]>, + pub lines: Vec<[f64; 4]>, +} + +fn oriented(paper: (f64, f64), o: Orientation, landscape_wanted: bool) -> (f64, f64) { + let (w, h) = (paper.0.min(paper.1), paper.0.max(paper.1)); + match o { + Orientation::Portrait => (w, h), + Orientation::Landscape => (h, w), + Orientation::Auto => { + if landscape_wanted { + (h, w) + } else { + (w, h) + } + } + } +} + +/// Place a page of display size `(dw, dh)` inside `cell` (sheet space), scaled by `s` +/// (centred), optionally rotated 90° (counter-clockwise). +fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: bool) -> Placement { + let (cw, ch) = (cell[2] - cell[0], cell[3] - cell[1]); + let (pw, ph) = if rotate { (dh * s, dw * s) } else { (dw * s, dh * s) }; + let (x, y) = (cell[0] + (cw - pw) / 2.0, cell[1] + (ch - ph) / 2.0); + let matrix = if rotate { Matrix([0.0, s, -s, 0.0, x + pw, y]) } else { Matrix([s, 0.0, 0.0, s, x, y]) }; + Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } +} + +fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { + if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } +} + +/// Lay out the sheets for `settings` given each source page's display size. +pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { + let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); + if pages.is_empty() { + return Err(PrintError::NoPages); + } + let paper = settings.paper; + if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { + return Err(PrintError::Invalid("invalid paper size".into())); + } + let mut sheets = Vec::new(); + match settings.layout { + Layout::Size(mode) => { + for &p in &pages { + let d = sizes[p]; + let size = oriented(paper, settings.orientation, d.0 > d.1); + let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); + let s = match mode { + SizeMode::Fit => fit, + SizeMode::Actual => 1.0, + SizeMode::Shrink => fit.min(1.0), + SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, + SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), + }; + sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); + } + } + Layout::Label { flip } => { + let size = oriented(paper, settings.orientation, false); + let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; + for &p in &pages { + let d = sizes[p]; + let crop = settings.crops.get(p).copied().flatten(); + let rect = crop + .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) + .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) + .unwrap_or([0.0, 0.0, d.0, d.1]); + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); + let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { + (false, _) => 0, + (true, true) => 3, + (true, false) => 1, + }; + if flip { + quarter = (quarter + 2) % 4; + } + let s = if turn { + fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) + } else { + fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) + }; + sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }); + } + } + Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { + let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); + let Some(cells) = cells else { + return Err(PrintError::Invalid("invalid number of pages per sheet".into())); + }; + // The grid can have more cells than the pages asked for on each sheet; the rest stay blank. + let per_sheet = per_sheet.min(cells); + if per_sheet == 0 { + return Err(PrintError::Invalid("invalid number of pages per sheet".into())); + } + // A grid with more columns than rows wants a landscape sheet, and the reverse. + let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; + let page_landscape = first.0 > first.1; + let (gc, gr, sheet_landscape) = if cols == rows { + (cols, rows, page_landscape) + } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { + // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. + (rows, cols, true) + } else { + (cols, rows, false) + }; + let size = oriented(paper, settings.orientation, sheet_landscape); + let gap = 6.0; + let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); + let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64); + if cw <= 0.0 || ch <= 0.0 { + return Err(PrintError::Invalid("the page grid does not fit on the paper".into())); + } + let sheet_count = pages.len().div_ceil(per_sheet); + for sheet_index in 0..sheet_count { + let mut sheet = Sheet { size, ..Sheet::default() }; + if order == PageOrder::CutStack { + // Marks align across every sheet, including unoccupied cells. Cut in the + // middle of each gutter; marks stay in the printable area, clear of cells. + for col in 1..gc { + let x = MARGIN + col as f64 * (cw + gap) - gap / 2.0; + sheet.lines.extend([[x, MARGIN, x, MARGIN + 8.0], [x, size.1 - MARGIN - 8.0, x, size.1 - MARGIN]]); + } + for row in 1..gr { + let y = size.1 - MARGIN - row as f64 * (ch + gap) + gap / 2.0; + sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]); + } + } + for k in 0..per_sheet { + // A cell's pile gets consecutive pages. Missing pages leave blank cells, + // rather than shifting later piles left on the final sheets. + let index = if order == PageOrder::CutStack { + k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index)) + } else { + sheet_index.checked_mul(per_sheet).and_then(|n| n.checked_add(k)) + }; + let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue }; + + let (col, row) = match order { + PageOrder::Horizontal | PageOrder::CutStack => (k % gc, k / gc), + PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc), + PageOrder::Vertical => (k / gr, k % gr), + PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr), + }; + let x0 = MARGIN + col as f64 * (cw + gap); + let y1 = size.1 - MARGIN - row as f64 * (ch + gap); + let cell = [x0, y1 - ch, x0 + cw, y1]; + let d = sizes.get(p).copied().ok_or_else(|| PrintError::Invalid("page size is missing".into()))?; + if !(d.0.is_finite() && d.1.is_finite() && d.0 > 0.0 && d.1 > 0.0) { + return Err(PrintError::Invalid("invalid page size".into())); + } + let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0; + let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch)); + if !s.is_finite() || s <= 0.0 { + return Err(PrintError::Invalid("page size cannot be scaled to the grid".into())); + } + let pl = place_in(p, d, cell, s, rotate); + if border { + let b = pl.matrix.bbox(pl.clip); + sheet.borders.push(b); + } + sheet.placed.push(pl); + } + sheets.push(sheet); + } + } + Layout::Booklet { subset, binding } => { + // Pad to a multiple of 4 with blank pages (None). + let n4 = pages.len().div_ceil(4) * 4; + let at = |i: usize| pages.get(i).copied(); + let size = oriented(paper, Orientation::Landscape, true); + let half = (size.0 - 2.0 * MARGIN) / 2.0; + let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; + for k in 0..n4 / 4 { + let front = (at(n4 - 1 - 2 * k), at(2 * k)); + let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); + let mut sides = Vec::new(); + if subset != BookletSubset::BackOnly { + sides.push(front); + } + if subset != BookletSubset::FrontOnly { + sides.push(back); + } + for (l, r) in sides { + let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; + let mut sheet = Sheet { size, ..Sheet::default() }; + for (p, cell) in [(l, cells[0]), (r, cells[1])] { + if let Some(p) = p { + let d = sizes[p]; + let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); + sheet.placed.push(place_in(p, d, cell, s, false)); + } + } + sheets.push(sheet); + } + } + } + Layout::Poster { scale, overlap, cut_marks } => { + if !(scale.is_finite() && scale > 0.0 && overlap.is_finite() && overlap >= 0.0) { + return Err(PrintError::Invalid("invalid tile scale or overlap".into())); + } + let s = scale / 100.0; + for &p in &pages { + let d = sizes[p]; + let size = oriented(paper, settings.orientation, d.0 > d.1); + // Each tile shows `step` of the scaled page plus `overlap` shared with the next. + let (tw, th) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); + if overlap * 2.0 >= tw.min(th) { + return Err(PrintError::Invalid("the overlap is larger than the tile".into())); + } + let (sw, sh) = (d.0 * s, d.1 * s); + let (stepx, stepy) = (tw - overlap, th - overlap); + let nx = (((sw - overlap) / stepx).ceil() as usize).max(1); + let ny = (((sh - overlap) / stepy).ceil() as usize).max(1); + // The counts saturate at usize::MAX for a huge scale, so their product can overflow. + if nx.checked_mul(ny).is_none_or(|n| n > 1024) { + return Err(PrintError::Invalid("the poster would need more than 1024 sheets".into())); + } + // Tiles from the top-left, row by row. + for ty in 0..ny { + for tx in 0..nx { + // The window into the scaled page (scaled display space, y up). + let wx0 = tx as f64 * stepx; + let wy1 = sh - ty as f64 * stepy; + let matrix = Matrix([s, 0.0, 0.0, s, MARGIN - wx0, size.1 - MARGIN - wy1]); + let clip = [wx0 / s, (wy1 - th) / s, (wx0 + tw) / s, wy1 / s]; + let mut sheet = Sheet { size, placed: vec![Placement { page: p, matrix, clip }], ..Sheet::default() }; + if cut_marks { + let (x0, y0, x1, y1) = (MARGIN, MARGIN, size.0 - MARGIN, size.1 - MARGIN); + for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { + let dx = if x == x0 { -1.0 } else { 1.0 }; + let dy = if y == y0 { -1.0 } else { 1.0 }; + sheet.lines.push([x + dx * 2.0, y, x + dx * (MARGIN - 2.0), y]); + sheet.lines.push([x, y + dy * 2.0, x, y + dy * (MARGIN - 2.0)]); + } + } + sheets.push(sheet); + } + } + } + } + } + Ok(sheets) +} From 4e278cb5fc7e9e47b923cf902700aaf91ba0711c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:38:14 +0000 Subject: [PATCH 083/246] refactor(xfa): split data.rs into data/ New modules: path, dates, values, build, merge, write, import, passwords. --- crates/xfa/src/data.rs | 1907 +----------------------------- crates/xfa/src/data/build.rs | 164 +++ crates/xfa/src/data/dates.rs | 193 +++ crates/xfa/src/data/import.rs | 353 ++++++ crates/xfa/src/data/merge.rs | 399 +++++++ crates/xfa/src/data/passwords.rs | 194 +++ crates/xfa/src/data/path.rs | 156 +++ crates/xfa/src/data/values.rs | 192 +++ crates/xfa/src/data/write.rs | 264 +++++ 9 files changed, 1944 insertions(+), 1878 deletions(-) create mode 100644 crates/xfa/src/data/build.rs create mode 100644 crates/xfa/src/data/dates.rs create mode 100644 crates/xfa/src/data/import.rs create mode 100644 crates/xfa/src/data/merge.rs create mode 100644 crates/xfa/src/data/passwords.rs create mode 100644 crates/xfa/src/data/path.rs create mode 100644 crates/xfa/src/data/values.rs create mode 100644 crates/xfa/src/data/write.rs diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index eac2f20bf4..7c387c44ef 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -15,6 +15,35 @@ use crate::XfaError; use crate::model::{Field, Node, SomIndices, Template, Ui}; use crate::packets::{Encoding, encode}; +mod build; +mod dates; +mod import; +mod merge; +mod passwords; +mod path; +mod values; +mod write; + +use build::MAX_INDEX; +pub use build::build_data; +#[cfg(test)] +pub(crate) use dates::iso_to_data; +pub(crate) use dates::{data_date_letters, data_to_iso, date_letters, date_tokens}; +pub use dates::{iso_to_pattern, pattern_to_iso}; +#[cfg(test)] +pub(crate) use import::MAX_IMPORT_BYTES; +pub use import::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image}; +pub use passwords::{clear_password_values, has_password_values}; +pub(crate) use path::template_field; +pub use path::{data_path, parse_datasets, som_to_path}; +pub use values::{FieldData, FieldDatum, is_on, read_values, read_values_with}; +#[cfg(test)] +pub(crate) use write::ENSURED; +pub use write::{ + DatasetsWrite, add_data_instances, remove_data_instance, write_data_ops, write_data_value, write_datasets, write_datasets_reusing, + write_datasets_with, +}; + const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/"; /// Most data nodes read. const MAX_NODES: u32 = 1_000_000; @@ -61,1199 +90,6 @@ impl DataNode { } } -fn to_node(n: roxmltree::Node, depth: usize) -> DataNode { - let name = n.tag_name().name().to_string(); - let children: Vec = - if depth < MAX_DEPTH { n.children().filter(|c| c.is_element()).map(|c| to_node(c, depth + 1)).collect() } else { Vec::new() }; - // The text of a leaf is its value; a container's own text is whitespace. - let text = if children.is_empty() { n.children().filter_map(|c| c.text()).collect::() } else { String::new() }; - DataNode { name, text, children } -} - -/// The `xfa:data` element of the datasets packet in `xdp` (or a bare datasets document), as a -/// tree whose root is that element. -pub fn parse_datasets(xdp: &str) -> Option { - if crate::xml_nesting_depth(xdp) > crate::MAX_XML_DEPTH { - return None; - } - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - let doc = roxmltree::Document::parse_with_options(xdp, opts).ok()?; - let datasets = doc.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets")?; - let data = datasets.children().find(|n| n.is_element() && n.tag_name().name() == "data")?; - Some(to_node(data, 0)) -} - -/// The data path a SOM expression names: `form[0].#subform[0].name[1]` → `[(form, 0), (name, 1)]`. -/// Unnamed containers (`#subform`) have no data node. -pub fn som_to_path(som: &str) -> DataPath { - som.split('.') - .filter_map(|seg| { - let seg = seg.trim(); - if seg.is_empty() || seg.starts_with('#') || seg.starts_with('$') { - return None; - } - let (name, idx) = match seg.split_once('[') { - Some((n, rest)) => (n, rest.trim_end_matches(']').parse::().unwrap_or(0)), - None => (seg, 0), - }; - Some((name.to_string(), idx.min(100_000))) - }) - .collect() -} - -/// The data path the object at `som` binds to: [`som_to_path`] without the areas on the way. -/// An area is a named container in the form, so it is part of SOM expressions, but only -/// subforms, exclusion groups and fields are data scopes: what an area holds binds under the -/// area's parent, as Adobe's viewers write it. The template tells which segments are areas; -/// segments it doesn't have (page-set objects, or a SOM from another template) are kept. -pub fn data_path(tpl: &Template, som: &str) -> DataPath { - let mut out = DataPath::new(); - // The template children the next segment is looked up in; `None` until the root matched, - // and again once a segment is not a container the template has. - let mut level: Option<&[Node]> = None; - for (i, (name, index)) in som_to_path(som).into_iter().enumerate() { - let found = match level { - None if i == 0 && tpl.root.common.name.as_deref() == Some(name.as_str()) => Some(Container::Children(&tpl.root.children)), - None => None, - Some(children) => match named(children, &name, 0) { - Some(Node::Subform(sf)) => Some(Container::Children(&sf.children)), - Some(Node::Area(a)) => Some(Container::Area(&a.children)), - _ => None, - }, - }; - match found { - Some(Container::Area(children)) => level = Some(children), - Some(Container::Children(children)) => { - level = Some(children); - out.push((name, index)); - } - None => { - level = None; - out.push((name, index)); - } - } - } - out -} - -enum Container<'a> { - Children(&'a [Node]), - /// Contents bind under the area's parent. - Area(&'a [Node]), -} - -/// The child of `nodes` named `name`, looking through unnamed subforms and areas (which have no -/// SOM segment of their own). -fn named<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> { - if depth > MAX_DEPTH { - return None; - } - nodes.iter().find(|n| n.common().name.as_deref() == Some(name)).or_else(|| { - nodes.iter().find_map(|n| match n { - Node::Subform(sf) if sf.common.name.is_none() => named(&sf.children, name, depth + 1), - Node::Area(a) if a.common.name.is_none() => named(&a.children, name, depth + 1), - _ => None, - }) - }) -} - -/// The date letters (`yyyy-mm-dd`, `yyyymmdd`) of an XFA date picture clause such as -/// `date{YYYY-MM-DD}`, or `None` when it has none this reads (empty, or with symbols beyond -/// years, months, days and separators). -pub(crate) fn date_letters(picture: &str) -> Option { - let inner = picture - .find('{') - .and_then(|a| { - let rest = picture.get(a + 1..)?; - rest.find('}').and_then(|b| rest.get(..b)) - }) - .unwrap_or(picture) - .trim(); - if inner.is_empty() || inner.chars().any(|c| !"YMDEJ-/., :".contains(c)) { - return None; - } - Some(inner.replace('E', "d").to_ascii_lowercase()) -} - -/// The date letters of `f`'s data picture, when it holds a whole date. A data picture without a -/// year, a month or a day can't hold the date the field shows, so such a field's data keeps -/// ISO dates. -pub(crate) fn data_date_letters(f: &Field) -> Option { - let letters = f.data_picture.as_deref().and_then(date_letters)?; - let tokens = date_tokens(&letters); - let has = |any: &[&str]| tokens.iter().any(|t| any.contains(&t.as_str())); - (has(&["yyyy", "yy"]) && has(&["mm", "m"]) && has(&["dd", "d"])).then_some(letters) -} - -/// A date as the data holds it, written in `data_pattern` (the field's data picture), read as -/// an ISO date. A value that doesn't match, or a field without a data picture, is taken as it -/// is: the data holds ISO dates by default. -pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> String { - data_pattern.and_then(|p| pattern_to_iso(value, p)).unwrap_or_else(|| value.to_string()) -} - -/// An ISO date as the data should hold it: in `data_pattern` when the field has one and the -/// date reads back from it unchanged (a two-digit year holds only 1950 to 2049), else as ISO. -pub(crate) fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String { - match data_pattern { - Some(p) => { - let written = iso_to_pattern(&iso, p); - if pattern_to_iso(&written, p).as_deref() == Some(iso.as_str()) { written } else { iso } - } - None => iso, - } -} - -/// The child of `nodes` the SOM segment `name[index]` names, numbered as the form numbers its -/// objects ([`SomIndices`]): same-named siblings count up, and the instances of a repeating -/// subform take the numbers from its own on. Unnamed subforms and areas are looked through, -/// as [`named`] does. -fn named_at<'a>(nodes: &'a [Node], name: &str, index: usize, depth: usize) -> Option<&'a Node> { - if depth > MAX_DEPTH { - return None; - } - let sibs = SomIndices::of(nodes); - let mut repeating = None; - for (i, n) in nodes.iter().enumerate() { - if n.common().name.as_deref() != Some(name) { - continue; - } - let sib = sibs.at(i); - if sib == index { - return Some(n); - } - if sib < index && matches!(n, Node::Subform(sf) if sf.occur.max != Some(1)) { - repeating = Some(n); - } - } - repeating.or_else(|| { - nodes.iter().find_map(|n| match n { - Node::Subform(sf) if sf.common.name.is_none() => named_at(&sf.children, name, index, depth + 1), - Node::Area(a) if a.common.name.is_none() => named_at(&a.children, name, index, depth + 1), - _ => None, - }) - }) -} - -/// The template field the object at a SOM path is an instance of, each segment's number counted -/// as the form counts it ([`named_at`]). `None` when the template has no such field, or it is -/// a button of an exclusion group (whose buttons take their value from the group). -pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> { - let path = som_to_path(som); - let ((root, _), rest) = path.split_first()?; - if tpl.root.common.name.as_deref() != Some(root.as_str()) { - return None; - } - let ((last, last_index), inner) = rest.split_last()?; - let mut children: &[Node] = &tpl.root.children; - for (name, index) in inner { - children = match named_at(children, name, *index, 0)? { - Node::Subform(sf) => &sf.children, - Node::Area(a) => &a.children, - _ => return None, - }; - } - match named_at(children, last, *last_index, 0)? { - Node::Field(f) => Some(f), - _ => None, - } -} - -/// The date letters of the data picture of the field `som` names, when it has one. -fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option { - tpl.and_then(|t| template_field(t, som)).and_then(data_date_letters) -} - -/// What a field holds, for writing the datasets. -#[derive(Clone, Debug, PartialEq)] -pub enum FieldData { - Text(String), - /// Checked or not. - Check(bool), - /// The selected button's on value, if any. - Radio(Option), - /// Nothing to write (buttons, signatures). - None, -} - -/// One field of the AcroForm, as the engine sees it. -#[derive(Clone, Debug, PartialEq)] -pub struct FieldDatum { - /// The terminal field dictionary (where `/PCSom` and `/PCItems` live). - pub obj: ObjRef, - /// The fully qualified field name (a static form's SOM path). - pub name: String, - pub data: FieldData, -} - -fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { - d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())) -} - -/// The SOM path of a field: its `/PCSom` when PdfCraft generated it, else its name. -fn som_of(doc: &Document, f: &FieldDatum) -> String { - doc.get(f.obj).as_dict().and_then(|d| text_of(doc, d, crate::pdf::SOM_KEY)).unwrap_or_else(|| f.name.clone()) -} - -/// The datasets text for a radio group. The AcroForm value is an appearance-state name -/// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item. -fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { - let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else { - return String::new(); - }; - let Some(group) = doc.get(f.obj).as_dict().cloned() else { return selected.to_string() }; - let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]); - for kid in kids { - let Some(widget) = doc.resolve(&kid).as_dict().cloned() else { continue }; - let original = text_of(doc, &widget, b"PCOn"); - let state = widget - .get(b"AP") - .map(|ap| doc.resolve(ap)) - .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) - .and_then(|n| doc.resolve(&n).as_dict().cloned()) - .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); - if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) { - return original.unwrap_or_else(|| selected.to_string()); - } - } - selected.to_string() -} - -/// The on and off item values of a check box (`/PCItems`), or the widget's on state. -fn items_of(doc: &Document, f: &FieldDatum) -> (String, String) { - let d = doc.get(f.obj); - let items: Vec = d - .as_dict() - .and_then(|d| d.get(b"PCItems").map(|o| doc.resolve(o))) - .and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| x.as_string().map(|s| s.to_text())).collect())) - .unwrap_or_default(); - let on = items.first().cloned().unwrap_or_else(|| "1".into()); - let off = items.get(1).cloned().unwrap_or_default(); - (on, off) -} - -/// The Acrobat date pattern a field's format action names (`AFDate_FormatEx("yyyy-mm-dd")`). -fn date_pattern_of(doc: &Document, f: &FieldDatum) -> Option { - let d = doc.get(f.obj); - let aa = doc.resolve(d.as_dict()?.get(b"AA")?); - let fa = doc.resolve(aa.as_dict()?.get(b"F")?); - let js = text_of(doc, fa.as_dict()?, b"JS")?; - let i = js.find("AFDate_FormatEx(")?; - let rest = &js[i + "AFDate_FormatEx(".len()..]; - let q = rest.find('"')?; - let body = &rest[q + 1..]; - Some(body[..body.find('"')?].to_string()) -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -/// The weekday (0 = Sunday) of a proleptic Gregorian date. -fn weekday(y: u32, m: u32, d: u32) -> usize { - const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; - let y = i64::from(y) - i64::from(m < 3); - let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); - ((y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7)) as usize -} - -/// The index and length of the name in `names` that `text` starts with (ignoring case), -/// cut to `abbr` letters when that is set. -fn name_at(text: &str, names: &[&str], abbr: bool) -> Option<(usize, usize)> { - names.iter().enumerate().find_map(|(i, n)| { - let n = if abbr { n.get(..3)? } else { n }; - text.get(..n.len()).filter(|t| t.eq_ignore_ascii_case(n)).map(|_| (i, n.len())) - }) -} - -/// Whether `token` is a run of the date letters (`y`, `m`, `d`) rather than a literal. -fn is_date_run(token: &str) -> bool { - token.starts_with(['y', 'm', 'd']) -} - -/// The tokens of a date pattern: runs of `y`, `m` and `d` (`yyyy`, `yy`, `mmmm`, `mmm`, `mm`, -/// `m`, `dddd`, `ddd`, `dd`, `d` are understood), or a literal character. -pub(crate) fn date_tokens(pattern: &str) -> Vec { - let mut out = Vec::new(); - let chars: Vec = pattern.chars().collect(); - let mut i = 0; - while i < chars.len() { - let c = chars[i].to_ascii_lowercase(); - if "ymd".contains(c) { - let mut j = i; - while j < chars.len() && chars[j].to_ascii_lowercase() == c { - j += 1; - } - out.push(std::iter::repeat_n(c, j - i).collect()); - i = j; - } else { - out.push(chars[i].to_string()); - i += 1; - } - } - out -} - -/// `2001-02-03` shown in `pattern` (`mm/dd/yyyy` → `02/03/2001`, `mmm d, yyyy` → -/// `Feb 3, 2001`). Patterns with a token this doesn't know and non-dates come back as they are. -pub fn iso_to_pattern(iso: &str, pattern: &str) -> String { - let parts: Vec<&str> = iso.trim().splitn(3, '-').collect(); - let [y, m, d] = parts[..] else { return iso.to_string() }; - let (Ok(y), Ok(m), Ok(d)) = (y.parse::(), m.parse::(), d.parse::()) else { return iso.to_string() }; - let mut out = String::new(); - for t in date_tokens(pattern) { - match t.as_str() { - "yyyy" => out.push_str(&format!("{y:04}")), - "yy" => out.push_str(&format!("{:02}", y % 100)), - "mm" => out.push_str(&format!("{m:02}")), - "m" => out.push_str(&m.to_string()), - "dd" => out.push_str(&format!("{d:02}")), - "d" => out.push_str(&d.to_string()), - "mmm" | "mmmm" => { - let Some(name) = m.checked_sub(1).and_then(|i| MONTHS.get(i as usize)) else { return iso.to_string() }; - match t.len() { - 3 => out.push_str(name.get(..3).unwrap_or(name)), - _ => out.push_str(name), - } - } - "ddd" | "dddd" => { - let name = WEEKDAYS.get(weekday(y, m, d)).copied().unwrap_or_default(); - match t.len() { - 3 => out.push_str(name.get(..3).unwrap_or(name)), - _ => out.push_str(name), - } - } - other if is_date_run(other) => return iso.to_string(), - other => out.push_str(other), - } - } - out -} - -/// A date typed as `pattern` back to `yyyy-mm-dd`. `None` when it doesn't match. -pub fn pattern_to_iso(text: &str, pattern: &str) -> Option { - let text = text.trim(); - let (mut y, mut m, mut d) = (None, None, None); - let mut day_name = None; - let mut rest = text; - let tokens = date_tokens(pattern); - for (i, t) in tokens.iter().enumerate() { - match t.as_str() { - "yyyy" | "yy" | "mm" | "m" | "dd" | "d" => { - // Digits up to the next literal (or a fixed width). - let fixed = matches!(t.as_str(), "yyyy" | "yy" | "mm" | "dd"); - let width = - if fixed { t.len() } else { rest.chars().take_while(char::is_ascii_digit).count().min(if t == "d" || t == "m" { 2 } else { 4 }) }; - let digits: String = rest.chars().take(width).collect(); - if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { - return None; - } - let v: u32 = digits.parse().ok()?; - rest = &rest[digits.len()..]; - match t.as_str() { - "yyyy" => y = Some(v), - "yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }), - "mm" | "m" => m = Some(v), - _ => d = Some(v), - } - } - "mmm" | "mmmm" => { - let (i, len) = name_at(rest, &MONTHS, t.len() == 3)?; - m = Some(i as u32 + 1); - rest = rest.get(len..)?; - } - "ddd" | "dddd" => { - let (i, len) = name_at(rest, &WEEKDAYS, t.len() == 3)?; - day_name = Some(i); - rest = rest.get(len..)?; - } - run if is_date_run(run) => return None, - lit => { - rest = rest.strip_prefix(lit)?; - } - } - let _ = i; - } - if !rest.is_empty() { - return None; - } - let (y, m, d) = (y?, m?, d?); - if day_name.is_some_and(|w| w != weekday(y, m, d)) { - return None; - } - (1..=12).contains(&m).then(|| format!("{y:04}-{m:02}-{d:02}")).filter(|_| (1..=31).contains(&d)) -} - -fn xml_name(s: &str) -> String { - let mut out: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { c } else { '_' }).collect(); - if out.is_empty() || !(out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')) { - out.insert(0, '_'); - } - out -} - -/// `s` as XML character data. Characters XML 1.0 cannot hold are left out; the flag says -/// whether any were. -fn escape(s: &str) -> (String, bool) { - let mut out = String::with_capacity(s.len()); - let mut dropped = false; - for c in s.chars() { - match c { - '&' => out.push_str("&"), - '<' => out.push_str("<"), - '>' => out.push_str(">"), - '"' => out.push_str("""), - // Parsers turn a raw carriage return into a line feed; a reference keeps it. - '\r' => out.push_str(" "), - '\u{FFFE}' | '\u{FFFF}' => dropped = true, - c if c.is_control() && c != '\n' && c != '\t' => dropped = true, - c => out.push(c), - } - } - (out, dropped) -} - -/// Most data nodes one write may create (instances skipped by a SOM index count too). -const MAX_NEW_NODES: usize = 20_000; -/// Highest same-name sibling index a write follows. -const MAX_INDEX: usize = 10_000; -/// Most bytes of names the nodes one write creates hold together: every sibling an index -/// skips over gets its own copy of the name. -const MAX_NEW_NAME_BYTES: usize = 4 << 20; - -/// What one write may still create: nodes and bytes of their names. -struct NewNodes { - nodes: usize, - name_bytes: usize, -} - -impl NewNodes { - fn new() -> NewNodes { - NewNodes { nodes: MAX_NEW_NODES, name_bytes: MAX_NEW_NAME_BYTES } - } -} - -/// A data tree being built for writing. -#[derive(Default)] -struct Build { - name: String, - text: String, - children: Vec, - /// Positions in `children` by name, in order. - by_name: HashMap>, -} - -impl Build { - /// The node at `path` below this one, created as needed. `None` when the path is deeper - /// than [`MAX_DEPTH`], an index is above [`MAX_INDEX`], or creating it would spend more - /// new nodes or name bytes than `budget` holds. - fn at(&mut self, path: &[(String, usize)], budget: &mut NewNodes) -> Option<&mut Build> { - if path.len() > MAX_DEPTH { - return None; - } - let mut node = self; - for (name, idx) in path { - if *idx > MAX_INDEX { - return None; - } - let name = xml_name(name); - let have = node.by_name.get(&name).map_or(0, Vec::len); - if *idx >= have { - let need = idx - have + 1; - let bytes = need.saturating_mul(name.len()); - if need > budget.nodes || bytes > budget.name_bytes { - return None; - } - budget.nodes -= need; - budget.name_bytes -= bytes; - let start = node.children.len(); - node.children.extend((0..need).map(|_| Build { name: name.clone(), ..Default::default() })); - node.by_name.entry(name.clone()).or_default().extend(start..start + need); - } - let pos = *node.by_name.get(&name)?.get(*idx)?; - node = node.children.get_mut(pos)?; - } - Some(node) - } - - /// The tree as XML. Depth is bounded by [`Build::at`]. - fn write(&self, out: &mut String) { - if self.children.is_empty() { - out.push_str(&format!("<{}>{}", self.name, escape(&self.text).0, self.name)); - return; - } - out.push_str(&format!("<{}>", self.name)); - self.write_children(out); - out.push_str(&format!("", self.name)); - } - - fn write_children(&self, out: &mut String) { - for c in &self.children { - c.write(out); - } - } -} - -/// A value to write: where (the field's SOM path, and the data path it binds to) and what. -struct Want { - som: String, - path: DataPath, - text: String, - /// Create the node even for an empty value (an instance of a repeating subform). - ensure: bool, -} - -/// `tpl`, or the document's own template when none is given (parsed once). -fn with_template(doc: &Document, tpl: Option<&Template>, f: impl FnOnce(Option<&Template>) -> T) -> T { - match tpl { - Some(t) => f(Some(t)), - None => { - let parsed = crate::script::template_of(doc).ok(); - f(parsed.as_ref()) - } - } -} - -/// [`data_path`] when the template is known, else [`som_to_path`]. -fn path_of(tpl: Option<&Template>, som: &str) -> DataPath { - match tpl { - Some(t) => data_path(t, som), - None => som_to_path(som), - } -} - -/// The values the datasets should hold for `fields`, by data path: dates in each field's data -/// picture (or ISO), check and radio states, choice values as saved. -fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Template>) -> Vec { - let mut out = Vec::new(); - for f in fields { - let som = som_of(doc, f); - let path = path_of(tpl, &som); - if path.is_empty() { - continue; - } - let text = match &f.data { - FieldData::Text(t) => match date_pattern_of(doc, f) { - Some(p) => match pattern_to_iso(t, &p) { - // The date goes into the data in the field's data picture, or as an ISO date. - Some(iso) => iso_to_data(iso, data_date_pattern(tpl, &som).as_deref()), - None => t.clone(), - }, - None => t.clone(), - }, - FieldData::Check(on) => { - let (on_item, off_item) = items_of(doc, f); - if *on { on_item } else { off_item } - } - FieldData::Radio(sel) => radio_dataset_value(doc, f, sel), - FieldData::None => continue, - }; - out.push(Want { som, path, text, ensure: false }); - } - out -} - -/// What a rewrite of the datasets packet does. -#[derive(Clone, Copy)] -enum Op<'a> { - /// Put these values in (adding nodes as needed). - Values(&'a [Want]), - /// Take the node at this path out. - Remove(&'a DataPath), - /// Make these nodes the whole content of the `xfa:data` element (an import). - ReplaceData(&'a [ImportNode]), - /// Make the leaf at this path a picture: base64 text with its content type. - Picture(&'a DataPath, &'a str, &'a str), -} - -/// Note that a value could not be written (once per kind of reason, naming a few fields). -fn skipped(warnings: &mut Vec, reason: &str, som: &str) { - const MAX_WARNINGS: usize = 20; - if let Some(w) = warnings.iter_mut().find(|w| w.starts_with(reason)) { - if w.matches(", ").count() < 4 { - w.push_str(&format!(", {som}")); - } else if !w.ends_with('…') { - w.push_str(", …"); - } - } else if warnings.len() < MAX_WARNINGS { - warnings.push(format!("{reason}: {som}")); - } -} - -const TOO_MANY: &str = "XFA data not written for fields whose data path is too deep or repeats too often"; -const STRUCTURED: &str = "XFA data not written for fields whose data node holds structured content"; -const CONFLICT: &str = "XFA data not written for fields bound to the same data node as another field"; -const CONTROL: &str = "Characters XML can't hold were left out of the XFA data of"; - -/// Fresh data for `wants`: the children of an `xfa:data` element. -fn build_children(wants: &[Want], warnings: &mut Vec) -> String { - let mut root = Build::default(); - let mut budget = NewNodes::new(); - for w in wants { - match root.at(&w.path, &mut budget) { - Some(node) => node.text = w.text.clone(), - None => skipped(warnings, TOO_MANY, &w.som), - } - if escape(&w.text).1 { - skipped(warnings, CONTROL, &w.som); - } - } - let mut out = String::new(); - root.write_children(&mut out); - out -} - -/// The `xfa:data` element for `fields`, built afresh. -pub fn build_data(doc: &Document, fields: &[FieldDatum]) -> String { - let wants = with_template(doc, None, |t| wanted(doc, fields, t)); - format!("{}", build_children(&wants, &mut Vec::new())) -} - -fn fresh_datasets(data_children: &str) -> String { - format!("{data_children}") -} - -/// Where an element's start tag ends (the index of its `>`), skipping quoted attribute values. -fn start_tag_end(src: &str, range: &std::ops::Range) -> Option { - let bytes = src.as_bytes(); - let mut quote = None; - for i in range.clone() { - let b = *bytes.get(i)?; - match (quote, b) { - (Some(q), _) if b == q => quote = None, - (Some(_), _) => {} - (None, b'"' | b'\'') => quote = Some(b), - (None, b'>') => return Some(i), - _ => {} - } - } - None -} - -/// An element's qualified name as written. -fn qname<'s>(src: &'s str, range: &std::ops::Range) -> Option<&'s str> { - let rest = src.get(range.start.checked_add(1)?..range.end)?; - let end = rest.find(|c: char| c.is_whitespace() || c == '/' || c == '>')?; - rest.get(..end).filter(|n| !n.is_empty()) -} - -/// One change to the packet text: replace `start..end` with `text`. -struct Edit { - start: usize, - end: usize, - text: String, -} - -/// The edit that puts `inner` (markup) at the end of `node`'s content. -fn append_edit(src: &str, node: roxmltree::Node, inner: &str) -> Option { - let range = node.range(); - let tag_end = start_tag_end(src, &range)?; - if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { - // `` becomes `inner`. - let name = qname(src, &range)?; - return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{inner}") }); - } - let close = range.start + src.get(range.clone())?.rfind(" Option { - let range = node.range(); - let tag_end = start_tag_end(src, &range)?; - if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { - let name = qname(src, &range)?; - return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{text}") }); - } - let close = range.start + src.get(range.clone())?.rfind(" tag_end).then(|| Edit { start: tag_end + 1, end: close, text: text.to_string() }) -} - -/// What merging the values into a packet's text came to. -enum Merged { - /// The data already holds every value. - Unchanged, - Changed(String), - /// The text could not be merged into (the reason is in the warnings). - Failed, -} - -/// Where a datasets packet sits: one packet of an array (`Packet`), or inside the whole XDP. -#[derive(Clone, Copy, PartialEq)] -enum Within { - Packet, - Xdp, -} - -const WRAP_OPEN: &str = ""; -const WRAP_CLOSE: &str = ""; - -/// Write `wants` into the data of the datasets packet in `text`, changing only what has to -/// change: values go into the data nodes they bind to, missing nodes are added, and everything -/// else (data no field binds to, other namespaces, attributes, comments, layout) stays as -/// written. -/// The prefix bound to the XFA data namespace where `n` is (`None`: unbound or the default -/// namespace, which attributes can't use). -fn xfa_prefix(n: roxmltree::Node) -> Option { - n.namespaces().find(|ns| ns.uri() == XFA_DATA_NS).and_then(|ns| ns.name()).filter(|p| !p.is_empty()).map(str::to_string) -} - -/// Data read from a file, held as a tree so it can be written into any packet with the -/// prefix that packet uses, never as the file's own markup. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ImportNode { - pub name: String, - /// The element's namespace (`None`: none). - pub ns: Option, - /// `(namespace, name, value)`. - pub attrs: Vec<(Option, String, String)>, - pub text: String, - pub children: Vec, -} - -/// Deepest data an import keeps. -const MAX_IMPORT_DEPTH: usize = MAX_DEPTH; - -/// Most bytes of names, namespaces, attributes and text an import copies out of its file: a -/// namespace declared once is copied into every element and attribute that inherits it. -pub(crate) const MAX_IMPORT_BYTES: usize = 4 * MAX_IMPORT; - -/// What an import may still copy: nodes and bytes. -struct ImportBudget { - nodes: usize, - bytes: usize, -} - -impl ImportBudget { - fn new() -> ImportBudget { - ImportBudget { nodes: MAX_NODES as usize, bytes: MAX_IMPORT_BYTES } - } - - fn take_bytes(&mut self, n: usize) -> Result<(), XfaError> { - self.bytes = self - .bytes - .checked_sub(n) - .ok_or_else(|| XfaError::TooLarge(format!("the data file expands to more than {} MB", MAX_IMPORT_BYTES >> 20)))?; - Ok(()) - } -} - -/// `n` as an [`ImportNode`], counting nodes and copied bytes against `budget`; `TooLarge` past -/// the depth or either budget. -fn import_node(n: roxmltree::Node, depth: usize, budget: &mut ImportBudget) -> Result { - if depth > MAX_IMPORT_DEPTH { - return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); - } - budget.nodes = budget.nodes.checked_sub(1).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; - let name = n.tag_name().name(); - let ns = n.tag_name().namespace(); - budget.take_bytes(name.len().saturating_add(ns.map_or(0, str::len)))?; - let mut attrs = Vec::new(); - for a in n.attributes().filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/")) { - budget.take_bytes(a.namespace().map_or(0, str::len).saturating_add(a.name().len()).saturating_add(a.value().len()))?; - attrs.push((a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string())); - } - let mut children = Vec::new(); - let mut text = String::new(); - for c in n.children() { - if c.is_element() { - children.push(import_node(c, depth + 1, budget)?); - } else if let Some(t) = c.text() { - budget.take_bytes(t.len())?; - text.push_str(t); - } - } - // A group's own text is layout whitespace. - if !children.is_empty() && text.trim().is_empty() { - text.clear(); - } - Ok(ImportNode { name: name.to_string(), ns: ns.map(str::to_string), attrs, text, children }) -} - -/// `nodes` as markup for a packet whose XFA data namespace prefix is `xfa` (declared where -/// they go); other namespaces are declared on the elements and attributes that use them. -fn serialize_nodes(nodes: &[ImportNode], xfa: &str) -> String { - let mut out = String::new(); - for n in nodes { - serialize_node(n, xfa, &mut out, 0); - } - out -} - -fn serialize_node(n: &ImportNode, xfa: &str, out: &mut String, depth: usize) { - if depth > MAX_IMPORT_DEPTH { - return; - } - let mut decls: Vec<(String, String)> = Vec::new(); - let mut qualify = |ns: &Option, name: &str, attr: bool| -> String { - match ns.as_deref() { - None => xml_name(name), - Some(uri) if uri == XFA_DATA_NS => format!("{xfa}:{}", xml_name(name)), - Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", xml_name(name)), - Some(uri) => { - let p = match decls.iter().position(|(_, u)| u == uri) { - Some(i) => format!("n{i}"), - None => { - decls.push((format!("n{}", decls.len()), uri.to_string())); - format!("n{}", decls.len() - 1) - } - }; - let _ = attr; - format!("{p}:{}", xml_name(name)) - } - } - }; - let tag = qualify(&n.ns, &n.name, false); - let attrs: Vec = n - .attrs - .iter() - .take(256) - .map(|(ns, name, value)| { - let q = qualify(ns, name, true); - format!(" {q}=\"{}\"", escape(value).0) - }) - .collect(); - out.push('<'); - out.push_str(&tag); - for (p, uri) in &decls { - out.push_str(&format!(" xmlns:{p}=\"{}\"", escape(uri).0)); - } - for a in &attrs { - out.push_str(a); - } - if n.text.is_empty() && n.children.is_empty() { - out.push_str("/>"); - return; - } - out.push('>'); - out.push_str(&escape(&n.text).0); - for c in &n.children { - serialize_node(c, xfa, out, depth + 1); - } - out.push_str(&format!("")); -} - -fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merged { - let wants: &[Want] = match op { - Op::Values(w) => w, - Op::Remove(_) | Op::ReplaceData(_) | Op::Picture(..) => &[], - }; - // Fresh data, where none exists yet (declared with the `xfa` prefix). - let fresh_children = |warnings: &mut Vec| match op { - Op::ReplaceData(nodes) => serialize_nodes(nodes, "xfa"), - _ => build_children(wants, warnings), - }; - if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { - warnings.push("The XFA data nests XML elements deeper than the reader accepts; the field values were not written to it".into()); - return Merged::Failed; - } - let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - // A packet of an array may use the prefixes the XDP envelope declares: read it inside - // an element declaring the usual ones. - let wrapped; - let (src, shift, parsed) = match roxmltree::Document::parse_with_options(text, opts()) { - Ok(d) => (text, 0, d), - Err(first) => { - wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); - match (within, roxmltree::Document::parse_with_options(&wrapped, opts())) { - (Within::Packet, Ok(d)) => (wrapped.as_str(), WRAP_OPEN.len(), d), - _ => { - warnings.push(format!("The XFA data could not be read ({first}); the field values were not written to it")); - return Merged::Failed; - } - } - } - }; - let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; - let datasets = parsed - .descendants() - .find(|n| is(n, "datasets") && n.tag_name().namespace() == Some(XFA_DATA_NS)) - .or_else(|| parsed.descendants().find(|n| is(n, "datasets"))); - let mut edits = Vec::new(); - let mut data = None; - match datasets { - None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, - None if within == Within::Xdp => { - let root = parsed.root_element(); - let children = fresh_children(warnings); - match append_edit(src, root, &fresh_datasets(&children)) { - Some(e) => edits.push(e), - None => { - warnings.push("The XFA packets could not be extended with data; the field values were not written".into()); - return Merged::Failed; - } - } - } - None => { - warnings.push("The XFA datasets packet holds no datasets element; the field values were not written to it".into()); - return Merged::Failed; - } - Some(ds) => match ds.children().find(|n| is(n, "data")) { - Some(d) => data = Some(d), - None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, - None => { - let prefix = qname(src, &ds.range()).and_then(|q| q.split_once(':')).map(|(p, _)| format!("{p}:")).unwrap_or_default(); - let children = fresh_children(warnings); - let element = format!("<{prefix}data>{}", children); - match append_edit(src, ds, &element) { - Some(e) => edits.push(e), - None => { - warnings.push("The XFA datasets could not be extended with data; the field values were not written".into()); - return Merged::Failed; - } - } - } - }, - } - if let Some(data) = data { - match op { - Op::Values(w) => merge_values(src, data, w, &mut edits, warnings), - Op::ReplaceData(nodes) => { - // Everything between the start and end tags; `` opens up. Written - // with the prefix the packet binds to the data namespace. - let prefix = xfa_prefix(data); - let markup = serialize_nodes(nodes, prefix.as_deref().unwrap_or("xfa")); - let range = data.range(); - match (start_tag_end(src, &range), qname(src, &range)) { - (Some(tag_end), Some(name)) if tag_end + 1 == range.end && src.get(..tag_end).is_some_and(|s| s.ends_with('/')) => { - edits.push(Edit { start: range.start, end: range.end, text: format!("<{name}>{markup}") }); - } - (Some(tag_end), _) => { - let close = src.get(range.clone()).and_then(|s| s.rfind(" edits.push(Edit { start: tag_end + 1, end: close, text: markup }), - _ => { - warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); - return Merged::Failed; - } - } - } - _ => { - warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); - return Merged::Failed; - } - } - } - Op::Picture(path, content_type, base64) => { - let mut node = Some(data); - for (name, idx) in path.iter() { - let want = xml_name(name); - node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == want).nth(*idx)); - } - let Some(n) = node else { return Merged::Unchanged }; - if n.children().any(|c| c.is_element()) { - // A data group, not a leaf: a picture would wipe what's under it. - skipped(warnings, STRUCTURED, &path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".")); - return Merged::Failed; - } - let name = qname(src, &n.range()).unwrap_or("value").to_string(); - let (ct, _) = escape(content_type); - let attr = match xfa_prefix(n) { - Some(p) => format!("{p}:contentType=\"{ct}\""), - None => format!("xmlns:xfa=\"{XFA_DATA_NS}\" xfa:contentType=\"{ct}\""), - }; - edits.push(Edit { start: n.range().start, end: n.range().end, text: format!("<{name} {attr}>{base64}") }); - } - Op::Remove(path) => { - // The element at `path`, with the whitespace before it. - let mut node = Some(data); - for (name, idx) in path { - node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == name).nth(*idx)); - } - if let Some(n) = node { - let r = n.range(); - let start = src.get(..r.start).map_or(r.start, |before| r.start - before.len() + before.trim_end().len()); - edits.push(Edit { start, end: r.end, text: String::new() }); - } - } - } - } - if edits.is_empty() { - return Merged::Unchanged; - } - // Apply from the end, so earlier positions stay valid; overlapping edits (two fields bound - // to one node) keep the first. - edits.sort_by_key(|e| (e.start, e.end)); - let mut kept: Vec = Vec::with_capacity(edits.len()); - for e in edits { - if kept.last().is_some_and(|k| e.start < k.end) { - if kept.last().is_some_and(|k| k.text != e.text) && !warnings.iter().any(|w| w == CONFLICT) { - warnings.push(CONFLICT.into()); - } - continue; - } - kept.push(e); - } - let mut out = src.to_string(); - for e in kept.iter().rev() { - if out.get(e.start..e.end).is_none() { - warnings.push("The XFA data could not be updated; the field values were not written to it".into()); - return Merged::Failed; - } - out.replace_range(e.start..e.end, &e.text); - } - if shift > 0 { - match out.get(shift..out.len().saturating_sub(WRAP_CLOSE.len())) { - Some(inner) => out = inner.to_string(), - None => return Merged::Failed, - } - } - Merged::Changed(out) -} - -/// The edits that put `wants` into the `data` element. -fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Vec, warnings: &mut Vec) { - // Element children by local name, per element visited (built once each). - let mut index: HashMap>> = HashMap::new(); - let tree = data.document(); - // Nodes to add, per existing element they go into. - let mut added: Vec<(roxmltree::NodeId, Build)> = Vec::new(); - let mut budget = NewNodes::new(); - for w in wants { - let (escaped, dropped) = escape(&w.text); - let mut node = data; - let mut depth = 0; - let mut have = 0; - for (name, idx) in &w.path { - let kids = index.entry(node.id()).or_insert_with(|| { - let mut by: HashMap> = HashMap::new(); - for c in node.children().filter(|c| c.is_element()) { - by.entry(c.tag_name().name().to_string()).or_default().push(c.id()); - } - by - }); - let same = kids.get(name.as_str()); - match same.and_then(|v| v.get(*idx)).and_then(|id| tree.get_node(*id)) { - Some(next) => { - node = next; - depth += 1; - } - None => { - have = same.map_or(0, Vec::len); - break; - } - } - } - if depth == w.path.len() { - // The data node exists: give it the value, unless it already holds it. A node - // only wanted to exist (a row instance) is left as it is. - if w.ensure && w.text.is_empty() { - continue; - } - let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); - if node.children().any(|c| c.is_element()) { - let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); - // Ignore existing XML indentation, but not whitespace in the accepted value. - // A skipped mismatch must warn: relayout would otherwise use stale data. - if all.trim() != w.text { - skipped(warnings, STRUCTURED, &w.som); - } - continue; - } - if current == w.text { - continue; - } - match replace_edit(src, node, &escaped) { - Some(e) => edits.push(e), - None => skipped(warnings, STRUCTURED, &w.som), - } - } else { - // Missing: add the rest of the path below the deepest node that exists. An empty - // value needs no node, unless the node itself is wanted (a new instance). - if w.text.is_empty() && !w.ensure { - continue; - } - let mut rest: DataPath = w.path.get(depth..).unwrap_or_default().to_vec(); - if let Some(first) = rest.first_mut() { - first.1 = first.1.saturating_sub(have); - } - let pos = match added.iter().position(|(id, _)| *id == node.id()) { - Some(p) => p, - None => { - added.push((node.id(), Build::default())); - added.len() - 1 - } - }; - let built = added.get_mut(pos).and_then(|(_, b)| b.at(&rest, &mut budget)); - match built { - Some(b) => b.text = w.text.clone(), - None => { - skipped(warnings, TOO_MANY, &w.som); - continue; - } - } - } - if dropped { - skipped(warnings, CONTROL, &w.som); - } - } - for (id, build) in added { - let Some(node) = tree.get_node(id) else { continue }; - let mut inner = String::new(); - build.write_children(&mut inner); - match append_edit(src, node, &inner) { - Some(e) => edits.push(e), - None => warnings.push("Some XFA data nodes could not be added; their field values were not written".into()), - } - } -} - -/// What [`write_datasets`] did. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct DatasetsWrite { - /// The datasets packet changed (a new revision of it was added to the document). - pub written: bool, - /// Values that could not be written, and why. - pub warnings: Vec, - /// The datasets stream written, when one was. - pub stream: Option, -} - -/// A packet stream's text and encoding; `None` (with a warning) when it can't be read exactly. -fn packet_text(doc: &Document, o: &Object, warnings: &mut Vec) -> Option<(String, Encoding)> { - let s = doc.resolve(o); - let Object::Stream(s) = &*s else { - warnings.push("The XFA data packet is not a stream; the field values were not written to it".into()); - return None; - }; - let bytes = match s.decoded_within(64 << 20) { - Ok(b) => b, - Err(e) => { - warnings.push(format!("The XFA data could not be decoded ({e}); the field values were not written to it")); - return None; - } - }; - let decoded = crate::packets::decode(&bytes); - if decoded.is_none() { - warnings.push("The XFA data is neither UTF-8 nor UTF-16; the field values were not written to it, so it stays as it was".into()); - } - decoded -} - -/// Write the fields' values into the datasets packet, keeping all the data no field binds to. -/// Nothing is written when the document has no XFA entry or the data already holds the values; -/// values that can't be written are reported, never silently dropped. -pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result { - write_datasets_reusing(doc, fields, None) -} - -/// [`write_datasets`], replacing the stream `reuse` in place when the packet is that stream -/// (written earlier in the same edit) rather than adding another. -pub fn write_datasets_reusing(doc: &mut Document, fields: &[FieldDatum], reuse: Option) -> Result { - write_datasets_with(doc, None, fields, reuse) -} - -/// [`write_datasets_reusing`] with the form's parsed template when the caller has it (it says -/// which SOM segments are areas, see [`data_path`]); `None` parses the document's. -pub fn write_datasets_with( - doc: &mut Document, - tpl: Option<&Template>, - fields: &[FieldDatum], - reuse: Option, -) -> Result { - let wants = with_template(doc, tpl, |t| wanted(doc, fields, t)); - rewrite(doc, &[Op::Values(&wants)], reuse) -} - /// One change to the data, for [`write_data_ops`]. #[derive(Clone, Debug, PartialEq)] pub enum DataOp { @@ -1316,688 +152,3 @@ impl DataNode { } } } - -fn path_som(path: &DataPath) -> String { - path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".") -} - -/// Make sure the data has at least `count` instances of `name` under `parent` (a script added -/// rows: the layout repeats the subform once per data instance). -pub fn add_data_instances(doc: &mut Document, parent: &DataPath, name: &str, count: usize) -> Result { - write_data_ops(doc, &[DataOp::Instances { parent: parent.clone(), name: name.to_string(), count }], None) -} - -/// Put `text` at the data node `path` (a field with no widget, a hidden one or a draw, that a -/// script gave a value), adding the node as needed. -pub fn write_data_value(doc: &mut Document, path: &DataPath, text: &str) -> Result { - write_data_ops(doc, &[DataOp::Value { path: path.clone(), text: text.to_string() }], None) -} - -/// Remove the data node at `path` (a script removed a row). -pub fn remove_data_instance(doc: &mut Document, path: &DataPath) -> Result { - write_data_ops(doc, &[DataOp::Remove(path.clone())], None) -} - -/// Largest picture written into the data (its bytes, before base64): with the 64 MB cap on -/// all packets together, several pictures still fit. -const MAX_DATA_IMAGE: usize = 8 << 20; -/// Longest data file imported. -const MAX_IMPORT: usize = 16 << 20; - -/// Put a picture into the data node at `path` as Designer saves one a user chose: base64, with -/// its content type (`image/png`, …) as `xfa:contentType` (in the packet's own prefix). The -/// node is made when missing; a data group is refused rather than wiped. `reuse`: the stream -/// this edit already wrote. -pub fn write_data_image( - doc: &mut Document, - path: &DataPath, - content_type: &str, - bytes: &[u8], - reuse: Option, -) -> Result { - use base64::Engine; - if path.is_empty() { - return Err(XfaError::Malformed("a picture needs a data path".into())); - } - if bytes.len() > MAX_DATA_IMAGE { - return Err(XfaError::TooLarge(format!("the picture is larger than {} MB", MAX_DATA_IMAGE >> 20))); - } - let ct: String = content_type.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '+')).take(64).collect(); - let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); - let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true }]; - rewrite(doc, &[Op::Values(&ensure), Op::Picture(path, &ct, &b64)], reuse) -} - -/// What an import of a data file did. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct DataImport { - /// Element nodes the new data holds. - pub nodes: usize, - pub warnings: Vec, - /// The datasets stream written. - pub stream: Option, -} - -/// The data a file holds, as the children of `xfa:data`: an XDP or datasets document (Export -/// Data's output, Designer's), a bare data element, or a data document whose root is the -/// form's root data node (named `expect_root`, when the caller knows it). Anything else is -/// refused: a form's data is never replaced by what is not its data. -pub fn parse_data_file(text: &str, expect_root: Option<&str>) -> Result, XfaError> { - if text.len() > MAX_IMPORT { - return Err(XfaError::TooLarge(format!("the data file is larger than {} MB", MAX_IMPORT >> 20))); - } - let text = text.trim_start_matches('\u{feff}'); - if crate::xml_nesting_depth(text) > MAX_IMPORT_DEPTH { - return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); - } - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - let parsed = roxmltree::Document::parse_with_options(text, opts).map_err(|e| XfaError::Malformed(format!("the data file is not XML: {e}")))?; - let root = parsed.root_element(); - let local = root.tag_name().name(); - let in_xfa = |n: &roxmltree::Node| n.tag_name().namespace().is_none_or(|ns| ns == XFA_DATA_NS); - let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name && in_xfa(n); - let data = match local { - "xdp" => { - let ds = root.children().find(|n| is(n, "datasets")).ok_or_else(|| XfaError::Malformed("the XDP holds no datasets".into()))?; - ds.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? - } - "datasets" if in_xfa(&root) => { - root.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? - } - "data" if in_xfa(&root) => root, - _ => { - if root.tag_name().namespace().is_some() { - return Err(XfaError::Malformed(format!("<{local}> is not XFA form data"))); - } - if let Some(want) = expect_root - && local != want - { - return Err(XfaError::Malformed(format!("the data file's root is <{local}>; this form's data root is <{want}>"))); - } - let node = import_node(root, 0, &mut ImportBudget::new())?; - return Ok(vec![node]); - } - }; - let mut budget = ImportBudget::new(); - let mut out = Vec::new(); - for c in data.children().filter(|c| c.is_element()) { - out.push(import_node(c, 1, &mut budget)?); - } - Ok(out) -} - -/// Replace the form's data with what the file `text` holds (see [`parse_data_file`]), keeping -/// the rest of the datasets (data descriptions, say). `reuse`: the stream this edit already -/// wrote. -pub fn import_data(doc: &mut Document, text: &str, expect_root: Option<&str>, reuse: Option) -> Result { - let nodes = parse_data_file(text, expect_root)?; - fn count(n: &[ImportNode]) -> usize { - n.iter().map(|c| 1 + count(&c.children)).sum() - } - let r = rewrite(doc, &[Op::ReplaceData(&nodes)], reuse)?; - if !r.written { - return Err(XfaError::Malformed(r.warnings.first().cloned().unwrap_or_else(|| "the data could not be written".into()))); - } - Ok(DataImport { nodes: count(&nodes), warnings: r.warnings, stream: r.stream }) -} - -/// The datasets packet's text: from the XFA array's `datasets` item, or the whole XDP. -fn datasets_packet(doc: &Document) -> Result, XfaError> { - let Some(root) = doc.root() else { return Ok(None) }; - let catalog = doc.get(root); - let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Ok(None) }; - let Some(xfa) = acro.as_dict().and_then(|a| a.get(b"XFA")).map(|x| doc.resolve(x)) else { return Ok(None) }; - let text_of = |o: &Object| -> Result, XfaError> { - match &*doc.resolve(o) { - Object::Stream(s) => Ok(crate::packets::decode(&s.decoded_within(64 << 20)?).map(|(t, _)| t)), - _ => Ok(None), - } - }; - match &*xfa { - Object::Array(items) => { - let at = (0..items.len()) - .step_by(2) - .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); - match at.and_then(|i| items.get(i + 1)) { - Some(o) => text_of(o), - None => Ok(None), - } - } - Object::Stream(_) => text_of(&xfa), - _ => Ok(None), - } -} - -/// The data to export, as Acrobat's Export Data writes it: `xdp` false gives an XML document -/// holding the data alone (the form's root data node at the root, or the data element when -/// there are several); true gives an XDP holding the whole datasets and a reference to the -/// PDF, named `pdf_name`. A document whose packets can't be read is an error, not empty data. -pub fn export_data(doc: &Document, xdp: bool, pdf_name: &str) -> Result { - if crate::read_packets(doc)?.is_none() { - return Err(XfaError::NotXfa); - } - let packet = datasets_packet(doc)?; - let (datasets, data) = match &packet { - Some(text) => match datasets_markup(text) { - Ok(Some(m)) => m, - Ok(None) => (fresh_datasets(""), String::new()), - Err(()) => return Err(XfaError::Malformed("the XFA data could not be read".into())), - }, - None => (fresh_datasets(""), String::new()), - }; - let mut out = String::from("\n"); - if xdp { - let (name, _) = escape(pdf_name); - out.push_str("\n"); - out.push_str(&datasets); - out.push_str(&format!("\n\n\n")); - } else { - out.push_str(if data.is_empty() { &datasets } else { &data }); - out.push('\n'); - } - Ok(out) -} - -/// The `xfa:datasets` element of `text` as written, and the data alone (its one child, or the -/// data element), each with the namespaces it uses declared on it. `Ok(None)`: no datasets -/// element; `Err`: the text can't be read. -fn datasets_markup(text: &str) -> Result, ()> { - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { - return Err(()); - } - let wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); - let (src, parsed) = match roxmltree::Document::parse_with_options(text, opts) { - Ok(d) => (text, d), - Err(_) => (wrapped.as_str(), roxmltree::Document::parse_with_options(&wrapped, opts).map_err(|_| ())?), - }; - let Some(ds) = parsed.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets") else { return Ok(None) }; - let data = ds.children().find(|n| n.is_element() && n.tag_name().name() == "data"); - let data_alone = match data { - Some(d) => { - let kids: Vec = d.children().filter(|c| c.is_element()).collect(); - match kids.as_slice() { - [one] => element_markup(src, *one).ok_or(())?, - _ => element_markup(src, d).ok_or(())?, - } - } - None => String::new(), - }; - Ok(Some((element_markup(src, ds).ok_or(())?, data_alone))) -} - -/// `n` as written in `src`, with the namespace declarations in scope that its start tag lacks -/// added to it, so it stands alone. -fn element_markup(src: &str, n: roxmltree::Node) -> Option { - let range = n.range(); - let text = src.get(range.clone())?; - let tag_end = start_tag_end(src, &range)?; - let start_tag = src.get(range.start..tag_end)?; - let mut decls = String::new(); - for ns in n.namespaces() { - let attr = match ns.name() { - Some(p) => format!("xmlns:{p}="), - None => "xmlns=".to_string(), - }; - if !start_tag.contains(&attr) { - decls.push_str(&format!(" {attr}\"{}\"", escape(ns.uri()).0)); - } - } - if decls.is_empty() { - return Some(text.to_string()); - } - let name_end = qname(src, &range)?.len() + 1; - Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?)) -} - -/// Most instance nodes one [`write_data_ops`] queues over all its passes (a removal starts a new -/// pass, which asks for the instances again). -const MAX_ENSURED: usize = 200_000; - -// Instance nodes write_data_ops has queued on this thread, for tests that bound its work. -#[cfg(test)] -thread_local! { - pub(crate) static ENSURED: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script -/// event changed becomes a single new datasets stream. When the packet is already the stream -/// `reuse` (written earlier for the same event), that stream is replaced in place instead of -/// a new one being added. The stream written is in the report. -pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) -> Result { - // Runs of values (and instances) merge in one pass; a value set twice keeps the last. - let mut steps: Vec> = Vec::new(); - let mut removes: Vec> = Vec::new(); - let mut run: Vec = Vec::new(); - // Instances queued in this run, by parent and name: asking for more only queues the rest. - let mut queued: HashMap<(&DataPath, &str), usize> = HashMap::new(); - let (mut ensured, mut capped) = (0usize, false); - let flush = |run: &mut Vec, steps: &mut Vec>, removes: &mut Vec>| { - if !run.is_empty() { - let mut seen = std::collections::HashSet::new(); - let mut kept: Vec = std::mem::take(run).into_iter().rev().filter(|w| seen.insert((w.ensure, w.path.clone()))).collect(); - kept.reverse(); - steps.push(kept); - removes.push(None); - } - }; - for op in ops { - match op { - DataOp::Value { path, text } if !path.is_empty() => { - // An empty value clears an existing node and needs no new one. - run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false }); - } - DataOp::Instances { parent, name, count } => { - let done = queued.entry((parent, name.as_str())).or_insert(0); - while *done < (*count).min(MAX_INDEX) { - if ensured >= MAX_ENSURED { - capped = true; - break; - } - ensured += 1; - #[cfg(test)] - ENSURED.with(|n| n.set(n.get() + 1)); - let k = *done; - *done += 1; - let mut path = parent.clone(); - path.push((name.clone(), k)); - run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true }); - } - } - DataOp::Remove(path) if !path.is_empty() => { - flush(&mut run, &mut steps, &mut removes); - queued.clear(); - steps.push(Vec::new()); - removes.push(Some(path.clone())); - } - _ => {} - } - } - flush(&mut run, &mut steps, &mut removes); - let plan: Vec = steps - .iter() - .zip(&removes) - .map(|(w, r)| match r { - Some(p) => Op::Remove(p), - None => Op::Values(w), - }) - .collect(); - let mut written = rewrite(doc, &plan, reuse)?; - if capped { - written.warnings.push(format!("XFA data instances not written past {MAX_ENSURED}: the rows were added and removed too often")); - } - Ok(written) -} - -/// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream -/// `reuse` or already an unsaved overlay stream of `doc`, else as a new object. -fn put_stream(doc: &mut Document, old: Option<&Object>, reuse: Option, bytes: &[u8]) -> ObjRef { - let stream = Object::Stream(Stream::flate(Dict::new(), bytes)); - match old.and_then(Object::as_ref) { - Some(r) if reuse == Some(r) || doc.is_edited(r.num) => { - doc.set(r, stream); - r - } - _ => doc.add(stream), - } -} - -/// Merge `ops` into `text` in order. `None`: nothing changed (or nothing could be merged). -fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec) -> Option { - let mut current: Option = None; - for op in ops { - match merge(current.as_deref().unwrap_or(text), within, *op, warnings) { - Merged::Changed(t) => current = Some(t), - Merged::Unchanged => {} - // Later steps still apply to what merged so far; the reason is in the warnings. - Merged::Failed => {} - } - } - current -} - -/// Longest packet text written (the packets together are read up to 64 MB). -const MAX_PACKET_WRITE: usize = 48 << 20; - -/// Is `text` a packet the reader will take back (well-formed, within size)? A rewrite that -/// isn't is not written: better the old data than an unreadable packet. -fn readable(text: &str, within: Within, warnings: &mut Vec) -> bool { - if text.len() > MAX_PACKET_WRITE { - warnings.push(format!("The XFA data would be larger than {} MB; it was not written", MAX_PACKET_WRITE >> 20)); - return false; - } - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; - let ok = roxmltree::Document::parse_with_options(text, opts).is_ok() - || (within == Within::Packet && roxmltree::Document::parse_with_options(&format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"), opts).is_ok()); - if !ok { - warnings.push("The XFA data would not be well-formed XML after this change; it was not written".into()); - } - ok -} - -fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option) -> Result { - let mut report = DatasetsWrite::default(); - if ops.is_empty() { - return Ok(report); - } - let Some(root) = doc.root() else { return Ok(report) }; - let catalog = doc.get(root); - let Some(acro_obj) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).cloned() else { return Ok(report) }; - let acro_ref = acro_obj.as_ref(); - let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(report) }; - let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(report) }; - let warnings = &mut report.warnings; - match &*doc.resolve(&xfa) { - Object::Array(items) => { - let mut items = items.clone(); - let at = (0..items.len()) - .step_by(2) - .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); - match at { - Some(i) => { - let Some(existing) = items.get(i + 1).cloned() else { return Ok(report) }; - let Some((text, encoding)) = packet_text(doc, &existing, warnings) else { return Ok(report) }; - let Some(t) = merge_all(&text, Within::Packet, ops, warnings) else { return Ok(report) }; - if !readable(&t, Within::Packet, warnings) { - return Ok(report); - } - let r = put_stream(doc, Some(&existing), reuse, &encode(&t, encoding)); - report.stream = Some(r); - if let Some(slot) = items.get_mut(i + 1) { - *slot = Object::Ref(r); - } - } - None => { - // No datasets packet yet: made from the first values, then the rest merged - // in; before the postamble, or last. - let Some(first) = ops.iter().position(|op| matches!(op, Op::Values(_))) else { return Ok(report) }; - let Some(Op::Values(wants)) = ops.get(first) else { return Ok(report) }; - let fresh = fresh_datasets(&build_children(wants, warnings)); - let text = merge_all(&fresh, Within::Packet, ops.get(first + 1..).unwrap_or_default(), warnings).unwrap_or(fresh); - if !readable(&text, Within::Packet, warnings) { - return Ok(report); - } - let r = doc.add(Object::Stream(Stream::flate(Dict::new(), text.as_bytes()))); - report.stream = Some(r); - let pos = items.iter().position(|n| n.as_string().is_some_and(|s| s.to_text() == "postamble")).unwrap_or(items.len()); - items.insert(pos, Object::Ref(r)); - items.insert(pos, Object::String(PdfString::text("datasets"))); - } - } - acro.set(b"XFA".to_vec(), Object::Array(items)); - } - Object::Stream(_) => { - let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) }; - let Some(new) = merge_all(&text, Within::Xdp, ops, warnings) else { return Ok(report) }; - if !readable(&new, Within::Xdp, warnings) { - return Ok(report); - } - let r = put_stream(doc, Some(&xfa), reuse, &encode(&new, encoding)); - report.stream = Some(r); - acro.set(b"XFA".to_vec(), Object::Ref(r)); - } - _ => return Ok(report), - } - match acro_ref { - Some(ar) => doc.set(ar, Object::Dict(acro)), - None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?, - } - report.written = true; - Ok(report) -} - -/// The values the datasets hold for `fields`: `(field name, what it should hold)`, only for -/// fields the data mentions. -pub fn read_values(doc: &Document, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { - read_values_with(doc, None, fields) -} - -/// [`read_values`] with the form's parsed template when the caller has it; `None` parses the -/// document's. -pub fn read_values_with(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { - with_template(doc, tpl, |t| values_at(doc, t, fields)) -} - -/// The values the document's datasets hold for `fields`, read in each field's data picture and -/// shown in its own pattern; only fields the data mentions. -fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { - let Ok(Some(p)) = crate::read_packets(doc) else { return Vec::new() }; - let Some(data) = parse_datasets(&p.xdp) else { return Vec::new() }; - let mut out = Vec::new(); - for f in fields { - let som = som_of(doc, f); - let path = path_of(tpl, &som); - let Some(text) = data.value_at(&path) else { continue }; - let text = text.as_str(); - let value = match &f.data { - FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) { - Some(pat) => iso_to_pattern(&data_to_iso(text, data_date_pattern(tpl, &som).as_deref()), &pat), - None => text.to_string(), - }), - FieldData::Check(_) => { - let (on, _) = items_of(doc, f); - FieldData::Check(is_on(text, &on)) - } - FieldData::Radio(_) => FieldData::Radio((!text.trim().is_empty()).then(|| text.trim().to_string())), - FieldData::None => continue, - }; - out.push((f.name.clone(), value)); - } - out -} - -/// Does a data value mean "checked" for a button whose on value is `on`? -pub fn is_on(text: &str, on: &str) -> bool { - let t = text.trim(); - if t == on { - return true; - } - !matches!(t.to_ascii_lowercase().as_str(), "" | "0" | "off" | "false" | "no") && on.trim().is_empty() -} - -/// The datasets stream's object reference in `doc` (the `datasets` item in the `/XFA` array, or -/// the single `/XFA` stream), when `doc` has one. -fn datasets_stream_ref(doc: &Document) -> Option { - let root = doc.root()?; - let catalog = doc.get(root); - let acro = catalog.as_dict()?.get(b"AcroForm").map(|a| doc.resolve(a))?; - let xfa = acro.as_dict()?.get(b"XFA")?; - match &*doc.resolve(xfa) { - Object::Array(items) => { - let at = (0..items.len()) - .step_by(2) - .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len())?; - items.get(at + 1)?.as_ref() - } - Object::Stream(_) => xfa.as_ref(), - _ => None, - } -} - -/// The SOM path and default value of every AcroForm text field with the Password flag (bit 14, -/// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm -/// dictionary. -fn acro_password_fields(doc: &Document) -> Vec<(String, String)> { - const PASSWORD_FLAG: i64 = 1 << 13; - let Some(root) = doc.root() else { return Vec::new() }; - let catalog = doc.get(root); - let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Vec::new() }; - let Some(top) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else { - return Vec::new(); - }; - struct AcroWalk<'a> { - doc: &'a Document, - seen: std::collections::HashSet, - out: Vec<(String, String)>, - } - impl AcroWalk<'_> { - fn walk( - &mut self, - items: &[Object], - parent_name: &str, - inherited_ft: Option<&[u8]>, - inherited_ff: i64, - inherited_dv: Option<&str>, - depth: usize, - ) { - if depth > 32 { - return; - } - for item in items { - let Some(r) = item.as_ref() else { continue }; - if !self.seen.insert(r) { - continue; - } - let obj = self.doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let ft = d.name(b"FT").or(inherited_ft); - let ff = d.int(b"Ff").unwrap_or(inherited_ff); - let dv_own = text_of(self.doc, d, b"DV"); - let dv = dv_own.as_deref().or(inherited_dv); - let t = text_of(self.doc, d, b"T"); - let full_name = match (&t, parent_name.is_empty()) { - (Some(seg), true) => seg.clone(), - (Some(seg), false) => format!("{parent_name}.{seg}"), - (None, _) => parent_name.to_string(), - }; - let kids = d.get(b"Kids").map(|k| self.doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - let has_named_kid = kids.iter().filter_map(Object::as_ref).any(|kr| self.doc.get(kr).as_dict().is_some_and(|kd| kd.contains(b"T"))); - if !has_named_kid && ft == Some(b"Tx") && (ff & PASSWORD_FLAG) != 0 { - let som = text_of(self.doc, d, crate::pdf::SOM_KEY).unwrap_or_else(|| full_name.clone()); - if !som.is_empty() { - self.out.push((som, dv.unwrap_or_default().to_string())); - } - } - if !kids.is_empty() { - self.walk(&kids, &full_name, ft, ff, dv, depth + 1); - } - } - } - } - let mut w = AcroWalk { doc, seen: std::collections::HashSet::new(), out: Vec::new() }; - w.walk(&top, "", None, 0, None, 0); - w.out -} - -/// The `Want` entries needed to restore password fields in `doc`'s datasets packet to their -/// defaults (or empty when they have no default). -fn password_dataset_wants(doc: &Document) -> Vec { - if datasets_stream_ref(doc).is_none() { - return Vec::new(); - } - let Ok(Some(packet)) = datasets_packet(doc) else { return Vec::new() }; - let Some(data) = parse_datasets(&packet) else { return Vec::new() }; - with_template(doc, None, |tpl| { - let mut candidates: Vec<(String, DataPath, String)> = Vec::new(); - let mut seen = std::collections::HashSet::new(); - if let Some(t) = tpl { - struct Walk<'a> { - tpl: &'a Template, - data: &'a DataNode, - nodes: usize, - seen: &'a mut std::collections::HashSet, - out: &'a mut Vec<(String, DataPath, String)>, - } - impl Walk<'_> { - fn child_som(parent: &str, name: Option<&str>, index: usize) -> String { - match name { - Some(n) if !parent.is_empty() => format!("{parent}.{n}[{index}]"), - Some(n) => format!("{n}[{index}]"), - None => parent.to_string(), - } - } - fn walk(&mut self, children: &[Node], parent_som: &str, depth: usize) { - if depth > MAX_DEPTH || self.nodes >= crate::script::MAX_FORM_NODES { - return; - } - let sibs = SomIndices::of(children); - for (i, n) in children.iter().enumerate() { - if self.nodes >= crate::script::MAX_FORM_NODES { - break; - } - self.nodes += 1; - let sib = sibs.at(i); - match n { - Node::Subform(sf) => { - let from_data = match &sf.common.name { - Some(name) if sf.occur.max != Some(1) => self.data.count(&data_path(self.tpl, parent_som), name), - _ => 0, - }; - let instances = crate::layout::instance_count(&sf.occur, from_data).max(from_data); - for k in 0..instances { - if k > 0 { - if self.nodes >= crate::script::MAX_FORM_NODES { - break; - } - self.nodes += 1; - } - let som = Self::child_som(parent_som, sf.common.name.as_deref(), sib + k); - self.walk(&sf.children, &som, depth + 1); - } - } - Node::Area(a) => { - let som = Self::child_som(parent_som, a.common.name.as_deref(), sib); - self.walk(&a.children, &som, depth + 1); - } - Node::Field(f) if f.ui == Ui::PasswordEdit => { - let som = Self::child_som(parent_som, f.common.name.as_deref().or(Some("field")), sib); - let path = data_path(self.tpl, &som); - if !path.is_empty() && self.seen.insert(path.clone()) { - let default = f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(); - self.out.push((som, path, default)); - } - } - _ => {} - } - } - } - } - let root = Node::Subform(Box::new(t.root.clone())); - let mut w = Walk { tpl: t, data: &data, nodes: 0, seen: &mut seen, out: &mut candidates }; - w.walk(std::slice::from_ref(&root), "", 0); - if let Some(ps) = &t.root.page_set { - for (i, area) in ps.areas.iter().enumerate() { - let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]")); - w.walk(&area.items, &som, 1); - } - } - } - for (som, default) in acro_password_fields(doc) { - let path = path_of(tpl, &som); - if !path.is_empty() && seen.insert(path.clone()) { - candidates.push((som, path, default)); - } - } - candidates - .into_iter() - .filter_map(|(som, path, default)| { - let cur = data.value_at(&path)?; - (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false }) - }) - .collect() - }) -} - -/// Whether any XFA password field (`` or an AcroForm text field with the Password -/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944). -pub fn has_password_values(doc: &Document) -> bool { - !password_dataset_wants(doc).is_empty() -} - -/// Clear typed password values from `doc`'s XFA datasets packet (restoring a template default -/// where the field has one, else emptying the element), replacing the datasets stream in place -/// (#3944). Returns how many password fields were cleared in the datasets. -pub fn clear_password_values(doc: &mut Document) -> Result { - let wants = password_dataset_wants(doc); - if wants.is_empty() { - return Ok(0); - } - let count = wants.len(); - let reuse = datasets_stream_ref(doc); - let report = rewrite(doc, &[Op::Values(&wants)], reuse)?; - if !report.written || !password_dataset_wants(doc).is_empty() { - let reason = report.warnings.first().map_or("the XFA datasets password values could not be cleared", String::as_str); - return Err(XfaError::Malformed(reason.to_string())); - } - Ok(count) -} diff --git a/crates/xfa/src/data/build.rs b/crates/xfa/src/data/build.rs new file mode 100644 index 0000000000..68e8a2a364 --- /dev/null +++ b/crates/xfa/src/data/build.rs @@ -0,0 +1,164 @@ +use super::values::{FieldDatum, Want, wanted, with_template}; +use super::*; + +pub(super) fn xml_name(s: &str) -> String { + let mut out: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { c } else { '_' }).collect(); + if out.is_empty() || !(out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')) { + out.insert(0, '_'); + } + out +} + +/// `s` as XML character data. Characters XML 1.0 cannot hold are left out; the flag says +/// whether any were. +pub(super) fn escape(s: &str) -> (String, bool) { + let mut out = String::with_capacity(s.len()); + let mut dropped = false; + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + // Parsers turn a raw carriage return into a line feed; a reference keeps it. + '\r' => out.push_str(" "), + '\u{FFFE}' | '\u{FFFF}' => dropped = true, + c if c.is_control() && c != '\n' && c != '\t' => dropped = true, + c => out.push(c), + } + } + (out, dropped) +} + +/// Most data nodes one write may create (instances skipped by a SOM index count too). +const MAX_NEW_NODES: usize = 20_000; + +/// Highest same-name sibling index a write follows. +pub(super) const MAX_INDEX: usize = 10_000; + +/// Most bytes of names the nodes one write creates hold together: every sibling an index +/// skips over gets its own copy of the name. +const MAX_NEW_NAME_BYTES: usize = 4 << 20; + +/// What one write may still create: nodes and bytes of their names. +pub(super) struct NewNodes { + nodes: usize, + name_bytes: usize, +} + +impl NewNodes { + pub(super) fn new() -> NewNodes { + NewNodes { nodes: MAX_NEW_NODES, name_bytes: MAX_NEW_NAME_BYTES } + } +} + +/// A data tree being built for writing. +#[derive(Default)] +pub(super) struct Build { + name: String, + pub(super) text: String, + children: Vec, + /// Positions in `children` by name, in order. + by_name: HashMap>, +} + +impl Build { + /// The node at `path` below this one, created as needed. `None` when the path is deeper + /// than [`MAX_DEPTH`], an index is above [`MAX_INDEX`], or creating it would spend more + /// new nodes or name bytes than `budget` holds. + pub(super) fn at(&mut self, path: &[(String, usize)], budget: &mut NewNodes) -> Option<&mut Build> { + if path.len() > MAX_DEPTH { + return None; + } + let mut node = self; + for (name, idx) in path { + if *idx > MAX_INDEX { + return None; + } + let name = xml_name(name); + let have = node.by_name.get(&name).map_or(0, Vec::len); + if *idx >= have { + let need = idx - have + 1; + let bytes = need.saturating_mul(name.len()); + if need > budget.nodes || bytes > budget.name_bytes { + return None; + } + budget.nodes -= need; + budget.name_bytes -= bytes; + let start = node.children.len(); + node.children.extend((0..need).map(|_| Build { name: name.clone(), ..Default::default() })); + node.by_name.entry(name.clone()).or_default().extend(start..start + need); + } + let pos = *node.by_name.get(&name)?.get(*idx)?; + node = node.children.get_mut(pos)?; + } + Some(node) + } + + /// The tree as XML. Depth is bounded by [`Build::at`]. + fn write(&self, out: &mut String) { + if self.children.is_empty() { + out.push_str(&format!("<{}>{}", self.name, escape(&self.text).0, self.name)); + return; + } + out.push_str(&format!("<{}>", self.name)); + self.write_children(out); + out.push_str(&format!("", self.name)); + } + + pub(super) fn write_children(&self, out: &mut String) { + for c in &self.children { + c.write(out); + } + } +} + +/// Note that a value could not be written (once per kind of reason, naming a few fields). +pub(super) fn skipped(warnings: &mut Vec, reason: &str, som: &str) { + const MAX_WARNINGS: usize = 20; + if let Some(w) = warnings.iter_mut().find(|w| w.starts_with(reason)) { + if w.matches(", ").count() < 4 { + w.push_str(&format!(", {som}")); + } else if !w.ends_with('…') { + w.push_str(", …"); + } + } else if warnings.len() < MAX_WARNINGS { + warnings.push(format!("{reason}: {som}")); + } +} + +pub(super) const TOO_MANY: &str = "XFA data not written for fields whose data path is too deep or repeats too often"; + +pub(super) const STRUCTURED: &str = "XFA data not written for fields whose data node holds structured content"; + +pub(super) const CONFLICT: &str = "XFA data not written for fields bound to the same data node as another field"; + +pub(super) const CONTROL: &str = "Characters XML can't hold were left out of the XFA data of"; + +/// Fresh data for `wants`: the children of an `xfa:data` element. +pub(super) fn build_children(wants: &[Want], warnings: &mut Vec) -> String { + let mut root = Build::default(); + let mut budget = NewNodes::new(); + for w in wants { + match root.at(&w.path, &mut budget) { + Some(node) => node.text = w.text.clone(), + None => skipped(warnings, TOO_MANY, &w.som), + } + if escape(&w.text).1 { + skipped(warnings, CONTROL, &w.som); + } + } + let mut out = String::new(); + root.write_children(&mut out); + out +} + +/// The `xfa:data` element for `fields`, built afresh. +pub fn build_data(doc: &Document, fields: &[FieldDatum]) -> String { + let wants = with_template(doc, None, |t| wanted(doc, fields, t)); + format!("{}", build_children(&wants, &mut Vec::new())) +} + +pub(super) fn fresh_datasets(data_children: &str) -> String { + format!("{data_children}") +} diff --git a/crates/xfa/src/data/dates.rs b/crates/xfa/src/data/dates.rs new file mode 100644 index 0000000000..029d91b273 --- /dev/null +++ b/crates/xfa/src/data/dates.rs @@ -0,0 +1,193 @@ +use super::path::template_field; +use super::*; + +/// The date letters (`yyyy-mm-dd`, `yyyymmdd`) of an XFA date picture clause such as +/// `date{YYYY-MM-DD}`, or `None` when it has none this reads (empty, or with symbols beyond +/// years, months, days and separators). +pub(crate) fn date_letters(picture: &str) -> Option { + let inner = picture + .find('{') + .and_then(|a| { + let rest = picture.get(a + 1..)?; + rest.find('}').and_then(|b| rest.get(..b)) + }) + .unwrap_or(picture) + .trim(); + if inner.is_empty() || inner.chars().any(|c| !"YMDEJ-/., :".contains(c)) { + return None; + } + Some(inner.replace('E', "d").to_ascii_lowercase()) +} + +/// The date letters of `f`'s data picture, when it holds a whole date. A data picture without a +/// year, a month or a day can't hold the date the field shows, so such a field's data keeps +/// ISO dates. +pub(crate) fn data_date_letters(f: &Field) -> Option { + let letters = f.data_picture.as_deref().and_then(date_letters)?; + let tokens = date_tokens(&letters); + let has = |any: &[&str]| tokens.iter().any(|t| any.contains(&t.as_str())); + (has(&["yyyy", "yy"]) && has(&["mm", "m"]) && has(&["dd", "d"])).then_some(letters) +} + +/// A date as the data holds it, written in `data_pattern` (the field's data picture), read as +/// an ISO date. A value that doesn't match, or a field without a data picture, is taken as it +/// is: the data holds ISO dates by default. +pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> String { + data_pattern.and_then(|p| pattern_to_iso(value, p)).unwrap_or_else(|| value.to_string()) +} + +/// An ISO date as the data should hold it: in `data_pattern` when the field has one and the +/// date reads back from it unchanged (a two-digit year holds only 1950 to 2049), else as ISO. +pub(crate) fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String { + match data_pattern { + Some(p) => { + let written = iso_to_pattern(&iso, p); + if pattern_to_iso(&written, p).as_deref() == Some(iso.as_str()) { written } else { iso } + } + None => iso, + } +} + +/// The date letters of the data picture of the field `som` names, when it has one. +pub(super) fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option { + tpl.and_then(|t| template_field(t, som)).and_then(data_date_letters) +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// The weekday (0 = Sunday) of a proleptic Gregorian date. +fn weekday(y: u32, m: u32, d: u32) -> usize { + const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; + let y = i64::from(y) - i64::from(m < 3); + let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); + ((y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7)) as usize +} + +/// The index and length of the name in `names` that `text` starts with (ignoring case), +/// cut to `abbr` letters when that is set. +fn name_at(text: &str, names: &[&str], abbr: bool) -> Option<(usize, usize)> { + names.iter().enumerate().find_map(|(i, n)| { + let n = if abbr { n.get(..3)? } else { n }; + text.get(..n.len()).filter(|t| t.eq_ignore_ascii_case(n)).map(|_| (i, n.len())) + }) +} + +/// Whether `token` is a run of the date letters (`y`, `m`, `d`) rather than a literal. +fn is_date_run(token: &str) -> bool { + token.starts_with(['y', 'm', 'd']) +} + +/// The tokens of a date pattern: runs of `y`, `m` and `d` (`yyyy`, `yy`, `mmmm`, `mmm`, `mm`, +/// `m`, `dddd`, `ddd`, `dd`, `d` are understood), or a literal character. +pub(crate) fn date_tokens(pattern: &str) -> Vec { + let mut out = Vec::new(); + let chars: Vec = pattern.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i].to_ascii_lowercase(); + if "ymd".contains(c) { + let mut j = i; + while j < chars.len() && chars[j].to_ascii_lowercase() == c { + j += 1; + } + out.push(std::iter::repeat_n(c, j - i).collect()); + i = j; + } else { + out.push(chars[i].to_string()); + i += 1; + } + } + out +} + +/// `2001-02-03` shown in `pattern` (`mm/dd/yyyy` → `02/03/2001`, `mmm d, yyyy` → +/// `Feb 3, 2001`). Patterns with a token this doesn't know and non-dates come back as they are. +pub fn iso_to_pattern(iso: &str, pattern: &str) -> String { + let parts: Vec<&str> = iso.trim().splitn(3, '-').collect(); + let [y, m, d] = parts[..] else { return iso.to_string() }; + let (Ok(y), Ok(m), Ok(d)) = (y.parse::(), m.parse::(), d.parse::()) else { return iso.to_string() }; + let mut out = String::new(); + for t in date_tokens(pattern) { + match t.as_str() { + "yyyy" => out.push_str(&format!("{y:04}")), + "yy" => out.push_str(&format!("{:02}", y % 100)), + "mm" => out.push_str(&format!("{m:02}")), + "m" => out.push_str(&m.to_string()), + "dd" => out.push_str(&format!("{d:02}")), + "d" => out.push_str(&d.to_string()), + "mmm" | "mmmm" => { + let Some(name) = m.checked_sub(1).and_then(|i| MONTHS.get(i as usize)) else { return iso.to_string() }; + match t.len() { + 3 => out.push_str(name.get(..3).unwrap_or(name)), + _ => out.push_str(name), + } + } + "ddd" | "dddd" => { + let name = WEEKDAYS.get(weekday(y, m, d)).copied().unwrap_or_default(); + match t.len() { + 3 => out.push_str(name.get(..3).unwrap_or(name)), + _ => out.push_str(name), + } + } + other if is_date_run(other) => return iso.to_string(), + other => out.push_str(other), + } + } + out +} + +/// A date typed as `pattern` back to `yyyy-mm-dd`. `None` when it doesn't match. +pub fn pattern_to_iso(text: &str, pattern: &str) -> Option { + let text = text.trim(); + let (mut y, mut m, mut d) = (None, None, None); + let mut day_name = None; + let mut rest = text; + let tokens = date_tokens(pattern); + for (i, t) in tokens.iter().enumerate() { + match t.as_str() { + "yyyy" | "yy" | "mm" | "m" | "dd" | "d" => { + // Digits up to the next literal (or a fixed width). + let fixed = matches!(t.as_str(), "yyyy" | "yy" | "mm" | "dd"); + let width = + if fixed { t.len() } else { rest.chars().take_while(char::is_ascii_digit).count().min(if t == "d" || t == "m" { 2 } else { 4 }) }; + let digits: String = rest.chars().take(width).collect(); + if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let v: u32 = digits.parse().ok()?; + rest = &rest[digits.len()..]; + match t.as_str() { + "yyyy" => y = Some(v), + "yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }), + "mm" | "m" => m = Some(v), + _ => d = Some(v), + } + } + "mmm" | "mmmm" => { + let (i, len) = name_at(rest, &MONTHS, t.len() == 3)?; + m = Some(i as u32 + 1); + rest = rest.get(len..)?; + } + "ddd" | "dddd" => { + let (i, len) = name_at(rest, &WEEKDAYS, t.len() == 3)?; + day_name = Some(i); + rest = rest.get(len..)?; + } + run if is_date_run(run) => return None, + lit => { + rest = rest.strip_prefix(lit)?; + } + } + let _ = i; + } + if !rest.is_empty() { + return None; + } + let (y, m, d) = (y?, m?, d?); + if day_name.is_some_and(|w| w != weekday(y, m, d)) { + return None; + } + (1..=12).contains(&m).then(|| format!("{y:04}-{m:02}-{d:02}")).filter(|_| (1..=31).contains(&d)) +} diff --git a/crates/xfa/src/data/import.rs b/crates/xfa/src/data/import.rs new file mode 100644 index 0000000000..cec30b11b3 --- /dev/null +++ b/crates/xfa/src/data/import.rs @@ -0,0 +1,353 @@ +use super::build::{escape, fresh_datasets, xml_name}; +use super::merge::{Op, WRAP_CLOSE, WRAP_OPEN, qname, start_tag_end}; +use super::values::Want; +use super::write::{DatasetsWrite, rewrite}; +use super::*; + +/// Data read from a file, held as a tree so it can be written into any packet with the +/// prefix that packet uses, never as the file's own markup. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ImportNode { + pub name: String, + /// The element's namespace (`None`: none). + pub ns: Option, + /// `(namespace, name, value)`. + pub attrs: Vec<(Option, String, String)>, + pub text: String, + pub children: Vec, +} + +/// Deepest data an import keeps. +const MAX_IMPORT_DEPTH: usize = MAX_DEPTH; + +/// Most bytes of names, namespaces, attributes and text an import copies out of its file: a +/// namespace declared once is copied into every element and attribute that inherits it. +pub(crate) const MAX_IMPORT_BYTES: usize = 4 * MAX_IMPORT; + +/// What an import may still copy: nodes and bytes. +struct ImportBudget { + nodes: usize, + bytes: usize, +} + +impl ImportBudget { + fn new() -> ImportBudget { + ImportBudget { nodes: MAX_NODES as usize, bytes: MAX_IMPORT_BYTES } + } + + fn take_bytes(&mut self, n: usize) -> Result<(), XfaError> { + self.bytes = self + .bytes + .checked_sub(n) + .ok_or_else(|| XfaError::TooLarge(format!("the data file expands to more than {} MB", MAX_IMPORT_BYTES >> 20)))?; + Ok(()) + } +} + +/// `n` as an [`ImportNode`], counting nodes and copied bytes against `budget`; `TooLarge` past +/// the depth or either budget. +fn import_node(n: roxmltree::Node, depth: usize, budget: &mut ImportBudget) -> Result { + if depth > MAX_IMPORT_DEPTH { + return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); + } + budget.nodes = budget.nodes.checked_sub(1).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?; + let name = n.tag_name().name(); + let ns = n.tag_name().namespace(); + budget.take_bytes(name.len().saturating_add(ns.map_or(0, str::len)))?; + let mut attrs = Vec::new(); + for a in n.attributes().filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/")) { + budget.take_bytes(a.namespace().map_or(0, str::len).saturating_add(a.name().len()).saturating_add(a.value().len()))?; + attrs.push((a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string())); + } + let mut children = Vec::new(); + let mut text = String::new(); + for c in n.children() { + if c.is_element() { + children.push(import_node(c, depth + 1, budget)?); + } else if let Some(t) = c.text() { + budget.take_bytes(t.len())?; + text.push_str(t); + } + } + // A group's own text is layout whitespace. + if !children.is_empty() && text.trim().is_empty() { + text.clear(); + } + Ok(ImportNode { name: name.to_string(), ns: ns.map(str::to_string), attrs, text, children }) +} + +/// `nodes` as markup for a packet whose XFA data namespace prefix is `xfa` (declared where +/// they go); other namespaces are declared on the elements and attributes that use them. +pub(super) fn serialize_nodes(nodes: &[ImportNode], xfa: &str) -> String { + let mut out = String::new(); + for n in nodes { + serialize_node(n, xfa, &mut out, 0); + } + out +} + +fn serialize_node(n: &ImportNode, xfa: &str, out: &mut String, depth: usize) { + if depth > MAX_IMPORT_DEPTH { + return; + } + let mut decls: Vec<(String, String)> = Vec::new(); + let mut qualify = |ns: &Option, name: &str, attr: bool| -> String { + match ns.as_deref() { + None => xml_name(name), + Some(uri) if uri == XFA_DATA_NS => format!("{xfa}:{}", xml_name(name)), + Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", xml_name(name)), + Some(uri) => { + let p = match decls.iter().position(|(_, u)| u == uri) { + Some(i) => format!("n{i}"), + None => { + decls.push((format!("n{}", decls.len()), uri.to_string())); + format!("n{}", decls.len() - 1) + } + }; + let _ = attr; + format!("{p}:{}", xml_name(name)) + } + } + }; + let tag = qualify(&n.ns, &n.name, false); + let attrs: Vec = n + .attrs + .iter() + .take(256) + .map(|(ns, name, value)| { + let q = qualify(ns, name, true); + format!(" {q}=\"{}\"", escape(value).0) + }) + .collect(); + out.push('<'); + out.push_str(&tag); + for (p, uri) in &decls { + out.push_str(&format!(" xmlns:{p}=\"{}\"", escape(uri).0)); + } + for a in &attrs { + out.push_str(a); + } + if n.text.is_empty() && n.children.is_empty() { + out.push_str("/>"); + return; + } + out.push('>'); + out.push_str(&escape(&n.text).0); + for c in &n.children { + serialize_node(c, xfa, out, depth + 1); + } + out.push_str(&format!("")); +} + +/// Largest picture written into the data (its bytes, before base64): with the 64 MB cap on +/// all packets together, several pictures still fit. +const MAX_DATA_IMAGE: usize = 8 << 20; + +/// Longest data file imported. +const MAX_IMPORT: usize = 16 << 20; + +/// Put a picture into the data node at `path` as Designer saves one a user chose: base64, with +/// its content type (`image/png`, …) as `xfa:contentType` (in the packet's own prefix). The +/// node is made when missing; a data group is refused rather than wiped. `reuse`: the stream +/// this edit already wrote. +pub fn write_data_image( + doc: &mut Document, + path: &DataPath, + content_type: &str, + bytes: &[u8], + reuse: Option, +) -> Result { + use base64::Engine; + if path.is_empty() { + return Err(XfaError::Malformed("a picture needs a data path".into())); + } + if bytes.len() > MAX_DATA_IMAGE { + return Err(XfaError::TooLarge(format!("the picture is larger than {} MB", MAX_DATA_IMAGE >> 20))); + } + let ct: String = content_type.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '+')).take(64).collect(); + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true }]; + rewrite(doc, &[Op::Values(&ensure), Op::Picture(path, &ct, &b64)], reuse) +} + +/// What an import of a data file did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DataImport { + /// Element nodes the new data holds. + pub nodes: usize, + pub warnings: Vec, + /// The datasets stream written. + pub stream: Option, +} + +/// The data a file holds, as the children of `xfa:data`: an XDP or datasets document (Export +/// Data's output, Designer's), a bare data element, or a data document whose root is the +/// form's root data node (named `expect_root`, when the caller knows it). Anything else is +/// refused: a form's data is never replaced by what is not its data. +pub fn parse_data_file(text: &str, expect_root: Option<&str>) -> Result, XfaError> { + if text.len() > MAX_IMPORT { + return Err(XfaError::TooLarge(format!("the data file is larger than {} MB", MAX_IMPORT >> 20))); + } + let text = text.trim_start_matches('\u{feff}'); + if crate::xml_nesting_depth(text) > MAX_IMPORT_DEPTH { + return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep"))); + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let parsed = roxmltree::Document::parse_with_options(text, opts).map_err(|e| XfaError::Malformed(format!("the data file is not XML: {e}")))?; + let root = parsed.root_element(); + let local = root.tag_name().name(); + let in_xfa = |n: &roxmltree::Node| n.tag_name().namespace().is_none_or(|ns| ns == XFA_DATA_NS); + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name && in_xfa(n); + let data = match local { + "xdp" => { + let ds = root.children().find(|n| is(n, "datasets")).ok_or_else(|| XfaError::Malformed("the XDP holds no datasets".into()))?; + ds.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? + } + "datasets" if in_xfa(&root) => { + root.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))? + } + "data" if in_xfa(&root) => root, + _ => { + if root.tag_name().namespace().is_some() { + return Err(XfaError::Malformed(format!("<{local}> is not XFA form data"))); + } + if let Some(want) = expect_root + && local != want + { + return Err(XfaError::Malformed(format!("the data file's root is <{local}>; this form's data root is <{want}>"))); + } + let node = import_node(root, 0, &mut ImportBudget::new())?; + return Ok(vec![node]); + } + }; + let mut budget = ImportBudget::new(); + let mut out = Vec::new(); + for c in data.children().filter(|c| c.is_element()) { + out.push(import_node(c, 1, &mut budget)?); + } + Ok(out) +} + +/// Replace the form's data with what the file `text` holds (see [`parse_data_file`]), keeping +/// the rest of the datasets (data descriptions, say). `reuse`: the stream this edit already +/// wrote. +pub fn import_data(doc: &mut Document, text: &str, expect_root: Option<&str>, reuse: Option) -> Result { + let nodes = parse_data_file(text, expect_root)?; + fn count(n: &[ImportNode]) -> usize { + n.iter().map(|c| 1 + count(&c.children)).sum() + } + let r = rewrite(doc, &[Op::ReplaceData(&nodes)], reuse)?; + if !r.written { + return Err(XfaError::Malformed(r.warnings.first().cloned().unwrap_or_else(|| "the data could not be written".into()))); + } + Ok(DataImport { nodes: count(&nodes), warnings: r.warnings, stream: r.stream }) +} + +/// The datasets packet's text: from the XFA array's `datasets` item, or the whole XDP. +pub(super) fn datasets_packet(doc: &Document) -> Result, XfaError> { + let Some(root) = doc.root() else { return Ok(None) }; + let catalog = doc.get(root); + let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Ok(None) }; + let Some(xfa) = acro.as_dict().and_then(|a| a.get(b"XFA")).map(|x| doc.resolve(x)) else { return Ok(None) }; + let text_of = |o: &Object| -> Result, XfaError> { + match &*doc.resolve(o) { + Object::Stream(s) => Ok(crate::packets::decode(&s.decoded_within(64 << 20)?).map(|(t, _)| t)), + _ => Ok(None), + } + }; + match &*xfa { + Object::Array(items) => { + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); + match at.and_then(|i| items.get(i + 1)) { + Some(o) => text_of(o), + None => Ok(None), + } + } + Object::Stream(_) => text_of(&xfa), + _ => Ok(None), + } +} + +/// The data to export, as Acrobat's Export Data writes it: `xdp` false gives an XML document +/// holding the data alone (the form's root data node at the root, or the data element when +/// there are several); true gives an XDP holding the whole datasets and a reference to the +/// PDF, named `pdf_name`. A document whose packets can't be read is an error, not empty data. +pub fn export_data(doc: &Document, xdp: bool, pdf_name: &str) -> Result { + if crate::read_packets(doc)?.is_none() { + return Err(XfaError::NotXfa); + } + let packet = datasets_packet(doc)?; + let (datasets, data) = match &packet { + Some(text) => match datasets_markup(text) { + Ok(Some(m)) => m, + Ok(None) => (fresh_datasets(""), String::new()), + Err(()) => return Err(XfaError::Malformed("the XFA data could not be read".into())), + }, + None => (fresh_datasets(""), String::new()), + }; + let mut out = String::from("\n"); + if xdp { + let (name, _) = escape(pdf_name); + out.push_str("\n"); + out.push_str(&datasets); + out.push_str(&format!("\n\n\n")); + } else { + out.push_str(if data.is_empty() { &datasets } else { &data }); + out.push('\n'); + } + Ok(out) +} + +/// The `xfa:datasets` element of `text` as written, and the data alone (its one child, or the +/// data element), each with the namespaces it uses declared on it. `Ok(None)`: no datasets +/// element; `Err`: the text can't be read. +fn datasets_markup(text: &str) -> Result, ()> { + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { + return Err(()); + } + let wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); + let (src, parsed) = match roxmltree::Document::parse_with_options(text, opts) { + Ok(d) => (text, d), + Err(_) => (wrapped.as_str(), roxmltree::Document::parse_with_options(&wrapped, opts).map_err(|_| ())?), + }; + let Some(ds) = parsed.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets") else { return Ok(None) }; + let data = ds.children().find(|n| n.is_element() && n.tag_name().name() == "data"); + let data_alone = match data { + Some(d) => { + let kids: Vec = d.children().filter(|c| c.is_element()).collect(); + match kids.as_slice() { + [one] => element_markup(src, *one).ok_or(())?, + _ => element_markup(src, d).ok_or(())?, + } + } + None => String::new(), + }; + Ok(Some((element_markup(src, ds).ok_or(())?, data_alone))) +} + +/// `n` as written in `src`, with the namespace declarations in scope that its start tag lacks +/// added to it, so it stands alone. +fn element_markup(src: &str, n: roxmltree::Node) -> Option { + let range = n.range(); + let text = src.get(range.clone())?; + let tag_end = start_tag_end(src, &range)?; + let start_tag = src.get(range.start..tag_end)?; + let mut decls = String::new(); + for ns in n.namespaces() { + let attr = match ns.name() { + Some(p) => format!("xmlns:{p}="), + None => "xmlns=".to_string(), + }; + if !start_tag.contains(&attr) { + decls.push_str(&format!(" {attr}\"{}\"", escape(ns.uri()).0)); + } + } + if decls.is_empty() { + return Some(text.to_string()); + } + let name_end = qname(src, &range)?.len() + 1; + Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?)) +} diff --git a/crates/xfa/src/data/merge.rs b/crates/xfa/src/data/merge.rs new file mode 100644 index 0000000000..6888b1439a --- /dev/null +++ b/crates/xfa/src/data/merge.rs @@ -0,0 +1,399 @@ +use super::build::{Build, CONFLICT, CONTROL, NewNodes, STRUCTURED, TOO_MANY, build_children, escape, fresh_datasets, skipped, xml_name}; +use super::import::{ImportNode, serialize_nodes}; +use super::values::Want; +use super::write::MAX_PACKET_WRITE; +use super::*; + +/// What a rewrite of the datasets packet does. +#[derive(Clone, Copy)] +pub(super) enum Op<'a> { + /// Put these values in (adding nodes as needed). + Values(&'a [Want]), + /// Take the node at this path out. + Remove(&'a DataPath), + /// Make these nodes the whole content of the `xfa:data` element (an import). + ReplaceData(&'a [ImportNode]), + /// Make the leaf at this path a picture: base64 text with its content type. + Picture(&'a DataPath, &'a str, &'a str), +} + +/// Where an element's start tag ends (the index of its `>`), skipping quoted attribute values. +pub(super) fn start_tag_end(src: &str, range: &std::ops::Range) -> Option { + let bytes = src.as_bytes(); + let mut quote = None; + for i in range.clone() { + let b = *bytes.get(i)?; + match (quote, b) { + (Some(q), _) if b == q => quote = None, + (Some(_), _) => {} + (None, b'"' | b'\'') => quote = Some(b), + (None, b'>') => return Some(i), + _ => {} + } + } + None +} + +/// An element's qualified name as written. +pub(super) fn qname<'s>(src: &'s str, range: &std::ops::Range) -> Option<&'s str> { + let rest = src.get(range.start.checked_add(1)?..range.end)?; + let end = rest.find(|c: char| c.is_whitespace() || c == '/' || c == '>')?; + rest.get(..end).filter(|n| !n.is_empty()) +} + +/// One change to the packet text: replace `start..end` with `text`. +struct Edit { + start: usize, + end: usize, + text: String, +} + +/// The edit that puts `inner` (markup) at the end of `node`'s content. +fn append_edit(src: &str, node: roxmltree::Node, inner: &str) -> Option { + let range = node.range(); + let tag_end = start_tag_end(src, &range)?; + if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { + // `` becomes `inner`. + let name = qname(src, &range)?; + return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{inner}") }); + } + let close = range.start + src.get(range.clone())?.rfind(" Option { + let range = node.range(); + let tag_end = start_tag_end(src, &range)?; + if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { + let name = qname(src, &range)?; + return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{text}") }); + } + let close = range.start + src.get(range.clone())?.rfind(" tag_end).then(|| Edit { start: tag_end + 1, end: close, text: text.to_string() }) +} + +/// What merging the values into a packet's text came to. +enum Merged { + /// The data already holds every value. + Unchanged, + Changed(String), + /// The text could not be merged into (the reason is in the warnings). + Failed, +} + +/// Where a datasets packet sits: one packet of an array (`Packet`), or inside the whole XDP. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Within { + Packet, + Xdp, +} + +pub(super) const WRAP_OPEN: &str = ""; + +pub(super) const WRAP_CLOSE: &str = ""; + +/// Write `wants` into the data of the datasets packet in `text`, changing only what has to +/// change: values go into the data nodes they bind to, missing nodes are added, and everything +/// else (data no field binds to, other namespaces, attributes, comments, layout) stays as +/// written. +/// The prefix bound to the XFA data namespace where `n` is (`None`: unbound or the default +/// namespace, which attributes can't use). +fn xfa_prefix(n: roxmltree::Node) -> Option { + n.namespaces().find(|ns| ns.uri() == XFA_DATA_NS).and_then(|ns| ns.name()).filter(|p| !p.is_empty()).map(str::to_string) +} + +fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merged { + let wants: &[Want] = match op { + Op::Values(w) => w, + Op::Remove(_) | Op::ReplaceData(_) | Op::Picture(..) => &[], + }; + // Fresh data, where none exists yet (declared with the `xfa` prefix). + let fresh_children = |warnings: &mut Vec| match op { + Op::ReplaceData(nodes) => serialize_nodes(nodes, "xfa"), + _ => build_children(wants, warnings), + }; + if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { + warnings.push("The XFA data nests XML elements deeper than the reader accepts; the field values were not written to it".into()); + return Merged::Failed; + } + let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + // A packet of an array may use the prefixes the XDP envelope declares: read it inside + // an element declaring the usual ones. + let wrapped; + let (src, shift, parsed) = match roxmltree::Document::parse_with_options(text, opts()) { + Ok(d) => (text, 0, d), + Err(first) => { + wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); + match (within, roxmltree::Document::parse_with_options(&wrapped, opts())) { + (Within::Packet, Ok(d)) => (wrapped.as_str(), WRAP_OPEN.len(), d), + _ => { + warnings.push(format!("The XFA data could not be read ({first}); the field values were not written to it")); + return Merged::Failed; + } + } + } + }; + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; + let datasets = parsed + .descendants() + .find(|n| is(n, "datasets") && n.tag_name().namespace() == Some(XFA_DATA_NS)) + .or_else(|| parsed.descendants().find(|n| is(n, "datasets"))); + let mut edits = Vec::new(); + let mut data = None; + match datasets { + None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, + None if within == Within::Xdp => { + let root = parsed.root_element(); + let children = fresh_children(warnings); + match append_edit(src, root, &fresh_datasets(&children)) { + Some(e) => edits.push(e), + None => { + warnings.push("The XFA packets could not be extended with data; the field values were not written".into()); + return Merged::Failed; + } + } + } + None => { + warnings.push("The XFA datasets packet holds no datasets element; the field values were not written to it".into()); + return Merged::Failed; + } + Some(ds) => match ds.children().find(|n| is(n, "data")) { + Some(d) => data = Some(d), + None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged, + None => { + let prefix = qname(src, &ds.range()).and_then(|q| q.split_once(':')).map(|(p, _)| format!("{p}:")).unwrap_or_default(); + let children = fresh_children(warnings); + let element = format!("<{prefix}data>{}", children); + match append_edit(src, ds, &element) { + Some(e) => edits.push(e), + None => { + warnings.push("The XFA datasets could not be extended with data; the field values were not written".into()); + return Merged::Failed; + } + } + } + }, + } + if let Some(data) = data { + match op { + Op::Values(w) => merge_values(src, data, w, &mut edits, warnings), + Op::ReplaceData(nodes) => { + // Everything between the start and end tags; `` opens up. Written + // with the prefix the packet binds to the data namespace. + let prefix = xfa_prefix(data); + let markup = serialize_nodes(nodes, prefix.as_deref().unwrap_or("xfa")); + let range = data.range(); + match (start_tag_end(src, &range), qname(src, &range)) { + (Some(tag_end), Some(name)) if tag_end + 1 == range.end && src.get(..tag_end).is_some_and(|s| s.ends_with('/')) => { + edits.push(Edit { start: range.start, end: range.end, text: format!("<{name}>{markup}") }); + } + (Some(tag_end), _) => { + let close = src.get(range.clone()).and_then(|s| s.rfind(" edits.push(Edit { start: tag_end + 1, end: close, text: markup }), + _ => { + warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); + return Merged::Failed; + } + } + } + _ => { + warnings.push("The XFA data element could not be rewritten; the data was not imported".into()); + return Merged::Failed; + } + } + } + Op::Picture(path, content_type, base64) => { + let mut node = Some(data); + for (name, idx) in path.iter() { + let want = xml_name(name); + node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == want).nth(*idx)); + } + let Some(n) = node else { return Merged::Unchanged }; + if n.children().any(|c| c.is_element()) { + // A data group, not a leaf: a picture would wipe what's under it. + skipped(warnings, STRUCTURED, &path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".")); + return Merged::Failed; + } + let name = qname(src, &n.range()).unwrap_or("value").to_string(); + let (ct, _) = escape(content_type); + let attr = match xfa_prefix(n) { + Some(p) => format!("{p}:contentType=\"{ct}\""), + None => format!("xmlns:xfa=\"{XFA_DATA_NS}\" xfa:contentType=\"{ct}\""), + }; + edits.push(Edit { start: n.range().start, end: n.range().end, text: format!("<{name} {attr}>{base64}") }); + } + Op::Remove(path) => { + // The element at `path`, with the whitespace before it. + let mut node = Some(data); + for (name, idx) in path { + node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == name).nth(*idx)); + } + if let Some(n) = node { + let r = n.range(); + let start = src.get(..r.start).map_or(r.start, |before| r.start - before.len() + before.trim_end().len()); + edits.push(Edit { start, end: r.end, text: String::new() }); + } + } + } + } + if edits.is_empty() { + return Merged::Unchanged; + } + // Apply from the end, so earlier positions stay valid; overlapping edits (two fields bound + // to one node) keep the first. + edits.sort_by_key(|e| (e.start, e.end)); + let mut kept: Vec = Vec::with_capacity(edits.len()); + for e in edits { + if kept.last().is_some_and(|k| e.start < k.end) { + if kept.last().is_some_and(|k| k.text != e.text) && !warnings.iter().any(|w| w == CONFLICT) { + warnings.push(CONFLICT.into()); + } + continue; + } + kept.push(e); + } + let mut out = src.to_string(); + for e in kept.iter().rev() { + if out.get(e.start..e.end).is_none() { + warnings.push("The XFA data could not be updated; the field values were not written to it".into()); + return Merged::Failed; + } + out.replace_range(e.start..e.end, &e.text); + } + if shift > 0 { + match out.get(shift..out.len().saturating_sub(WRAP_CLOSE.len())) { + Some(inner) => out = inner.to_string(), + None => return Merged::Failed, + } + } + Merged::Changed(out) +} + +/// The edits that put `wants` into the `data` element. +fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Vec, warnings: &mut Vec) { + // Element children by local name, per element visited (built once each). + let mut index: HashMap>> = HashMap::new(); + let tree = data.document(); + // Nodes to add, per existing element they go into. + let mut added: Vec<(roxmltree::NodeId, Build)> = Vec::new(); + let mut budget = NewNodes::new(); + for w in wants { + let (escaped, dropped) = escape(&w.text); + let mut node = data; + let mut depth = 0; + let mut have = 0; + for (name, idx) in &w.path { + let kids = index.entry(node.id()).or_insert_with(|| { + let mut by: HashMap> = HashMap::new(); + for c in node.children().filter(|c| c.is_element()) { + by.entry(c.tag_name().name().to_string()).or_default().push(c.id()); + } + by + }); + let same = kids.get(name.as_str()); + match same.and_then(|v| v.get(*idx)).and_then(|id| tree.get_node(*id)) { + Some(next) => { + node = next; + depth += 1; + } + None => { + have = same.map_or(0, Vec::len); + break; + } + } + } + if depth == w.path.len() { + // The data node exists: give it the value, unless it already holds it. A node + // only wanted to exist (a row instance) is left as it is. + if w.ensure && w.text.is_empty() { + continue; + } + let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); + if node.children().any(|c| c.is_element()) { + let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); + // Ignore existing XML indentation, but not whitespace in the accepted value. + // A skipped mismatch must warn: relayout would otherwise use stale data. + if all.trim() != w.text { + skipped(warnings, STRUCTURED, &w.som); + } + continue; + } + if current == w.text { + continue; + } + match replace_edit(src, node, &escaped) { + Some(e) => edits.push(e), + None => skipped(warnings, STRUCTURED, &w.som), + } + } else { + // Missing: add the rest of the path below the deepest node that exists. An empty + // value needs no node, unless the node itself is wanted (a new instance). + if w.text.is_empty() && !w.ensure { + continue; + } + let mut rest: DataPath = w.path.get(depth..).unwrap_or_default().to_vec(); + if let Some(first) = rest.first_mut() { + first.1 = first.1.saturating_sub(have); + } + let pos = match added.iter().position(|(id, _)| *id == node.id()) { + Some(p) => p, + None => { + added.push((node.id(), Build::default())); + added.len() - 1 + } + }; + let built = added.get_mut(pos).and_then(|(_, b)| b.at(&rest, &mut budget)); + match built { + Some(b) => b.text = w.text.clone(), + None => { + skipped(warnings, TOO_MANY, &w.som); + continue; + } + } + } + if dropped { + skipped(warnings, CONTROL, &w.som); + } + } + for (id, build) in added { + let Some(node) = tree.get_node(id) else { continue }; + let mut inner = String::new(); + build.write_children(&mut inner); + match append_edit(src, node, &inner) { + Some(e) => edits.push(e), + None => warnings.push("Some XFA data nodes could not be added; their field values were not written".into()), + } + } +} + +/// Merge `ops` into `text` in order. `None`: nothing changed (or nothing could be merged). +pub(super) fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec) -> Option { + let mut current: Option = None; + for op in ops { + match merge(current.as_deref().unwrap_or(text), within, *op, warnings) { + Merged::Changed(t) => current = Some(t), + Merged::Unchanged => {} + // Later steps still apply to what merged so far; the reason is in the warnings. + Merged::Failed => {} + } + } + current +} + +/// Is `text` a packet the reader will take back (well-formed, within size)? A rewrite that +/// isn't is not written: better the old data than an unreadable packet. +pub(super) fn readable(text: &str, within: Within, warnings: &mut Vec) -> bool { + if text.len() > MAX_PACKET_WRITE { + warnings.push(format!("The XFA data would be larger than {} MB; it was not written", MAX_PACKET_WRITE >> 20)); + return false; + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let ok = roxmltree::Document::parse_with_options(text, opts).is_ok() + || (within == Within::Packet && roxmltree::Document::parse_with_options(&format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"), opts).is_ok()); + if !ok { + warnings.push("The XFA data would not be well-formed XML after this change; it was not written".into()); + } + ok +} diff --git a/crates/xfa/src/data/passwords.rs b/crates/xfa/src/data/passwords.rs new file mode 100644 index 0000000000..78503bb53c --- /dev/null +++ b/crates/xfa/src/data/passwords.rs @@ -0,0 +1,194 @@ +use super::import::datasets_packet; +use super::merge::Op; +use super::path::{data_path, parse_datasets}; +use super::values::{Want, path_of, text_of, with_template}; +use super::write::{datasets_stream_ref, rewrite}; +use super::*; + +/// The SOM path and default value of every AcroForm text field with the Password flag (bit 14, +/// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm +/// dictionary. +fn acro_password_fields(doc: &Document) -> Vec<(String, String)> { + const PASSWORD_FLAG: i64 = 1 << 13; + let Some(root) = doc.root() else { return Vec::new() }; + let catalog = doc.get(root); + let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Vec::new() }; + let Some(top) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else { + return Vec::new(); + }; + struct AcroWalk<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + out: Vec<(String, String)>, + } + impl AcroWalk<'_> { + fn walk( + &mut self, + items: &[Object], + parent_name: &str, + inherited_ft: Option<&[u8]>, + inherited_ff: i64, + inherited_dv: Option<&str>, + depth: usize, + ) { + if depth > 32 { + return; + } + for item in items { + let Some(r) = item.as_ref() else { continue }; + if !self.seen.insert(r) { + continue; + } + let obj = self.doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let ft = d.name(b"FT").or(inherited_ft); + let ff = d.int(b"Ff").unwrap_or(inherited_ff); + let dv_own = text_of(self.doc, d, b"DV"); + let dv = dv_own.as_deref().or(inherited_dv); + let t = text_of(self.doc, d, b"T"); + let full_name = match (&t, parent_name.is_empty()) { + (Some(seg), true) => seg.clone(), + (Some(seg), false) => format!("{parent_name}.{seg}"), + (None, _) => parent_name.to_string(), + }; + let kids = d.get(b"Kids").map(|k| self.doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + let has_named_kid = kids.iter().filter_map(Object::as_ref).any(|kr| self.doc.get(kr).as_dict().is_some_and(|kd| kd.contains(b"T"))); + if !has_named_kid && ft == Some(b"Tx") && (ff & PASSWORD_FLAG) != 0 { + let som = text_of(self.doc, d, crate::pdf::SOM_KEY).unwrap_or_else(|| full_name.clone()); + if !som.is_empty() { + self.out.push((som, dv.unwrap_or_default().to_string())); + } + } + if !kids.is_empty() { + self.walk(&kids, &full_name, ft, ff, dv, depth + 1); + } + } + } + } + let mut w = AcroWalk { doc, seen: std::collections::HashSet::new(), out: Vec::new() }; + w.walk(&top, "", None, 0, None, 0); + w.out +} + +/// The `Want` entries needed to restore password fields in `doc`'s datasets packet to their +/// defaults (or empty when they have no default). +fn password_dataset_wants(doc: &Document) -> Vec { + if datasets_stream_ref(doc).is_none() { + return Vec::new(); + } + let Ok(Some(packet)) = datasets_packet(doc) else { return Vec::new() }; + let Some(data) = parse_datasets(&packet) else { return Vec::new() }; + with_template(doc, None, |tpl| { + let mut candidates: Vec<(String, DataPath, String)> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + if let Some(t) = tpl { + struct Walk<'a> { + tpl: &'a Template, + data: &'a DataNode, + nodes: usize, + seen: &'a mut std::collections::HashSet, + out: &'a mut Vec<(String, DataPath, String)>, + } + impl Walk<'_> { + fn child_som(parent: &str, name: Option<&str>, index: usize) -> String { + match name { + Some(n) if !parent.is_empty() => format!("{parent}.{n}[{index}]"), + Some(n) => format!("{n}[{index}]"), + None => parent.to_string(), + } + } + fn walk(&mut self, children: &[Node], parent_som: &str, depth: usize) { + if depth > MAX_DEPTH || self.nodes >= crate::script::MAX_FORM_NODES { + return; + } + let sibs = SomIndices::of(children); + for (i, n) in children.iter().enumerate() { + if self.nodes >= crate::script::MAX_FORM_NODES { + break; + } + self.nodes += 1; + let sib = sibs.at(i); + match n { + Node::Subform(sf) => { + let from_data = match &sf.common.name { + Some(name) if sf.occur.max != Some(1) => self.data.count(&data_path(self.tpl, parent_som), name), + _ => 0, + }; + let instances = crate::layout::instance_count(&sf.occur, from_data).max(from_data); + for k in 0..instances { + if k > 0 { + if self.nodes >= crate::script::MAX_FORM_NODES { + break; + } + self.nodes += 1; + } + let som = Self::child_som(parent_som, sf.common.name.as_deref(), sib + k); + self.walk(&sf.children, &som, depth + 1); + } + } + Node::Area(a) => { + let som = Self::child_som(parent_som, a.common.name.as_deref(), sib); + self.walk(&a.children, &som, depth + 1); + } + Node::Field(f) if f.ui == Ui::PasswordEdit => { + let som = Self::child_som(parent_som, f.common.name.as_deref().or(Some("field")), sib); + let path = data_path(self.tpl, &som); + if !path.is_empty() && self.seen.insert(path.clone()) { + let default = f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(); + self.out.push((som, path, default)); + } + } + _ => {} + } + } + } + } + let root = Node::Subform(Box::new(t.root.clone())); + let mut w = Walk { tpl: t, data: &data, nodes: 0, seen: &mut seen, out: &mut candidates }; + w.walk(std::slice::from_ref(&root), "", 0); + if let Some(ps) = &t.root.page_set { + for (i, area) in ps.areas.iter().enumerate() { + let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]")); + w.walk(&area.items, &som, 1); + } + } + } + for (som, default) in acro_password_fields(doc) { + let path = path_of(tpl, &som); + if !path.is_empty() && seen.insert(path.clone()) { + candidates.push((som, path, default)); + } + } + candidates + .into_iter() + .filter_map(|(som, path, default)| { + let cur = data.value_at(&path)?; + (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false }) + }) + .collect() + }) +} + +/// Whether any XFA password field (`` or an AcroForm text field with the Password +/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944). +pub fn has_password_values(doc: &Document) -> bool { + !password_dataset_wants(doc).is_empty() +} + +/// Clear typed password values from `doc`'s XFA datasets packet (restoring a template default +/// where the field has one, else emptying the element), replacing the datasets stream in place +/// (#3944). Returns how many password fields were cleared in the datasets. +pub fn clear_password_values(doc: &mut Document) -> Result { + let wants = password_dataset_wants(doc); + if wants.is_empty() { + return Ok(0); + } + let count = wants.len(); + let reuse = datasets_stream_ref(doc); + let report = rewrite(doc, &[Op::Values(&wants)], reuse)?; + if !report.written || !password_dataset_wants(doc).is_empty() { + let reason = report.warnings.first().map_or("the XFA datasets password values could not be cleared", String::as_str); + return Err(XfaError::Malformed(reason.to_string())); + } + Ok(count) +} diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs new file mode 100644 index 0000000000..90cc4346f2 --- /dev/null +++ b/crates/xfa/src/data/path.rs @@ -0,0 +1,156 @@ +use super::*; + +fn to_node(n: roxmltree::Node, depth: usize) -> DataNode { + let name = n.tag_name().name().to_string(); + let children: Vec = + if depth < MAX_DEPTH { n.children().filter(|c| c.is_element()).map(|c| to_node(c, depth + 1)).collect() } else { Vec::new() }; + // The text of a leaf is its value; a container's own text is whitespace. + let text = if children.is_empty() { n.children().filter_map(|c| c.text()).collect::() } else { String::new() }; + DataNode { name, text, children } +} + +/// The `xfa:data` element of the datasets packet in `xdp` (or a bare datasets document), as a +/// tree whose root is that element. +pub fn parse_datasets(xdp: &str) -> Option { + if crate::xml_nesting_depth(xdp) > crate::MAX_XML_DEPTH { + return None; + } + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let doc = roxmltree::Document::parse_with_options(xdp, opts).ok()?; + let datasets = doc.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets")?; + let data = datasets.children().find(|n| n.is_element() && n.tag_name().name() == "data")?; + Some(to_node(data, 0)) +} + +/// The data path a SOM expression names: `form[0].#subform[0].name[1]` → `[(form, 0), (name, 1)]`. +/// Unnamed containers (`#subform`) have no data node. +pub fn som_to_path(som: &str) -> DataPath { + som.split('.') + .filter_map(|seg| { + let seg = seg.trim(); + if seg.is_empty() || seg.starts_with('#') || seg.starts_with('$') { + return None; + } + let (name, idx) = match seg.split_once('[') { + Some((n, rest)) => (n, rest.trim_end_matches(']').parse::().unwrap_or(0)), + None => (seg, 0), + }; + Some((name.to_string(), idx.min(100_000))) + }) + .collect() +} + +/// The data path the object at `som` binds to: [`som_to_path`] without the areas on the way. +/// An area is a named container in the form, so it is part of SOM expressions, but only +/// subforms, exclusion groups and fields are data scopes: what an area holds binds under the +/// area's parent, as Adobe's viewers write it. The template tells which segments are areas; +/// segments it doesn't have (page-set objects, or a SOM from another template) are kept. +pub fn data_path(tpl: &Template, som: &str) -> DataPath { + let mut out = DataPath::new(); + // The template children the next segment is looked up in; `None` until the root matched, + // and again once a segment is not a container the template has. + let mut level: Option<&[Node]> = None; + for (i, (name, index)) in som_to_path(som).into_iter().enumerate() { + let found = match level { + None if i == 0 && tpl.root.common.name.as_deref() == Some(name.as_str()) => Some(Container::Children(&tpl.root.children)), + None => None, + Some(children) => match named(children, &name, 0) { + Some(Node::Subform(sf)) => Some(Container::Children(&sf.children)), + Some(Node::Area(a)) => Some(Container::Area(&a.children)), + _ => None, + }, + }; + match found { + Some(Container::Area(children)) => level = Some(children), + Some(Container::Children(children)) => { + level = Some(children); + out.push((name, index)); + } + None => { + level = None; + out.push((name, index)); + } + } + } + out +} + +enum Container<'a> { + Children(&'a [Node]), + /// Contents bind under the area's parent. + Area(&'a [Node]), +} + +/// The child of `nodes` named `name`, looking through unnamed subforms and areas (which have no +/// SOM segment of their own). +fn named<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> { + if depth > MAX_DEPTH { + return None; + } + nodes.iter().find(|n| n.common().name.as_deref() == Some(name)).or_else(|| { + nodes.iter().find_map(|n| match n { + Node::Subform(sf) if sf.common.name.is_none() => named(&sf.children, name, depth + 1), + Node::Area(a) if a.common.name.is_none() => named(&a.children, name, depth + 1), + _ => None, + }) + }) +} + +/// The child of `nodes` the SOM segment `name[index]` names, numbered as the form numbers its +/// objects ([`SomIndices`]): same-named siblings count up, and the instances of a repeating +/// subform take the numbers from its own on. Unnamed subforms and areas are looked through, +/// as [`named`] does. +fn named_at<'a>(nodes: &'a [Node], name: &str, index: usize, depth: usize) -> Option<&'a Node> { + if depth > MAX_DEPTH { + return None; + } + let sibs = SomIndices::of(nodes); + let mut repeating = None; + for (i, n) in nodes.iter().enumerate() { + if n.common().name.as_deref() != Some(name) { + continue; + } + let sib = sibs.at(i); + if sib == index { + return Some(n); + } + if sib < index && matches!(n, Node::Subform(sf) if sf.occur.max != Some(1)) { + repeating = Some(n); + } + } + repeating.or_else(|| { + nodes.iter().find_map(|n| match n { + Node::Subform(sf) if sf.common.name.is_none() => named_at(&sf.children, name, index, depth + 1), + Node::Area(a) if a.common.name.is_none() => named_at(&a.children, name, index, depth + 1), + _ => None, + }) + }) +} + +/// The template field the object at a SOM path is an instance of, each segment's number counted +/// as the form counts it ([`named_at`]). `None` when the template has no such field, or it is +/// a button of an exclusion group (whose buttons take their value from the group). +pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> { + let path = som_to_path(som); + let ((root, _), rest) = path.split_first()?; + if tpl.root.common.name.as_deref() != Some(root.as_str()) { + return None; + } + let ((last, last_index), inner) = rest.split_last()?; + let mut children: &[Node] = &tpl.root.children; + for (name, index) in inner { + children = match named_at(children, name, *index, 0)? { + Node::Subform(sf) => &sf.children, + Node::Area(a) => &a.children, + _ => return None, + }; + } + match named_at(children, last, *last_index, 0)? { + Node::Field(f) => Some(f), + _ => None, + } +} + +pub(super) fn path_som(path: &DataPath) -> String { + path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::>().join(".") +} diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs new file mode 100644 index 0000000000..41fb6644e9 --- /dev/null +++ b/crates/xfa/src/data/values.rs @@ -0,0 +1,192 @@ +use super::dates::{data_date_pattern, data_to_iso, iso_to_data, iso_to_pattern, pattern_to_iso}; +use super::path::{data_path, parse_datasets, som_to_path}; +use super::*; + +/// What a field holds, for writing the datasets. +#[derive(Clone, Debug, PartialEq)] +pub enum FieldData { + Text(String), + /// Checked or not. + Check(bool), + /// The selected button's on value, if any. + Radio(Option), + /// Nothing to write (buttons, signatures). + None, +} + +/// One field of the AcroForm, as the engine sees it. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldDatum { + /// The terminal field dictionary (where `/PCSom` and `/PCItems` live). + pub obj: ObjRef, + /// The fully qualified field name (a static form's SOM path). + pub name: String, + pub data: FieldData, +} + +pub(super) fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())) +} + +/// The SOM path of a field: its `/PCSom` when PdfCraft generated it, else its name. +fn som_of(doc: &Document, f: &FieldDatum) -> String { + doc.get(f.obj).as_dict().and_then(|d| text_of(doc, d, crate::pdf::SOM_KEY)).unwrap_or_else(|| f.name.clone()) +} + +/// The datasets text for a radio group. The AcroForm value is an appearance-state name +/// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item. +fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { + let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else { + return String::new(); + }; + let Some(group) = doc.get(f.obj).as_dict().cloned() else { return selected.to_string() }; + let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]); + for kid in kids { + let Some(widget) = doc.resolve(&kid).as_dict().cloned() else { continue }; + let original = text_of(doc, &widget, b"PCOn"); + let state = widget + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| doc.resolve(&n).as_dict().cloned()) + .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); + if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) { + return original.unwrap_or_else(|| selected.to_string()); + } + } + selected.to_string() +} + +/// The on and off item values of a check box (`/PCItems`), or the widget's on state. +fn items_of(doc: &Document, f: &FieldDatum) -> (String, String) { + let d = doc.get(f.obj); + let items: Vec = d + .as_dict() + .and_then(|d| d.get(b"PCItems").map(|o| doc.resolve(o))) + .and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| x.as_string().map(|s| s.to_text())).collect())) + .unwrap_or_default(); + let on = items.first().cloned().unwrap_or_else(|| "1".into()); + let off = items.get(1).cloned().unwrap_or_default(); + (on, off) +} + +/// The Acrobat date pattern a field's format action names (`AFDate_FormatEx("yyyy-mm-dd")`). +fn date_pattern_of(doc: &Document, f: &FieldDatum) -> Option { + let d = doc.get(f.obj); + let aa = doc.resolve(d.as_dict()?.get(b"AA")?); + let fa = doc.resolve(aa.as_dict()?.get(b"F")?); + let js = text_of(doc, fa.as_dict()?, b"JS")?; + let i = js.find("AFDate_FormatEx(")?; + let rest = &js[i + "AFDate_FormatEx(".len()..]; + let q = rest.find('"')?; + let body = &rest[q + 1..]; + Some(body[..body.find('"')?].to_string()) +} + +/// A value to write: where (the field's SOM path, and the data path it binds to) and what. +pub(super) struct Want { + pub(super) som: String, + pub(super) path: DataPath, + pub(super) text: String, + /// Create the node even for an empty value (an instance of a repeating subform). + pub(super) ensure: bool, +} + +/// `tpl`, or the document's own template when none is given (parsed once). +pub(super) fn with_template(doc: &Document, tpl: Option<&Template>, f: impl FnOnce(Option<&Template>) -> T) -> T { + match tpl { + Some(t) => f(Some(t)), + None => { + let parsed = crate::script::template_of(doc).ok(); + f(parsed.as_ref()) + } + } +} + +/// [`data_path`] when the template is known, else [`som_to_path`]. +pub(super) fn path_of(tpl: Option<&Template>, som: &str) -> DataPath { + match tpl { + Some(t) => data_path(t, som), + None => som_to_path(som), + } +} + +/// The values the datasets should hold for `fields`, by data path: dates in each field's data +/// picture (or ISO), check and radio states, choice values as saved. +pub(super) fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Template>) -> Vec { + let mut out = Vec::new(); + for f in fields { + let som = som_of(doc, f); + let path = path_of(tpl, &som); + if path.is_empty() { + continue; + } + let text = match &f.data { + FieldData::Text(t) => match date_pattern_of(doc, f) { + Some(p) => match pattern_to_iso(t, &p) { + // The date goes into the data in the field's data picture, or as an ISO date. + Some(iso) => iso_to_data(iso, data_date_pattern(tpl, &som).as_deref()), + None => t.clone(), + }, + None => t.clone(), + }, + FieldData::Check(on) => { + let (on_item, off_item) = items_of(doc, f); + if *on { on_item } else { off_item } + } + FieldData::Radio(sel) => radio_dataset_value(doc, f, sel), + FieldData::None => continue, + }; + out.push(Want { som, path, text, ensure: false }); + } + out +} + +/// The values the datasets hold for `fields`: `(field name, what it should hold)`, only for +/// fields the data mentions. +pub fn read_values(doc: &Document, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { + read_values_with(doc, None, fields) +} + +/// [`read_values`] with the form's parsed template when the caller has it; `None` parses the +/// document's. +pub fn read_values_with(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { + with_template(doc, tpl, |t| values_at(doc, t, fields)) +} + +/// The values the document's datasets hold for `fields`, read in each field's data picture and +/// shown in its own pattern; only fields the data mentions. +fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { + let Ok(Some(p)) = crate::read_packets(doc) else { return Vec::new() }; + let Some(data) = parse_datasets(&p.xdp) else { return Vec::new() }; + let mut out = Vec::new(); + for f in fields { + let som = som_of(doc, f); + let path = path_of(tpl, &som); + let Some(text) = data.value_at(&path) else { continue }; + let text = text.as_str(); + let value = match &f.data { + FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) { + Some(pat) => iso_to_pattern(&data_to_iso(text, data_date_pattern(tpl, &som).as_deref()), &pat), + None => text.to_string(), + }), + FieldData::Check(_) => { + let (on, _) = items_of(doc, f); + FieldData::Check(is_on(text, &on)) + } + FieldData::Radio(_) => FieldData::Radio((!text.trim().is_empty()).then(|| text.trim().to_string())), + FieldData::None => continue, + }; + out.push((f.name.clone(), value)); + } + out +} + +/// Does a data value mean "checked" for a button whose on value is `on`? +pub fn is_on(text: &str, on: &str) -> bool { + let t = text.trim(); + if t == on { + return true; + } + !matches!(t.to_ascii_lowercase().as_str(), "" | "0" | "off" | "false" | "no") && on.trim().is_empty() +} diff --git a/crates/xfa/src/data/write.rs b/crates/xfa/src/data/write.rs new file mode 100644 index 0000000000..282dd33ef5 --- /dev/null +++ b/crates/xfa/src/data/write.rs @@ -0,0 +1,264 @@ +use super::build::{MAX_INDEX, build_children, fresh_datasets}; +use super::merge::{Op, Within, merge_all, readable}; +use super::path::path_som; +use super::values::{FieldDatum, Want, wanted, with_template}; +use super::*; + +/// What [`write_datasets`] did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DatasetsWrite { + /// The datasets packet changed (a new revision of it was added to the document). + pub written: bool, + /// Values that could not be written, and why. + pub warnings: Vec, + /// The datasets stream written, when one was. + pub stream: Option, +} + +/// A packet stream's text and encoding; `None` (with a warning) when it can't be read exactly. +fn packet_text(doc: &Document, o: &Object, warnings: &mut Vec) -> Option<(String, Encoding)> { + let s = doc.resolve(o); + let Object::Stream(s) = &*s else { + warnings.push("The XFA data packet is not a stream; the field values were not written to it".into()); + return None; + }; + let bytes = match s.decoded_within(64 << 20) { + Ok(b) => b, + Err(e) => { + warnings.push(format!("The XFA data could not be decoded ({e}); the field values were not written to it")); + return None; + } + }; + let decoded = crate::packets::decode(&bytes); + if decoded.is_none() { + warnings.push("The XFA data is neither UTF-8 nor UTF-16; the field values were not written to it, so it stays as it was".into()); + } + decoded +} + +/// Write the fields' values into the datasets packet, keeping all the data no field binds to. +/// Nothing is written when the document has no XFA entry or the data already holds the values; +/// values that can't be written are reported, never silently dropped. +pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result { + write_datasets_reusing(doc, fields, None) +} + +/// [`write_datasets`], replacing the stream `reuse` in place when the packet is that stream +/// (written earlier in the same edit) rather than adding another. +pub fn write_datasets_reusing(doc: &mut Document, fields: &[FieldDatum], reuse: Option) -> Result { + write_datasets_with(doc, None, fields, reuse) +} + +/// [`write_datasets_reusing`] with the form's parsed template when the caller has it (it says +/// which SOM segments are areas, see [`data_path`]); `None` parses the document's. +pub fn write_datasets_with( + doc: &mut Document, + tpl: Option<&Template>, + fields: &[FieldDatum], + reuse: Option, +) -> Result { + let wants = with_template(doc, tpl, |t| wanted(doc, fields, t)); + rewrite(doc, &[Op::Values(&wants)], reuse) +} + +/// Make sure the data has at least `count` instances of `name` under `parent` (a script added +/// rows: the layout repeats the subform once per data instance). +pub fn add_data_instances(doc: &mut Document, parent: &DataPath, name: &str, count: usize) -> Result { + write_data_ops(doc, &[DataOp::Instances { parent: parent.clone(), name: name.to_string(), count }], None) +} + +/// Put `text` at the data node `path` (a field with no widget, a hidden one or a draw, that a +/// script gave a value), adding the node as needed. +pub fn write_data_value(doc: &mut Document, path: &DataPath, text: &str) -> Result { + write_data_ops(doc, &[DataOp::Value { path: path.clone(), text: text.to_string() }], None) +} + +/// Remove the data node at `path` (a script removed a row). +pub fn remove_data_instance(doc: &mut Document, path: &DataPath) -> Result { + write_data_ops(doc, &[DataOp::Remove(path.clone())], None) +} + +/// Most instance nodes one [`write_data_ops`] queues over all its passes (a removal starts a new +/// pass, which asks for the instances again). +const MAX_ENSURED: usize = 200_000; + +// Instance nodes write_data_ops has queued on this thread, for tests that bound its work. +#[cfg(test)] +thread_local! { + pub(crate) static ENSURED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script +/// event changed becomes a single new datasets stream. When the packet is already the stream +/// `reuse` (written earlier for the same event), that stream is replaced in place instead of +/// a new one being added. The stream written is in the report. +pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) -> Result { + // Runs of values (and instances) merge in one pass; a value set twice keeps the last. + let mut steps: Vec> = Vec::new(); + let mut removes: Vec> = Vec::new(); + let mut run: Vec = Vec::new(); + // Instances queued in this run, by parent and name: asking for more only queues the rest. + let mut queued: HashMap<(&DataPath, &str), usize> = HashMap::new(); + let (mut ensured, mut capped) = (0usize, false); + let flush = |run: &mut Vec, steps: &mut Vec>, removes: &mut Vec>| { + if !run.is_empty() { + let mut seen = std::collections::HashSet::new(); + let mut kept: Vec = std::mem::take(run).into_iter().rev().filter(|w| seen.insert((w.ensure, w.path.clone()))).collect(); + kept.reverse(); + steps.push(kept); + removes.push(None); + } + }; + for op in ops { + match op { + DataOp::Value { path, text } if !path.is_empty() => { + // An empty value clears an existing node and needs no new one. + run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false }); + } + DataOp::Instances { parent, name, count } => { + let done = queued.entry((parent, name.as_str())).or_insert(0); + while *done < (*count).min(MAX_INDEX) { + if ensured >= MAX_ENSURED { + capped = true; + break; + } + ensured += 1; + #[cfg(test)] + ENSURED.with(|n| n.set(n.get() + 1)); + let k = *done; + *done += 1; + let mut path = parent.clone(); + path.push((name.clone(), k)); + run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true }); + } + } + DataOp::Remove(path) if !path.is_empty() => { + flush(&mut run, &mut steps, &mut removes); + queued.clear(); + steps.push(Vec::new()); + removes.push(Some(path.clone())); + } + _ => {} + } + } + flush(&mut run, &mut steps, &mut removes); + let plan: Vec = steps + .iter() + .zip(&removes) + .map(|(w, r)| match r { + Some(p) => Op::Remove(p), + None => Op::Values(w), + }) + .collect(); + let mut written = rewrite(doc, &plan, reuse)?; + if capped { + written.warnings.push(format!("XFA data instances not written past {MAX_ENSURED}: the rows were added and removed too often")); + } + Ok(written) +} + +/// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream +/// `reuse` or already an unsaved overlay stream of `doc`, else as a new object. +fn put_stream(doc: &mut Document, old: Option<&Object>, reuse: Option, bytes: &[u8]) -> ObjRef { + let stream = Object::Stream(Stream::flate(Dict::new(), bytes)); + match old.and_then(Object::as_ref) { + Some(r) if reuse == Some(r) || doc.is_edited(r.num) => { + doc.set(r, stream); + r + } + _ => doc.add(stream), + } +} + +/// Longest packet text written (the packets together are read up to 64 MB). +pub(super) const MAX_PACKET_WRITE: usize = 48 << 20; + +pub(super) fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option) -> Result { + let mut report = DatasetsWrite::default(); + if ops.is_empty() { + return Ok(report); + } + let Some(root) = doc.root() else { return Ok(report) }; + let catalog = doc.get(root); + let Some(acro_obj) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).cloned() else { return Ok(report) }; + let acro_ref = acro_obj.as_ref(); + let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(report) }; + let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(report) }; + let warnings = &mut report.warnings; + match &*doc.resolve(&xfa) { + Object::Array(items) => { + let mut items = items.clone(); + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); + match at { + Some(i) => { + let Some(existing) = items.get(i + 1).cloned() else { return Ok(report) }; + let Some((text, encoding)) = packet_text(doc, &existing, warnings) else { return Ok(report) }; + let Some(t) = merge_all(&text, Within::Packet, ops, warnings) else { return Ok(report) }; + if !readable(&t, Within::Packet, warnings) { + return Ok(report); + } + let r = put_stream(doc, Some(&existing), reuse, &encode(&t, encoding)); + report.stream = Some(r); + if let Some(slot) = items.get_mut(i + 1) { + *slot = Object::Ref(r); + } + } + None => { + // No datasets packet yet: made from the first values, then the rest merged + // in; before the postamble, or last. + let Some(first) = ops.iter().position(|op| matches!(op, Op::Values(_))) else { return Ok(report) }; + let Some(Op::Values(wants)) = ops.get(first) else { return Ok(report) }; + let fresh = fresh_datasets(&build_children(wants, warnings)); + let text = merge_all(&fresh, Within::Packet, ops.get(first + 1..).unwrap_or_default(), warnings).unwrap_or(fresh); + if !readable(&text, Within::Packet, warnings) { + return Ok(report); + } + let r = doc.add(Object::Stream(Stream::flate(Dict::new(), text.as_bytes()))); + report.stream = Some(r); + let pos = items.iter().position(|n| n.as_string().is_some_and(|s| s.to_text() == "postamble")).unwrap_or(items.len()); + items.insert(pos, Object::Ref(r)); + items.insert(pos, Object::String(PdfString::text("datasets"))); + } + } + acro.set(b"XFA".to_vec(), Object::Array(items)); + } + Object::Stream(_) => { + let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) }; + let Some(new) = merge_all(&text, Within::Xdp, ops, warnings) else { return Ok(report) }; + if !readable(&new, Within::Xdp, warnings) { + return Ok(report); + } + let r = put_stream(doc, Some(&xfa), reuse, &encode(&new, encoding)); + report.stream = Some(r); + acro.set(b"XFA".to_vec(), Object::Ref(r)); + } + _ => return Ok(report), + } + match acro_ref { + Some(ar) => doc.set(ar, Object::Dict(acro)), + None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?, + } + report.written = true; + Ok(report) +} + +/// The datasets stream's object reference in `doc` (the `datasets` item in the `/XFA` array, or +/// the single `/XFA` stream), when `doc` has one. +pub(super) fn datasets_stream_ref(doc: &Document) -> Option { + let root = doc.root()?; + let catalog = doc.get(root); + let acro = catalog.as_dict()?.get(b"AcroForm").map(|a| doc.resolve(a))?; + let xfa = acro.as_dict()?.get(b"XFA")?; + match &*doc.resolve(xfa) { + Object::Array(items) => { + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len())?; + items.get(at + 1)?.as_ref() + } + Object::Stream(_) => xfa.as_ref(), + _ => None, + } +} From 3da49321f2ac00ee12531ce88461f19bf0c35fd8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:46:36 +0000 Subject: [PATCH 084/246] refactor(xfa): split layout.rs into layout/ New modules: items, helpers, pages, flow, measure, draw, field. --- crates/xfa/src/layout.rs | 1930 +----------------------------- crates/xfa/src/layout/common.rs | 211 ++++ crates/xfa/src/layout/draw.rs | 324 +++++ crates/xfa/src/layout/field.rs | 305 +++++ crates/xfa/src/layout/flow.rs | 310 +++++ crates/xfa/src/layout/items.rs | 145 +++ crates/xfa/src/layout/measure.rs | 366 ++++++ crates/xfa/src/layout/pages.rs | 280 +++++ 8 files changed, 1955 insertions(+), 1916 deletions(-) create mode 100644 crates/xfa/src/layout/common.rs create mode 100644 crates/xfa/src/layout/draw.rs create mode 100644 crates/xfa/src/layout/field.rs create mode 100644 crates/xfa/src/layout/flow.rs create mode 100644 crates/xfa/src/layout/items.rs create mode 100644 crates/xfa/src/layout/measure.rs create mode 100644 crates/xfa/src/layout/pages.rs diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 670d43791a..3d443980b0 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -20,6 +20,20 @@ use crate::model::{ }; use crate::text::{self, Block, Face, Span}; +mod draw; +mod field; +mod flow; +mod common; +mod items; +mod measure; +mod pages; + +use common::collect_values; +pub(crate) use common::date_pattern; +pub use common::formatted_value; +pub(crate) use items::UNDERLINE_DROP; +pub use items::{Action, BorderShape, Form, Item, Page, Rounded, Widget, WidgetBorder, WidgetKind}; + /// Most pages a form may lay out to. pub const MAX_PAGES: usize = 500; /// Most items (text spans, lines, widgets…) across all pages. @@ -40,150 +54,6 @@ const MAX_IMAGE_BYTES: usize = 256 << 20; /// Placeholder for the page count in text laid out before the count is known. const PAGE_COUNT_MARK: char = '\u{E000}'; -/// How far below its baseline an underline is drawn, in the text's size: an underline is the -/// level line pushed just before its span, this far below it (the PDF writer tells it apart from -/// a rule by this). -pub(crate) const UNDERLINE_DROP: f64 = 0.1; - -#[derive(Clone, Debug, PartialEq)] -pub enum Item { - Fill { - rect: Rect, - color: [f64; 3], - }, - Line { - from: (f64, f64), - to: (f64, f64), - width: f64, - color: [f64; 3], - dashed: bool, - }, - /// A box with rounded (or inverted) corners: its fill, and its edge drawn inside it. - Rounded(Box), - Text(Span), - Image { - rect: Rect, - data: Arc>, - content_type: String, - }, - Widget(Box), -} - -/// A box with rounded corners ([`Item::Rounded`]). -#[derive(Clone, Debug, PartialEq)] -pub struct Rounded { - pub rect: Rect, - /// Top-left, top-right, bottom-right and bottom-left radii, each at most half the box's - /// shorter side. - pub radii: [f64; 4], - /// Corners whose curve goes into the box rather than around it, in the same order. - pub inverted: [bool; 4], - pub fill: Option<[f64; 3]>, - /// The edge: its width, colour and whether it is dashed. - pub edge: Option<(f64, [f64; 3], bool)>, - /// A page break cut the box here: that side has no edge, and its corners are square. - pub open_top: bool, - pub open_bottom: bool, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum WidgetKind { - Text, - /// A text field whose characters are shown as asterisks. - Password, - /// A drop-down (`list_box` false) or list box: `options` are (saved value, shown text). - Choice { - options: Vec<(String, String)>, - list_box: bool, - multi: bool, - editable: bool, - }, - /// A signature field, to be signed in the app. - Signature, - /// An image field: a push button that picks a picture (the picture itself is page content). - Image, - /// A date field with its Acrobat format pattern (`yyyy-mm-dd`). - Date(String), - CheckBox { - on: String, - round: bool, - }, - Radio { - group: String, - on: String, - round: bool, - }, - Button { - caption: String, - }, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BorderShape { - None, - Full, - Underline, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct WidgetBorder { - pub color: Option<[f64; 3]>, - pub fill: Option<[f64; 3]>, - pub shape: BorderShape, - pub width: f64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Action { - Reset, - Print, - SaveAs, - Url(String), - /// Any other click script: run by the XFA scripting engine. - Script(String), -} - -/// An interactive field to create as an AcroForm widget. -#[derive(Clone, Debug, PartialEq)] -pub struct Widget { - pub rect: Rect, - pub kind: WidgetKind, - /// The PDF field name (unique in the form, no periods). - pub name: String, - /// The template's scripting object model path, for data round trips. - pub som: String, - pub tooltip: Option, - pub face: Face, - pub h_align: HAlign, - pub multiline: bool, - pub max_chars: Option, - pub read_only: bool, - pub border: WidgetBorder, - /// The field's value: saved (bound) values for choice lists, newline-separated when several. - pub value: Option, - /// The template's default value (what a reset restores); data values are not defaults. - pub default: Option, - pub action: Option, - /// Check boxes: the on and off values of the data; radio buttons: the on value. - pub items: Vec, - /// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`). - pub no_scroll: bool, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Page { - pub width: f64, - pub height: f64, - pub items: Vec, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Form { - pub pages: Vec, - pub fields: usize, - pub warnings: Vec, -} - /// Inherited text defaults and the SOM path of the enclosing containers. #[derive(Clone)] struct Ctx { @@ -358,1775 +228,3 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result thread_local! { pub(crate) static EMBED_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } - -fn collect_values(nodes: &[Node], out: &mut HashMap, depth: usize) { - if depth > MAX_DEPTH { - return; - } - for n in nodes { - match n { - Node::Field(f) => { - if let Some(id) = &f.common.id { - out.insert(id.clone(), f.value.plain().unwrap_or_default()); - } - } - Node::Subform(s) => { - collect_values(&s.children, out, depth + 1); - if let Some(ps) = &s.page_set { - for a in &ps.areas { - collect_values(&a.items, out, depth + 1); - } - } - } - Node::Area(a) => collect_values(&a.children, out, depth + 1), - Node::ExclGroup(_) | Node::Draw(_) => {} - } - } -} - -fn clamp_opt(v: f64, min: Option, max: Option) -> f64 { - let mut v = v; - if let Some(m) = min { - v = v.max(m); - } - if let Some(m) = max - && m > 0.0 - { - v = v.min(m); - } - v.max(0.0) -} - -/// Does `node` flow (may be split across pages) rather than being placed as one block? -fn splittable(node: &Node) -> bool { - matches!(node, Node::Subform(s) if matches!(s.layout, Layout::Tb | Layout::Table) && s.common.h.is_none()) -} - -/// Top and bottom of an item a page break must not cut (text, a picture, a field); `None` -/// for fills and lines, which are cut at the break. -fn solid_extent(item: &Item) -> Option<(f64, f64)> { - match item { - Item::Text(s) => Some((s.baseline - s.face.size * 0.8, s.baseline + s.face.size * 0.2)), - Item::Image { rect, .. } => Some((rect.y, rect.bottom())), - Item::Widget(w) => Some((w.rect.y, w.rect.bottom())), - Item::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => None, - } -} - -/// `item` as the part above `cut` and the part below: solid items go whole to the side their -/// top is on, fills and lines crossing the cut are cut in two. -fn divide(item: Item, cut: f64) -> (Option, Option) { - if let Some((top, _)) = solid_extent(&item) { - return if top < cut - 0.01 { (Some(item), None) } else { (None, Some(item)) }; - } - match item { - Item::Fill { rect, color } => { - if rect.bottom() <= cut + 0.01 { - (Some(Item::Fill { rect, color }), None) - } else if rect.y >= cut - 0.01 { - (None, Some(Item::Fill { rect, color })) - } else { - let above = Rect::new(rect.x, rect.y, rect.w, cut - rect.y); - let below = Rect::new(rect.x, cut, rect.w, rect.bottom() - cut); - (Some(Item::Fill { rect: above, color }), Some(Item::Fill { rect: below, color })) - } - } - Item::Line { from, to, width, color, dashed } => { - let (lo, hi) = if from.1 <= to.1 { (from, to) } else { (to, from) }; - if lo.1 >= cut { - (None, Some(Item::Line { from, to, width, color, dashed })) - } else if hi.1 <= cut { - (Some(Item::Line { from, to, width, color, dashed }), None) - } else { - // lo.1 < cut < hi.1, so the divisor is positive. - let t = (cut - lo.1) / (hi.1 - lo.1); - let mid = (lo.0 + (hi.0 - lo.0) * t, cut); - (Some(Item::Line { from: lo, to: mid, width, color, dashed }), Some(Item::Line { from: mid, to: hi, width, color, dashed })) - } - } - Item::Rounded(r) => { - if r.rect.bottom() <= cut + 0.01 { - (Some(Item::Rounded(r)), None) - } else if r.rect.y >= cut - 0.01 { - (None, Some(Item::Rounded(r))) - } else { - // Each part keeps the corners on its side, rounded no more than its own height - // allows, and is open where it was cut. - let [tl, tr, br, bl] = r.radii; - let above = Rect::new(r.rect.x, r.rect.y, r.rect.w, cut - r.rect.y); - let below = Rect::new(r.rect.x, cut, r.rect.w, r.rect.bottom() - cut); - let fit = |v: f64, h: f64| v.min(h.max(0.0)); - let upper = Rounded { rect: above, radii: [fit(tl, above.h), fit(tr, above.h), 0.0, 0.0], open_bottom: true, ..(*r).clone() }; - let lower = Rounded { rect: below, radii: [0.0, 0.0, fit(br, below.h), fit(bl, below.h)], open_top: true, ..*r }; - (Some(Item::Rounded(Box::new(upper))), Some(Item::Rounded(Box::new(lower)))) - } - } - other => (Some(other), None), - } -} - -/// `item` moved by (`dx`, `dy`). -fn moved(mut item: Item, dx: f64, dy: f64) -> Item { - match &mut item { - Item::Fill { rect, .. } | Item::Image { rect, .. } => { - rect.x += dx; - rect.y += dy; - } - Item::Rounded(r) => { - r.rect.x += dx; - r.rect.y += dy; - } - Item::Line { from, to, .. } => { - *from = (from.0 + dx, from.1 + dy); - *to = (to.0 + dx, to.1 + dy); - } - Item::Text(s) => { - s.x += dx; - s.baseline += dy; - } - Item::Widget(w) => { - w.rect.x += dx; - w.rect.y += dy; - } - } - item -} - -fn is_row(node: &Node) -> bool { - matches!(node, Node::Subform(s) if s.layout == Layout::Row) -} - -/// The Acrobat date pattern for an XFA picture clause such as `date{YYYY-MM-DD}`. -pub(crate) fn date_pattern(picture: Option<&str>) -> String { - picture.and_then(date_letters).unwrap_or_else(|| "yyyy-mm-dd".into()) -} - -/// What scripts read as a field's `formattedValue` when its display picture is `picture`: the -/// ISO date `raw` shown in a date picture (`date{YYYYMMDD}` turns `2026-10-20` into -/// `20261020`; of several alternatives, the first). `None` for other kinds of picture, for a -/// clause this can't read or show (month and weekday names) and for a value that isn't an ISO -/// date: the raw value stands then. -pub fn formatted_value(picture: &str, raw: &str) -> Option { - let first = picture.split('|').next()?.trim(); - let rest = first.strip_prefix("date")?; - if !rest.starts_with(['{', '(']) { - return None; - } - let pattern = date_pattern(Some(first)); - // A picture with parts this can't show yet (month or weekday names, `MMM` or `EEE`) would - // leave them as letters: the raw value stands then. - let shows = |t: &String| !t.chars().all(|c| c.is_ascii_alphabetic()) || matches!(t.as_str(), "yyyy" | "yy" | "mm" | "m" | "dd" | "d"); - if !crate::data::date_tokens(&pattern).iter().all(shows) { - return None; - } - // Only the ISO date's own shape (`2026-10-20`): `2026-1-2` is not one, and stands. - let b = raw.as_bytes(); - let iso = b.len() == 10 && b.iter().enumerate().all(|(i, c)| if i == 4 || i == 7 { *c == b'-' } else { c.is_ascii_digit() }); - if !iso { - return None; - } - let shown = iso_to_pattern(raw, &pattern); - (shown != raw).then_some(shown) -} - -/// The native PDF action a button's click script amounts to (a reset, print, save or link), -/// else the script itself to run; `None` without a click script. -fn button_action(scripts: &[Script]) -> Option { - let s = scripts.iter().find(|s| s.activity == "click")?; - let t = &s.text; - // Only a single-statement idiom becomes a native PDF action; anything else is run by the - // XFA scripting engine. - let one = t.trim().trim_end_matches(';'); - let single = !one.contains(';') && !one.contains('\n'); - if !single { - return (!t.trim().is_empty()).then(|| Action::Script(t.clone())); - } - if t.contains("resetData") { - Some(Action::Reset) - } else if t.contains("host.print") || t.contains("execMenuItem(\"Print\")") { - Some(Action::Print) - } else if t.contains("SaveAs") || t.contains("execMenuItem(\"Save\")") { - Some(Action::SaveAs) - } else if let Some(i) = t.find("launchURL(") { - let rest = &t[i + "launchURL(".len()..]; - let q = rest.find(['"', '\''])?; - let quote = rest[q..].chars().next()?; - let body = &rest[q + 1..]; - let end = body.find(quote)?; - Some(Action::Url(body[..end].to_string())) - } else if t.trim().is_empty() { - None - } else { - Some(Action::Script(t.clone())) - } -} - -/// A PDF field name: no periods (they separate hierarchy levels), no control characters. -fn clean_name(name: &str) -> String { - let s: String = name.chars().filter(|c| !c.is_control()).map(|c| if c == '.' { '_' } else { c }).collect(); - let s = s.trim().to_string(); - if s.is_empty() { "field".into() } else { s } -} - -impl Layouter<'_> { - fn warn_once(&mut self, msg: impl Into) { - let m = msg.into(); - if self.warned.insert(m.clone()) && self.warnings.len() < 100 { - self.warnings.push(m); - } - } - - fn page_no(&self) -> usize { - self.pages.len() - } - - /// The text an embedded field `id` shows, at most `max` bytes of a field value (the page - /// number and count are a few bytes; the text layout cuts them). - fn embed(&self, id: &str, max: usize) -> String { - let v = match self.tpl.page_roles.iter().find(|(i, _)| i == id).map(|(_, r)| *r) { - Some(PageRole::Number) => self.page_no().to_string(), - Some(PageRole::Count) => PAGE_COUNT_MARK.to_string(), - None => self.values.get(id).map(|v| text::prefix(v, max).to_string()).unwrap_or_default(), - }; - #[cfg(test)] - EMBED_COPIED.with(|c| c.set(c.get().saturating_add(v.len() as u64))); - v - } - - /// `TooLarge` once the measurement budget is spent (sizes measured after that are guesses). - fn check_measures(&self) -> Result<(), XfaError> { - if self.measures.get() > MAX_MEASURES { - return Err(XfaError::TooLarge("the form is nested too deeply to lay out".into())); - } - Ok(()) - } - - fn push(&mut self, item: Item) -> Result<(), XfaError> { - self.check_measures()?; - self.items += 1; - if self.items > MAX_ITEMS { - return Err(XfaError::TooLarge("the form has too many objects to lay out".into())); - } - if let Some(p) = self.pages.last_mut() { - p.items.push(item); - } - Ok(()) - } - - fn push_block(&mut self, b: Block) -> Result<(), XfaError> { - for s in b.spans { - if s.face.underline { - let w = s.face.width(&s.text); - let y = s.baseline + s.face.size * UNDERLINE_DROP; - self.push(Item::Line { from: (s.x, y), to: (s.x + w, y), width: (s.face.size * 0.06).max(0.3), color: s.face.color, dashed: false })?; - } - if !s.text.trim().is_empty() { - self.push(Item::Text(s))?; - } - } - Ok(()) - } - - // ───────────────────────────────────────────────────────────────────────── pages - - fn new_page(&mut self) -> Result<(), XfaError> { - let idx = - (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); - self.new_page_of(idx) - } - - /// Once the body has been laid out, give every page area that has been used fewer times than - /// its `occur min` the pages it still needs, in template order, after the body's pages. - fn satisfy_page_area_minimums(&mut self) -> Result<(), XfaError> { - let areas = Rc::clone(&self.areas); - for (idx, area) in areas.iter().enumerate() { - while self.uses.get(idx).is_some_and(|u| *u < area.occur_min) { - self.new_page_of(idx)?; - } - } - Ok(()) - } - - /// Start a page from page area `idx`. - fn new_page_of(&mut self, idx: usize) -> Result<(), XfaError> { - if self.pages.len() >= MAX_PAGES { - return Err(XfaError::TooLarge(format!("the form lays out to more than {MAX_PAGES} pages"))); - } - let areas = Rc::clone(&self.areas); - let Some(area) = areas.get(idx) else { - return Err(XfaError::Malformed("the template has no page area".into())); - }; - if let Some(u) = self.uses.get_mut(idx) { - *u += 1; - } - self.pages.push(Page { width: area.width, height: area.height, items: Vec::new() }); - let content = area.content.first().copied().unwrap_or(Rect::new(0.0, 0.0, area.width, area.height)); - self.cur = Some(Cursor { area: idx, content: 0, rect: content, y: content.y, placed: false }); - // Master page content: positioned draws and fields. - let ctx = Ctx { font: Font::default(), para: Para::default(), som: area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_default() }; - let page_rect = Rect::new(0.0, 0.0, area.width, area.height); - let sibs = SomIndices::of(&area.items); - for (i, item) in area.items.iter().enumerate() { - self.draw_positioned_child(item, page_rect, &ctx, 1, sibs.at(i))?; - } - Ok(()) - } - - /// Move to the next content area, or the next page. - fn next_area(&mut self) -> Result<(), XfaError> { - if let Some(c) = &self.cur - && let Some(area) = self.areas.get(c.area) - && let Some(next) = area.content.get(c.content + 1).copied() - { - let i = c.content + 1; - self.cur = Some(Cursor { area: c.area, content: i, rect: next, y: next.y, placed: false }); - return Ok(()); - } - self.new_page() - } - - /// The current content area, how far down it the flow is, and whether anything has been - /// placed in it; before the first page, an empty letter-size page from its top. - fn cursor(&self) -> (Rect, f64, bool) { - match &self.cur { - Some(c) => (c.rect, c.y, c.placed), - None => (Rect::new(0.0, 0.0, 612.0, 792.0), 0.0, false), - } - } - - /// Where the flow is now ([`FlowMark`]); `None` before the first page. - fn flow_mark(&self) -> Option { - let c = self.cur.as_ref()?; - Some(FlowMark { page: self.pages.len(), area: c.area, content: c.content, y: c.y, items: self.pages.last().map_or(0, |p| p.items.len()) }) - } - - /// Whether the flow is still in the content area `m` was taken in. - fn on_area(&self, m: &FlowMark) -> bool { - self.pages.len() == m.page && self.cur.as_ref().is_some_and(|c| c.area == m.area && c.content == m.content) - } - - /// Whether page `page` (an index) got anything from item `at` on. - fn has_items_from(&self, page: usize, at: usize) -> bool { - self.pages.get(page).is_some_and(|p| p.items.len() > at) - } - - /// Break the flowed subforms being laid out ([`Self::frames`]) across content areas, as - /// another viewer breaks such a section on a synthetic form: each one's part here ends after - /// its content and its bottom margin (the innermost first, each inside the next), the flow - /// moves to the next content area, and each starts a new part there after its top margin - /// (the outermost first). The top margins take only what the `need` points of what comes next - /// leave of the new area: a child that fits a fresh area but not after the margins would - /// otherwise run past its bottom, out of its border. `to_page` moves to a new page instead (a - /// child's own page break), which breaks the subforms around it the same way. - fn break_frames(&mut self, need: f64, to_page: bool) -> Result<(), XfaError> { - let (rect, mut y, _) = self.cursor(); - let page = self.pages.len().saturating_sub(1); - let here: Vec = self.frames.iter().map(|f| self.on_area(&f.part)).collect(); - let filled: Vec = self.frames.iter().map(|f| self.has_items_from(page, f.part.items)).collect(); - for ((f, here), filled) in self.frames.iter_mut().zip(here).zip(filled).rev() { - y = (y + f.margin.bottom).min(rect.bottom()); - if !here { - f.lost = true; - } else if filled && y - f.part.y >= 0.01 { - f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, y - f.part.y))); - } - } - if to_page { - self.new_page()?; - } else { - self.next_area()?; - } - let (rect, y, _) = self.cursor(); - let mut room = (rect.bottom() - y - need).max(0.0); - for i in 0..self.frames.len() { - let Some(mark) = self.flow_mark() else { break }; - let Some(f) = self.frames.get_mut(i) else { break }; - f.part = mark; - // `max` before `min`: a NaN margin counts as none and leaves `room` a number. - let top = f.margin.top.max(0.0).min(room); - room = (room - top).max(0.0); - self.advance(top); - } - Ok(()) - } - - /// Close flowed subform `f` once its content is placed: its last part ends where the flow - /// is, and its border (edges and fill) is drawn round each part, closed on every side, under - /// that part's content. One holding something that didn't fit its content area ([`Frame::lost`]) - /// is drawn without its border, and the layout says so. - fn finish_frame(&mut self, mut f: Frame) -> Result<(), XfaError> { - let Some(b) = f.border.take() else { return Ok(()) }; - if b.fill().is_none() && b.visible_edges() == [false; 4] { - return Ok(()); - } - let (area, end, _) = self.cursor(); - // The bottom margin stays in the content area, as at a break; content that itself ran - // past it has already cost the section its border ([`Self::overflowed`]). - let end = end.min(area.bottom()).max(end - f.margin.bottom); - let page = self.pages.len().saturating_sub(1); - if !self.on_area(&f.part) { - f.lost = true; - } else if self.has_items_from(page, f.part.items) && end - f.part.y >= 0.01 { - f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, end - f.part.y))); - } - if f.lost { - self.warn_once("a bordered section holding an item that doesn't fit on its page is drawn without its border"); - return Ok(()); - } - // The last part first: a part's place on its page stays where it was found. - for (page, at, rect) in f.parts.into_iter().rev() { - self.insert_border(&b, rect, page, at)?; - } - Ok(()) - } - - /// Draw border `b` round `rect` on page `page` (an index), under what that page got from item - /// `at` on. - fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> { - let before = self.pages.last().map_or(0, |p| p.items.len()); - self.draw_border(b, rect)?; - let drawn: Vec = match self.pages.last_mut() { - Some(p) => { - let from = before.min(p.items.len()); - p.items.drain(from..).collect() - } - None => return Ok(()), - }; - if let Some(target) = self.pages.get_mut(page) { - let at = at.min(target.items.len()); - target.items.splice(at..at, drawn); - } - Ok(()) - } - - /// Move the cursor down (margins and spacing: the area still counts as empty). - fn advance(&mut self, dy: f64) { - if let Some(c) = &mut self.cur { - c.y += dy.max(0.0); - } - } - - /// Move past something that was placed. - fn place(&mut self, dy: f64) { - if let Some(c) = &mut self.cur { - c.y += dy.max(0.0); - c.placed = true; - } - let (_, y, _) = self.cursor(); - self.overflowed(y); - } - - /// Something was placed down to `bottom`: past the content area's bottom, the flowed subforms - /// it is inside can't hold it in a part, and are drawn without their border. - fn overflowed(&mut self, bottom: f64) { - let (rect, _, _) = self.cursor(); - if bottom > rect.bottom() + 0.01 { - for f in &mut self.frames { - f.lost = true; - } - } - } - - /// Make room for `h`, with `after` more drawn before it should it move (a table's overflow - /// leader): stay, or move to the next content area / page. Returns whether a break happened. - /// Something that fits a fresh area breaks the flowed subforms it is in with it - /// ([`Self::break_frames`]), a table's rows included; one taller than an area just moves on, - /// and those subforms are drawn without their border ([`Self::finish_frame`]). - fn make_room(&mut self, h: f64, after: f64) -> Result { - let (rect, y, placed) = self.cursor(); - if y + h <= rect.bottom() + 0.01 || !placed { - return Ok(false); - } - if h <= rect.h + 0.01 { - self.break_frames(h + after, false)?; - } else { - self.next_area()?; - } - Ok(true) - } - - /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, - /// in the next when it fits there, and across as many as it takes when it is taller than a - /// content area (a positioned or fixed-height subform holding more than a page). - fn flow_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { - let (rect, y, _) = self.cursor(); - if y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { - self.make_room(h, 0.0)?; - } - let (rect, y, _) = self.cursor(); - let first = self.pages.last().map_or(0, |p| p.items.len()); - self.leaves.clear(); - self.draw_node(node, Rect::new(x, y, w, h), ctx, depth, sib)?; - let page_h = self.pages.last().map_or(f64::INFINITY, |p| p.height); - // What was drawn, when it is lower than the block's own height says (content that - // outgrows a fixed height) and ends off the page. - let drawn = self - .pages - .last() - .and_then(|p| p.items.get(first..)) - .unwrap_or_default() - .iter() - .filter_map(solid_extent) - .map(|e| e.1) - .fold(y + h, f64::max); - if y + h <= rect.bottom() + 0.01 && drawn <= page_h + 0.01 { - self.leaves.clear(); - self.place(h); - return Ok(()); - } - self.split_block(first, y, drawn) - } - - /// Remember that the items the last page got since `start` are one field or draw at - /// `rect`, to be kept together by a page break. - fn keep_whole(&mut self, start: usize, rect: Rect) { - let end = self.pages.last().map_or(0, |p| p.items.len()); - if end > start { - self.leaves.push((start, end, rect.y, rect.bottom())); - } - } - - fn item_count(&self) -> usize { - self.pages.last().map_or(0, |p| p.items.len()) - } - - /// Spread the items the last page holds from index `first` on (a block drawn from `top` to - /// `end`, past the bottom of its content area) over this and the following content areas. - /// Breaks fall between text, pictures and fields, never through one that fits in an area; - /// fills and lines are cut at the break. - fn split_block(&mut self, first: usize, top: f64, end: f64) -> Result<(), XfaError> { - let items: Vec = match self.pages.last_mut() { - Some(p) if first <= p.items.len() => p.items.split_off(first), - _ => return Ok(()), - }; - // Each item with the extent of the field or draw it belongs to: such a leaf moves - // whole (its border with its text), unless it is taller than a content area. - let area_h = self.cursor().0.h; - let mut extents: Vec> = vec![None; items.len()]; - for (start, end, top, bottom) in std::mem::take(&mut self.leaves) { - if bottom - top <= area_h + 0.01 - && let Some(slots) = start.checked_sub(first).and_then(|s| extents.get_mut(s..end.saturating_sub(first))) - { - slots.fill(Some((top, bottom))); - } - } - let mut rest: Vec<(Item, Option<(f64, f64)>)> = items.into_iter().zip(extents).collect(); - let origin_x = self.cursor().0.x; - // An item's place on the current page is its drawn place moved by (dx, -shift). - let (mut dx, mut shift) = (0.0, 0.0); - let mut seg_top = top; - let mut first_segment = true; - loop { - let (rect, _, placed) = self.cursor(); - let limit = rect.bottom() + shift; - let mut cut = limit; - if rect.h < 1.0 { - // No room to split into: leave the rest here. - cut = f64::INFINITY; - } else { - // The lowest break at or above the bottom that crosses no solid item: going - // through them from the lowest top up, a break moved to an item's top can't - // cross one already passed. - let mut solid: Vec<(f64, f64)> = - rest.iter().filter_map(|(item, kept)| kept.or_else(|| solid_extent(item))).filter(|e| e.0 < limit - 0.01).collect(); - solid.sort_by(|a, b| b.0.total_cmp(&a.0)); - for (t, b) in solid { - if t < cut - 0.01 && b > cut + 0.01 { - cut = t; - } - } - if cut <= seg_top + 0.01 { - // An item taller than what is left. Start it in a fresh area if this one - // already holds something; otherwise cut through it. - cut = if first_segment && placed && seg_top > rect.y + 0.01 { seg_top } else { limit }; - } - } - let mut here = Vec::new(); - let mut below = Vec::new(); - for (item, kept) in rest { - if let Some((top, _)) = kept { - if top < cut - 0.01 { - here.push(item) - } else { - below.push((item, kept)) - } - continue; - } - match divide(item, cut) { - (Some(a), None) => here.push(a), - (None, Some(b)) => below.push((b, None)), - (Some(a), Some(b)) => { - here.push(a); - below.push((b, None)); - } - (None, None) => {} - } - } - if let Some(p) = self.pages.last_mut() { - p.items.extend(here.into_iter().map(|it| moved(it, dx, -shift))); - } - if below.is_empty() { - if let Some(c) = &mut self.cur { - c.y = (end - shift).min(c.rect.bottom()).max(c.y); - c.placed = true; - } - self.overflowed(end - shift); - return Ok(()); - } - rest = below; - seg_top = cut; - first_segment = false; - self.next_area()?; - let (rect, _, _) = self.cursor(); - shift = cut - rect.y; - dx = rect.x - origin_x; - } - } - - fn page_has_flow(&self) -> bool { - self.cur.as_ref().is_some_and(|c| c.placed || c.content > 0) - } - - // ───────────────────────────────────────────────────────────────────────── flow - - /// Flow `node` (a subform; anything else is ignored) into the content areas. - fn flow_subform(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { - if depth > MAX_DEPTH { - return Ok(()); - } - let Node::Subform(sf) = node else { return Ok(()) }; - self.check_measures()?; - if sf.break_before_page && self.page_has_flow() { - self.break_frames(0.0, true)?; - } - // Data adds instances of a repeating subform (rows added in another viewer). - let instances = self.instances(node, &ctx.som); - self.warn_truncated(node, &ctx.som, instances); - let ctx_parent = ctx; - for i in 0..instances { - // Every instance spends budget, so nested repeats that place nothing still stop. - self.measures.set(self.measures.get().saturating_add(1)); - self.check_measures()?; - let ctx = ctx_parent.with_som(&sf.common, sib + i); - let (rect, _, _) = self.cursor(); - let (x, w) = avail.unwrap_or((rect.x, rect.w)); - let w = clamp_opt(sf.common.w.unwrap_or(w), sf.common.min_w, sf.common.max_w).min(w.max(1.0)); - if !matches!(sf.layout, Layout::Tb | Layout::Table) || sf.common.h.is_some() { - // Not splittable: one block. - let (_, h) = self.size_of(node, w, ctx_parent, depth); - self.flow_block(node, x, (w, h), ctx_parent, depth, sib + i)?; - continue; - } - let m = sf.common.margin; - // Its border and fill go round what it takes from here, a part on each content area. - let framed = match self.flow_mark() { - Some(part) => { - self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false }); - true - } - None => false, - }; - self.advance(m.top); - let inner_x = x + m.left; - let inner_w = (w - m.horizontal()).max(1.0); - match sf.layout { - Layout::Table => self.flow_table(sf, inner_x, inner_w, &ctx, depth)?, - _ => { - let sibs = SomIndices::of(&sf.children); - for (ci, child) in sf.children.iter().enumerate() { - if !child.common().presence.occupies() { - continue; - } - let csib = sibs.at(ci); - if let Node::Subform(_) = child - && splittable(child) - { - self.flow_subform(child, Some((inner_x, inner_w)), &ctx, depth + 1, csib)?; - continue; - } - let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1); - self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?; - } - } - } - self.advance(m.bottom); - if framed && let Some(frame) = self.frames.pop() { - self.finish_frame(frame)?; - } - } - if sf.break_after_page { - self.break_frames(0.0, true)?; - } - Ok(()) - } - - fn columns(&self, sf: &Subform, inner_w: f64) -> Vec { - if !sf.column_widths.is_empty() { - return sf.column_widths.clone(); - } - let cells = sf.children.iter().filter_map(|c| if let Node::Subform(r) = c { Some(r.children.len()) } else { None }).max().unwrap_or(1).max(1); - vec![inner_w / cells as f64; cells] - } - - fn flow_table(&mut self, sf: &Subform, x: f64, inner_w: f64, ctx: &Ctx, depth: usize) -> Result<(), XfaError> { - let cols = self.columns(sf, inner_w); - let leader = - sf.overflow_leader.as_ref().and_then(|name| sf.children.iter().position(|c| c.common().name.as_deref() == Some(name) && is_row(c))); - // The overflow leader starts each part after the first, before the row that moved. - let lead = leader.and_then(|li| match sf.children.get(li) { - Some(Node::Subform(l)) => Some((li, l.as_ref())), - _ => None, - }); - let lead_h = lead.map_or(0.0, |(_, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1)); - let sibs = SomIndices::of(&sf.children); - for (ci, child) in sf.children.iter().enumerate() { - if !child.common().presence.occupies() { - continue; - } - let csib = sibs.at(ci); - if let Node::Subform(row) = child - && row.layout == Layout::Row - { - // Rows added as data (in another viewer) repeat the row. - let instances = self.instances(child, &ctx.som); - self.warn_truncated(child, &ctx.som, instances); - for inst in 0..instances { - let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); - let led = lead.filter(|(li, _)| *li != ci); - if self.make_room(h, led.map_or(0.0, |_| lead_h))? - && let Some((li, l)) = led - { - let (_, y, _) = self.cursor(); - self.draw_row(l, &cols, Rect::new(x, y, inner_w, lead_h), ctx, depth + 1, sibs.at(li))?; - self.place(lead_h); - } - let (_, y, _) = self.cursor(); - self.draw_row(row, &cols, Rect::new(x, y, inner_w, h), ctx, depth + 1, csib + inst)?; - self.place(h); - } - continue; - } - if splittable(child) - && let Node::Subform(_) = child - { - self.flow_subform(child, Some((x, inner_w)), ctx, depth + 1, csib)?; - continue; - } - let (cw, ch) = self.size_of(child, inner_w, ctx, depth + 1); - self.flow_block(child, x, (cw, ch), ctx, depth + 1, csib)?; - } - Ok(()) - } - - // ───────────────────────────────────────────────────────────────────────── sizing - - /// The size a node takes when given `avail_w` of width. Containers are measured once per - /// (node, width, depth): measuring a width-less container measures its content twice (for - /// its width, then its height), which would otherwise be exponential in the nesting. - /// How many instances of `node` to lay out under the container at `parent_som`: a - /// repeating subform's `occur` and data instances ([`instance_count`]); one for anything else. - fn instances(&self, node: &Node, parent_som: &str) -> usize { - let Node::Subform(sf) = node else { return 1 }; - let from_data = match (&sf.common.name, self.data) { - (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), - _ => 0, - }; - instance_count(&sf.occur, from_data) - } - - /// The data holds more instances of `node` than `laid_out`: say so, since calculations - /// then see only those. - fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) { - let Node::Subform(sf) = node else { return }; - let from_data = match (&sf.common.name, self.data) { - (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), - _ => 0, - }; - if from_data > laid_out { - self.warn_once(format!( - "{}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those)", - sf.common.name.as_deref().unwrap_or("a subform") - )); - } - } - - /// The width and height `node` takes in `avail_w`: its own size, else what its content needs - /// (containers are measured once per width and depth, and measuring stops at its caps). - fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { - let c = node.common(); - let spent = self.measures.get().saturating_add(1); - self.measures.set(spent); - if depth > MAX_DEPTH || spent > MAX_MEASURES { - return (c.w.unwrap_or(avail_w), c.h.unwrap_or(0.0)); - } - // Only containers recurse; their addresses are stable for the whole layout (the - // template is borrowed, the root and page areas are held by the layouter). - let key = match node { - Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth)), - Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth)), - _ => None, - }; - if let Some(key) = key - && let Ok(cache) = self.measured.try_borrow() - && let Some(m) = cache.get(&key) - && m.font == ctx.font - && m.para == ctx.para - { - return m.size; - } - let size = self.measure(node, avail_w, ctx, depth); - if let Some(key) = key - && let Ok(mut cache) = self.measured.try_borrow_mut() - { - cache.insert(key, Measured { font: ctx.font.clone(), para: ctx.para.clone(), size }); - } - size - } - - /// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_of`] caches it). - /// Without a width, a container, a text draw or a button is as wide as its content (at most - /// `avail_w`) and anything else takes `avail_w`; without a height, it is as tall as its content. - fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { - let c = node.common(); - // The node's own SOM: its repeating children count their instances under it, and a - // growing text field finds its value at it. Measurements are cached per template node, - // so instance 0 stands for all of them. - let ctx = ctx.with_som(c, 0); - let som = ctx.som.clone(); - // A container without a width is as wide as its content (a radio group beside its - // question, say), and so is a text label or a button (as wide as its caption), within - // what is available; any other field without one takes what is available. - let w = match c.w { - Some(w) => w, - None => match node { - Node::Subform(_) | Node::ExclGroup(_) | Node::Area(_) => { - let inner = (avail_w - c.margin.horizontal()).max(1.0); - (self.content_width(node, inner, &ctx, depth) + c.margin.horizontal()).min(avail_w) - } - Node::Draw(d) => self.natural_width(&d.value, &ctx.font, &ctx.para).map_or(avail_w, |t| (t + c.margin.horizontal()).min(avail_w)), - Node::Field(f) if f.ui == Ui::Button => f - .caption - .as_ref() - .and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para))) - .map_or(avail_w, |t| (t + c.margin.horizontal() + f.ui_margin.horizontal()).min(avail_w)), - Node::Field(_) => avail_w, - }, - }; - let w = clamp_opt(w, c.min_w, c.max_w); - let h = match c.h { - Some(h) => h, - None => { - let inner_w = (w - c.margin.horizontal()).max(1.0); - let content = match node { - Node::Draw(d) => self.value_height(&d.value, inner_w, &ctx), - Node::Field(f) => self.field_height(f, inner_w, &ctx), - Node::ExclGroup(g) => self.group_height(g, inner_w, &ctx, depth), - Node::Area(a) => self.positioned_height(&a.children, inner_w, &ctx, depth), - Node::Subform(s) => match s.layout { - Layout::Positioned => self.positioned_height(&s.children, inner_w, &ctx, depth), - // Each instance of a repeating child takes its height again. - Layout::Tb => s - .children - .iter() - .filter(|k| k.common().presence.occupies()) - .map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1 * self.instances(k, &som) as f64) - .sum(), - Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, &ctx, depth, &som).1, - Layout::Table => { - let cols = self.columns(s, inner_w); - s.children - .iter() - .filter(|k| k.common().presence.occupies()) - .map(|k| match k { - Node::Subform(r) if r.layout == Layout::Row => self.row_height(r, &cols, inner_w, &ctx, depth + 1), - _ => self.size_of(k, inner_w, &ctx, depth + 1).1, - }) - .sum() - } - Layout::Row => self.row_height(s, &self.columns(s, inner_w), inner_w, &ctx, depth + 1), - }, - }; - content + c.margin.vertical() - } - }; - // A flowed subform is as tall as what it holds: other viewers do not stretch one to - // the `minH` a designer left on it. - let min_h = match node { - Node::Subform(s) if c.h.is_none() && s.layout != Layout::Positioned => None, - _ => c.min_h, - }; - (w, clamp_opt(h, min_h, c.max_h)) - } - - /// The natural width of a container's content within `avail_w`. - fn content_width(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> f64 { - let group: Vec; - let (children, layout): (&[Node], Layout) = match node { - Node::Subform(s) => { - if s.layout == Layout::Table && !s.column_widths.is_empty() { - return s.column_widths.iter().sum::().min(avail_w); - } - (&s.children, s.layout) - } - Node::ExclGroup(g) => { - // Fields are leaves: measuring these copies caches nothing. - group = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); - (&group, g.layout) - } - Node::Area(a) => (&a.children, Layout::Positioned), - _ => return avail_w, - }; - let visible = children.iter().filter(|k| k.common().presence.occupies()); - match layout { - Layout::LrTb | Layout::RlTb | Layout::Row => { - let sum: f64 = visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).sum(); - if sum <= avail_w + 0.05 { sum } else { avail_w } - } - Layout::Positioned => visible - .map(|k| { - let (w, h) = self.size_of(k, avail_w, ctx, depth + 1); - k.common().top_left(w, h).0 + w - }) - .fold(0.0, f64::max) - .min(avail_w), - Layout::Tb | Layout::Table => visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).fold(0.0, f64::max).min(avail_w), - } - } - - /// How wide text `v` is unwrapped, in `font` and `para`, with a hair to spare so that laying - /// it out at that width keeps it on its lines; `None` for what isn't text, or text that draws - /// nothing. - fn natural_width(&self, v: &Value, font: &Font, para: &Para) -> Option { - let rich = match v { - Value::Text(t) => text::plain(t), - Value::Rich(r) => r.clone(), - _ => return None, - }; - // No text (a bordered spacer or rule): nothing to size to, so it keeps what it has room for. - let w = text::natural_width(&rich, para, &Face::of(font), &|id, max| self.embed(id, max)); - (w > 0.0).then_some(w + 0.01) - } - - /// How tall value `v` is laid out `w` wide: text wrapped in the context's font and paragraph, - /// an image at its aspect ratio, and nothing for lines, rectangles and empty values. - fn value_height(&self, v: &Value, w: f64, ctx: &Ctx) -> f64 { - match v { - Value::Text(t) => text::measure_height(&text::plain(t), w, &ctx.para, &Face::of(&ctx.font), &|id, max| self.embed(id, max)), - Value::Rich(r) => text::measure_height(r, w, &ctx.para, &Face::of(&ctx.font), &|id, max| self.embed(id, max)), - Value::Image { data, .. } => crate::image::size(data).map_or(w, |(iw, ih)| if iw > 0 { w * ih as f64 / iw as f64 } else { w }), - Value::Line { .. } | Value::Rectangle(_) | Value::Empty => 0.0, - } - } - - /// Where field `f`'s caption goes and how much room it takes from the field's box, or `None` - /// when it has no caption that takes room (a button's is drawn inside its box). - fn caption_reserve(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> Option<(Placement, f64)> { - let cap = f.caption.as_ref()?; - if !cap.presence.occupies() || f.ui == Ui::Button { - return None; - } - if cap.value.is_empty() && cap.reserve.is_none() { - return None; - } - // A check box has no text for an inline caption to run with: it is not shown. - if f.ui == Ui::CheckButton && cap.placement == Placement::Inline { - return None; - } - let placement = if cap.placement == Placement::Inline { Placement::Left } else { cap.placement }; - let font = ctx.font.apply(&cap.font); - let para = ctx.para.apply(&cap.para); - let reserve = cap.reserve.unwrap_or_else(|| match placement { - Placement::Top | Placement::Bottom => { - let rich = match &cap.value { - Value::Rich(r) => r.clone(), - other => text::plain(&other.plain().unwrap_or_default()), - }; - text::measure_height(&rich, inner_w, ¶, &Face::of(&font), &|id, max| self.embed(id, max)) - } - _ => (Face::of(&font).width(&cap.value.plain().unwrap_or_default()) + 2.0).min(inner_w * 0.6), - }); - Some((placement, reserve)) - } - - /// The height of field `f`'s content within `inner_w` (inside its margins): its widget (a check - /// box, a button's caption, or one line of text or three when multi-line, more for a text field - /// that grows) plus a caption above or below it, or the taller of the two for one beside it. - fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { - let font = ctx.font.apply(&f.common.font); - let lines = if f.multiline { 3.0 } else { 1.0 }; - let ui = match f.ui { - Ui::CheckButton => f.check_size.unwrap_or(10.0).max(text::line_height(font.size)), - // A button shows its caption: as tall as the caption's text in its own font. - Ui::Button if f.caption.as_ref().is_some_and(|c| !c.value.is_empty()) => { - let cap = f.caption.as_ref().map_or(0.0, |c| { - let rich = match &c.value { - Value::Rich(r) => r.clone(), - other => text::plain(&other.plain().unwrap_or_default()), - }; - let w = (inner_w - f.ui_margin.horizontal()).max(1.0); - text::measure_height(&rich, w, &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| self.embed(id, max)) - }); - cap + f.ui_margin.vertical() - } - _ => (text::line_height(font.size) * lines).max(self.grown_text_height(f, inner_w, ctx)) + 4.0 + f.ui_margin.vertical(), - }; - match self.caption_reserve(f, inner_w, ctx) { - Some((Placement::Top | Placement::Bottom, r)) => ui + r, - Some((placement, r)) => { - // A check box's caption without a reserve is drawn in all the box leaves. - let w = match auto_check_reserve(f, placement, inner_w, None) { - Some(auto) => auto, - None => inner_w - r, - }; - let cap = f.caption.as_ref().map(|c| { - let rich = match &c.value { - Value::Rich(r) => r.clone(), - other => text::plain(&other.plain().unwrap_or_default()), - }; - text::measure_height(&rich, w.max(1.0), &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| { - self.embed(id, max) - }) - }); - ui.max(cap.unwrap_or(0.0)) - } - None => ui, - } - } - - /// The face a field's text is laid out in: at its horizontal scale when the layout draws - /// the text itself (a non-interactive field), as designed when the forms layer draws it in - /// a widget, whose appearances have no horizontal scaling. - fn field_text_face(f: &Field, font: &Font) -> Face { - let face = Face::of(font); - if f.access == Access::NonInteractive { face } else { face.unscaled() } - } - - /// The height the text of a multi-line text field takes (its value in the data, else the - /// template's), for a field without a fixed height, which grows to hold its text. `ctx` - /// is the field's own (its SOM ends in the field). Zero for anything else. - fn grown_text_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { - if !f.grows_with_text() || f.common.name.is_none() { - return 0.0; - } - let value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &ctx.som))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); - let Some(value) = value.filter(|t| !t.trim().is_empty()) else { return 0.0 }; - // Beside a caption the text has what the caption leaves; a widget's text has 2pt of - // padding on each side, a non-interactive field's (drawn as text) none, so it wraps - // where it is drawn. - let beside = match self.caption_reserve(f, inner_w, ctx) { - Some((Placement::Top | Placement::Bottom, _)) | None => 0.0, - Some((_, r)) => r, - }; - let padding = if f.access == Access::NonInteractive { 0.0 } else { 4.0 }; - let w = (inner_w - beside - f.ui_margin.horizontal() - padding).max(1.0); - let font = ctx.font.apply(&f.common.font); - text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Self::field_text_face(f, &font), &|_, _| String::new()) - } - - /// The height of an exclusion group's fields at `inner_w`, by the group's layout: stacked - /// top to bottom, at their own positions, or packed in rows. - fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { - let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); - match g.layout { - Layout::Tb => nodes.iter().filter(|k| k.common().presence.occupies()).map(|k| self.size_of(k, inner_w, ctx, depth + 1).1).sum(), - Layout::Positioned => self.positioned_height(&nodes, inner_w, ctx, depth), - _ => self.packed_height(&nodes, inner_w, ctx, depth), - } - } - - fn positioned_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { - children - .iter() - .filter(|k| k.common().presence.occupies()) - .map(|k| { - let c = k.common(); - let (w, h) = self.size_of(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); - c.top_left(w, h).1 + h - }) - .fold(0.0, f64::max) - } - - /// Left-to-right packing: rows of children, wrapped at the available width, as (child, - /// instance, place). A repeating subform is packed once per instance it has under the - /// container at `som`. - fn pack_instances(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize, som: &str) -> (Vec<(usize, usize, Rect)>, f64) { - let mut out = Vec::new(); - let (mut x, mut y, mut line_h) = (0.0, 0.0, 0.0); - for (i, k) in children.iter().enumerate() { - if !k.common().presence.occupies() { - continue; - } - let (w, h) = self.size_of(k, inner_w, ctx, depth + 1); - for instance in 0..self.instances(k, som) { - if x > 0.0 && x + w > inner_w + 0.05 { - y += line_h; - x = 0.0; - line_h = 0.0; - } - out.push((i, instance, Rect::new(x, y, w, h))); - x += w; - line_h = line_h.max(h); - } - } - (out, y + line_h) - } - - /// [`Self::pack_instances`] for children that don't repeat (the fields of a group). - fn pack(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { - let (placed, h) = self.pack_instances(children, inner_w, ctx, depth, ""); - (placed.into_iter().map(|(i, _, r)| (i, r)).collect(), h) - } - - fn packed_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { - self.pack(children, inner_w, ctx, depth).1 - } - - /// Cell rectangles of a row (relative to the row's inner box) and the row height. - fn cells(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { - let ctx = ctx.child(&row.common); - let mut out = Vec::new(); - let mut col = 0usize; - let mut x = 0.0; - let mut h: f64 = 0.0; - for (i, k) in row.children.iter().enumerate() { - if !k.common().presence.occupies() { - continue; - } - let span = k.common().col_span.max(1); - let w = if col < cols.len() { cols.iter().skip(col).take(span).sum::() } else { k.common().w.unwrap_or((inner_w - x).max(1.0)) }; - let (_, ch) = self.size_of(k, w, &ctx, depth + 1); - out.push((i, Rect::new(x, 0.0, w, ch))); - h = h.max(ch); - x += w; - col += span; - } - let h = match row.common.h { - Some(rh) => rh, - None => h + row.common.margin.vertical(), - }; - (out, clamp_opt(h, row.common.min_h, row.common.max_h)) - } - - fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { - self.cells(row, cols, inner_w, ctx, depth).1 - } - - // ───────────────────────────────────────────────────────────────────────── drawing - - fn draw_row(&mut self, row: &Subform, cols: &[f64], rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { - if depth > MAX_DEPTH { - return Ok(()); - } - if let Some(b) = &row.common.border { - self.draw_border(b, rect)?; - } - let ctx = ctx.with_som(&row.common, sib); - let inner = rect.inset(&row.common.margin); - let (cells, _) = self.cells(row, cols, inner.w, &ctx, depth); - let sibs = SomIndices::of(&row.children); - for (i, r) in cells { - if let Some(k) = row.children.get(i) { - // Cells stretch to the row height. - self.draw_node(k, Rect::new(inner.x + r.x, inner.y, r.w, inner.h), &ctx, depth + 1, sibs.at(i))?; - } - } - Ok(()) - } - - fn draw_positioned_child(&mut self, k: &Node, inner: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { - let c = k.common(); - if !c.presence.occupies() { - return Ok(()); - } - let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); - let (x, y) = c.top_left(w, h); - self.draw_node(k, Rect::new(inner.x + x, inner.y + y, w, h), ctx, depth + 1, sib) - } - - fn draw_node(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { - if depth > MAX_DEPTH { - return Ok(()); - } - let c = node.common(); - if c.presence == Presence::Invisible { - return Ok(()); - } - match node { - Node::Draw(d) => { - let start = self.item_count(); - self.draw_draw(d, rect, ctx)?; - // Text and pictures stay with their border; a bare line or rectangle is cut. - if matches!(d.value, Value::Text(_) | Value::Rich(_) | Value::Image { .. }) { - self.keep_whole(start, rect); - } - Ok(()) - } - Node::Field(f) => { - let start = self.item_count(); - self.draw_field(f, rect, ctx, None, sib)?; - self.keep_whole(start, rect); - Ok(()) - } - Node::ExclGroup(g) => self.draw_group(g, rect, ctx, depth, sib), - Node::Area(a) => { - let ctx = ctx.with_som(&a.common, sib); - let sibs = SomIndices::of(&a.children); - for (i, k) in a.children.iter().enumerate() { - self.draw_positioned_child(k, rect, &ctx, depth + 1, sibs.at(i))?; - } - Ok(()) - } - Node::Subform(s) => { - // Every drawn instance spends budget, as a flowed one does, so nested repeats that - // place nothing still stop. - self.measures.set(self.measures.get().saturating_add(1)); - self.check_measures()?; - if let Some(b) = &s.common.border { - self.draw_border(b, rect)?; - } - let parent_ctx = ctx; - let ctx = ctx.with_som(&s.common, sib); - let inner = rect.inset(&s.common.margin); - let sibs = SomIndices::of(&s.children); - match s.layout { - Layout::Positioned => { - for (i, k) in s.children.iter().enumerate() { - self.draw_positioned_child(k, inner, &ctx, depth + 1, sibs.at(i))?; - } - } - Layout::Tb => { - let mut y = inner.y; - for (i, k) in s.children.iter().enumerate() { - if !k.common().presence.occupies() { - continue; - } - let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); - // A repeating subform here (a table in an area of a positioned page, - // say) gets its instances, as in a flowed one, one under another. - for instance in 0..self.instances(k, &ctx.som) { - self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sibs.at(i) + instance)?; - y += h; - } - } - } - Layout::LrTb | Layout::RlTb => { - // A repeating subform here (rows of fields under a heading) gets its - // instances, as in the other layouts. - let (placed, _) = self.pack_instances(&s.children, inner.w, &ctx, depth, &ctx.som); - for (i, instance, r) in placed { - if let Some(k) = s.children.get(i) { - let x = if s.layout == Layout::RlTb { inner.right() - r.x - r.w } else { inner.x + r.x }; - self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &ctx, depth + 1, sibs.at(i) + instance)?; - } - } - } - Layout::Table => { - let cols = self.columns(s, inner.w); - let mut y = inner.y; - for (i, k) in s.children.iter().enumerate() { - if !k.common().presence.occupies() { - continue; - } - let csib = sibs.at(i); - if let Node::Subform(r) = k - && r.layout == Layout::Row - { - let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1); - self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib)?; - y += h; - } else { - let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); - self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, csib)?; - y += h; - } - } - } - Layout::Row => { - let cols = self.columns(s, inner.w); - self.draw_row(s, &cols, rect, parent_ctx, depth, sib)?; - } - } - Ok(()) - } - } - } - - /// Draw border `b` around `rect`: its fill, then each shown edge as a line inside the box, or - /// the whole box as one rounded shape when its corners are rounded. - fn draw_border(&mut self, b: &Border, rect: Rect) -> Result<(), XfaError> { - let vis = b.visible_edges(); - // Rounded corners: the box is one shape, filled and edged along its curves, when its - // edges are all alike (all shown and the same, or all hidden). Edges that differ keep - // square corners. - let radii = b.radii(rect.w, rect.h); - let alike = b.edges.iter().all(|e| *e == b.edges[0]); - if radii.iter().any(|r| *r > 0.0) && (vis == [true; 4] && alike || vis == [false; 4]) { - let e = b.edges[0]; - let edge = vis[0].then(|| { - let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 }; - (e.thickness.clamp(0.1, 20.0), color, matches!(e.stroke, Stroke::Dashed | Stroke::Dotted)) - }); - let shape = Rounded { - rect, - radii, - inverted: b.corners.map(|c| c.inverted), - fill: b.fill().map(|f| f.0), - edge, - open_top: false, - open_bottom: false, - }; - return self.push(Item::Rounded(Box::new(shape))); - } - if let Some(fill) = b.fill() { - self.push(Item::Fill { rect, color: fill.0 })?; - } - let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); - // Top, right, bottom, left; lines sit inside the box. - let segs = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x0, y1), (x1, y1)), ((x0, y0), (x0, y1))]; - for (i, seg) in segs.iter().enumerate() { - if !vis[i] { - continue; - } - let e = b.edges[i]; - let t = e.thickness.clamp(0.1, 20.0); - let (dx, dy) = match i { - 0 => (0.0, t / 2.0), - 1 => (-t / 2.0, 0.0), - 2 => (0.0, -t / 2.0), - _ => (t / 2.0, 0.0), - }; - let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 }; - self.push(Item::Line { - from: (seg.0.0 + dx, seg.0.1 + dy), - to: (seg.1.0 + dx, seg.1.1 + dy), - width: t, - color, - dashed: matches!(e.stroke, Stroke::Dashed | Stroke::Dotted), - })?; - } - Ok(()) - } - - fn draw_value_text(&mut self, v: &Value, rect: Rect, font: &Font, para: &Para) -> Result<(), XfaError> { - let rich = match v { - Value::Text(t) => text::plain(t), - Value::Rich(r) => r.clone(), - _ => return Ok(()), - }; - let face = Face::of(font); - // Text that doesn't fit the height it was given (the template's face is narrower than - // the standard one drawn in its place, so it wraps to more lines) is drawn smaller, - // down to 70%, rather than over what lies below. - let fits = |k: f64| text::measure_height(&rich, rect.w / k, para, &face, &|id, max| self.embed(id, max)) <= rect.h / k + 1.0; - let scale = if rect.h < 4.0 || fits(1.0) { 1.0 } else { [0.95, 0.9, 0.85, 0.8, 0.75].into_iter().find(|k| fits(*k)).unwrap_or(0.7) }; - let mut block = text::layout(&rich, Rect::new(rect.x, rect.y, rect.w / scale, rect.h / scale), para, &face, &|id, max| self.embed(id, max)); - if scale < 1.0 { - for s in &mut block.spans { - s.x = rect.x + (s.x - rect.x) * scale; - s.baseline = rect.y + (s.baseline - rect.y) * scale; - s.face.size *= scale; - } - } - self.push_block(block) - } - - fn draw_draw(&mut self, d: &Draw, rect: Rect, ctx: &Ctx) -> Result<(), XfaError> { - if let Some(b) = &d.common.border { - self.draw_border(b, rect)?; - } - let ctx = ctx.child(&d.common); - let inner = rect.inset(&d.common.margin); - match &d.value { - Value::Text(_) | Value::Rich(_) => self.draw_value_text(&d.value, inner, &ctx.font, &ctx.para), - Value::Image { content_type, data } => self.draw_image(data, content_type, inner), - Value::Line { slope_up, edge } => { - if !edge.visible() { - return Ok(()); - } - let (from, to) = if *slope_up { - ((inner.x, inner.bottom()), (inner.right(), inner.y)) - } else { - ((inner.x, inner.y), (inner.right(), inner.bottom())) - }; - self.push(Item::Line { from, to, width: edge.thickness.clamp(0.1, 20.0), color: edge.color.0, dashed: edge.stroke == Stroke::Dashed }) - } - Value::Rectangle(b) => self.draw_border(b, inner), - Value::Empty => Ok(()), - } - } - - fn draw_group(&mut self, g: &ExclGroup, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { - if let Some(b) = &g.common.border { - self.draw_border(b, rect)?; - } - let ctx = ctx.with_som(&g.common, sib); - // The group's data node holds the selected button's on value. - let selected: Option = - self.data.and_then(|d| d.text_at(&data_path(self.tpl, &ctx.som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); - let inner = rect.inset(&g.common.margin); - let group = self.unique_name(g.common.name.as_deref().unwrap_or("group")); - let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); - let placed: Vec<(usize, Rect)> = match g.layout { - Layout::Tb => { - let mut y = 0.0; - let mut out = Vec::new(); - for (i, k) in nodes.iter().enumerate() { - if !k.common().presence.occupies() { - continue; - } - let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); - out.push((i, Rect::new(0.0, y, w, h))); - y += h; - } - out - } - Layout::Positioned => nodes - .iter() - .enumerate() - .filter(|(_, k)| k.common().presence.occupies()) - .map(|(i, k)| { - let c = k.common(); - let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1); - let (x, y) = c.top_left(w, h); - (i, Rect::new(x, y, w, h)) - }) - .collect(), - _ => self.pack(&nodes, inner.w, &ctx, depth).0, - }; - let sibs = SomIndices::of(&nodes); - for (i, r) in placed { - if let Some(f) = g.fields.get(i) { - let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| (i + 1).to_string()); - let tip = f.tooltip.clone().or_else(|| g.tooltip.clone()); - let chosen = selected.as_deref() == Some(on.as_str()); - self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sibs.at(i))?; - } - } - Ok(()) - } - - /// A picture scaled to fit `inner`, keeping its shape, from its top left. - fn draw_image(&mut self, data: &[u8], content_type: &str, inner: Rect) -> Result<(), XfaError> { - self.image_bytes = self.image_bytes.saturating_add(data.len()); - if self.image_bytes > MAX_IMAGE_BYTES { - return Err(XfaError::TooLarge("the form's pictures are too large together to lay out".into())); - } - let fitted = match crate::image::size(data) { - Some((iw, ih)) if iw > 0 && ih > 0 && inner.w > 0.0 && inner.h > 0.0 => { - let k = (inner.w / iw as f64).min(inner.h / ih as f64); - Rect::new(inner.x, inner.y, iw as f64 * k, ih as f64 * k) - } - _ => inner, - }; - self.push(Item::Image { rect: fitted, data: Arc::new(data.to_vec()), content_type: content_type.to_string() }) - } - - /// A field name made from `base` that no earlier field of the form has: the first keeps it, - /// later ones get `_2`, `_3`, …. - fn unique_name(&mut self, base: &str) -> String { - let base = clean_name(base); - let n = self.names.entry(base.clone()).or_insert(0); - *n += 1; - if *n == 1 { base } else { format!("{base}_{n}") } - } - - /// Draw field `f` at `rect`: its border (a button's is its widget's box), its caption, then its - /// widget, or its value as plain text when it is non-interactive, a barcode, or a text field - /// taller than its content area. `radio` is set for a group's radio button; `sib` is its SOM index. - fn draw_field( - &mut self, - f: &Field, - rect: Rect, - ctx: &Ctx, - radio: Option<(&str, &str, Option, bool)>, - sib: usize, - ) -> Result<(), XfaError> { - if let Some(b) = &f.common.border - && f.ui != Ui::Button - { - self.draw_border(b, rect)?; - } - let fctx = ctx.child(&f.common); - let inner = rect.inset(&f.common.margin); - let (mut ui, mut cap_rect) = (inner, None); - if let Some((placement, reserve)) = self.caption_reserve(f, inner.w, ctx) { - let reserve = auto_check_reserve(f, placement, inner.w, Some(inner.h)).unwrap_or(reserve); - let r = reserve.max(0.0); - match placement { - Placement::Top => { - cap_rect = Some(Rect::new(inner.x, inner.y, inner.w, r.min(inner.h))); - ui = Rect::new(inner.x, inner.y + r, inner.w, inner.h - r); - } - Placement::Bottom => { - cap_rect = Some(Rect::new(inner.x, inner.bottom() - r.min(inner.h), inner.w, r.min(inner.h))); - ui = Rect::new(inner.x, inner.y, inner.w, inner.h - r); - } - Placement::Right => { - cap_rect = Some(Rect::new(inner.right() - r.min(inner.w), inner.y, r.min(inner.w), inner.h)); - ui = Rect::new(inner.x, inner.y, inner.w - r, inner.h); - } - _ => { - cap_rect = Some(Rect::new(inner.x, inner.y, r.min(inner.w), inner.h)); - ui = Rect::new(inner.x + r, inner.y, inner.w - r, inner.h); - } - } - } - if let (Some(cr), Some(cap)) = (cap_rect, &f.caption) - && cap.presence == Presence::Visible - { - let font = ctx.font.apply(&cap.font); - let para = ctx.para.apply(&cap.para); - self.draw_value_text(&cap.value, cr, &font, ¶)?; - } - let som_path = || { - if fctx.som.is_empty() { - format!("{}[{sib}]", f.common.name.as_deref().unwrap_or("field")) - } else { - format!("{}.{}[{sib}]", fctx.som, f.common.name.as_deref().unwrap_or("field")) - } - }; - match f.ui { - Ui::Unknown => { - self.warn_once("a field of an unknown kind is left blank"); - return Ok(()); - } - Ui::ImageEdit => { - // The picture the data holds (base64, as Designer saves it) or the template's. - let field_name = f.common.name.clone().unwrap_or_else(|| "an image field".into()); - let text: Vec = self - .data - .and_then(|d| d.text_at(&data_path(self.tpl, &som_path()))) - .map(|t| t.bytes().filter(|b| !b.is_ascii_whitespace()).collect()) - .unwrap_or_default(); - let from_data = if text.is_empty() { - None - } else if text.len() > MAX_DATA_IMAGE / 3 * 4 + 4 { - self.warn_once(format!("{field_name}: the picture in the data is larger than 32 MB and is left out")); - None - } else { - match base64::engine::general_purpose::STANDARD.decode(&text) { - Ok(d) if crate::image::kind(&d).is_some() => Some(d), - Ok(_) => { - self.warn_once(format!("{field_name}: the picture in the data is not a JPEG, PNG, GIF, BMP or TIFF and is left out")); - None - } - Err(_) => { - self.warn_once(format!("{field_name}: the data holds no readable picture (not base64) and is left out")); - None - } - } - }; - let box_ = ui.inset(&f.ui_margin); - match (&from_data, &f.value) { - (Some(d), _) => self.draw_image(d, "", box_)?, - (None, Value::Image { content_type, data }) => self.draw_image(data, content_type, box_)?, - _ => {} - } - // Then the field itself, which clicking gives a new picture. - } - Ui::Barcode => { - // The value as text, where the bars would be. - self.warn_once("barcode fields show their value as text, not as bars"); - let value = self - .data - .and_then(|d| d.text_at(&data_path(self.tpl, &som_path()))) - .map(str::to_string) - .or_else(|| f.value.plain()) - .unwrap_or_default(); - if !value.trim().is_empty() { - let mut para = fctx.para.clone(); - para.h_align = HAlign::Center; - self.draw_value_text(&Value::Text(value), ui.inset(&f.ui_margin), &fctx.font, ¶)?; - } - return Ok(()); - } - _ => {} - } - if f.access == Access::NonInteractive && f.ui != Ui::Button { - // Shown as text, not as a field. - return self.draw_value_text(&f.value, ui.inset(&f.ui_margin), &fctx.font, &fctx.para); - } - // A text field that grew past what a content area holds can't be one widget: its text - // is drawn as lines the page breaks fall between, and stays what the data holds. - if f.grows_with_text() && rect.h > self.cursor().0.h + 0.01 { - let value = - self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som_path()))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); - if let Some(value) = value.filter(|t| !t.trim().is_empty()) { - let name = f.common.name.clone().unwrap_or_else(|| "a text field".into()); - self.warn_once(format!("{name}: its text is longer than a page; it is shown across pages and can't be edited here")); - let m = Margin { - top: f.ui_margin.top + 2.0, - bottom: f.ui_margin.bottom + 2.0, - left: f.ui_margin.left + 2.0, - right: f.ui_margin.right + 2.0, - }; - let para = Para { v_align: VAlign::Top, ..fctx.para.clone() }; - // In the face it was measured in, so it takes the lines it was given. - let face = Self::field_text_face(f, &fctx.font); - let block = text::layout(&text::plain(value.trim_end()), ui.inset(&m), ¶, &face, &|_, _| String::new()); - return self.push_block(block); - } - } - let face = match (&f.ui, &f.caption) { - // Buttons show their caption in the caption's font. - (Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)).unscaled(), - _ => Face::of(&fctx.font).unscaled(), - }; - let h_align = match (&f.ui, &f.caption) { - (Ui::Button, Some(cap)) => ctx.para.apply(&cap.para).h_align, - _ => fctx.para.h_align, - }; - let name = self.unique_name(f.common.name.as_deref().unwrap_or("field")); - let som = som_path(); - let read_only = matches!(f.access, Access::ReadOnly | Access::Protected); - // The data's value wins over the template's default. - let data_value: Option = - self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); - // A choice list's default is a saved value too. - let template_default = f.value.plain().map(|v| f.saved_value(v.trim())).filter(|v| !v.is_empty()); - let plain_value = data_value.clone().or_else(|| template_default.clone()); - let (kind, rect, border, value, tooltip) = match f.ui { - Ui::CheckButton => { - let s = f.check_size.unwrap_or(10.0).min(ui.w.max(1.0)).min(ui.h.max(1.0)); - // Without a caption the box sits at the right of the content area, as if a - // caption on its left took the rest, so the paragraph's alignment has no room to - // act on; beside a caption that alignment places it. The box's own margin doesn't - // move it. - let x = match (cap_rect, fctx.para.h_align) { - (None, _) | (Some(_), HAlign::Right) => ui.right() - s, - (Some(_), HAlign::Center) => ui.x + (ui.w - s) / 2.0, - (Some(_), HAlign::Left | HAlign::Justify) => ui.x, - }; - let y = match fctx.para.v_align { - VAlign::Top => ui.y, - VAlign::Middle => ui.y + (ui.h - s) / 2.0, - VAlign::Bottom => ui.bottom() - s, - }; - let r = Rect::new(x, y, s, s); - let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "1".into()); - let (kind, tip, checked) = match radio { - Some((group, on_value, tip, chosen)) => ( - WidgetKind::Radio { group: group.to_string(), on: on_value.to_string(), round: f.check_round }, - tip.or_else(|| f.tooltip.clone()), - chosen, - ), - None => ( - WidgetKind::CheckBox { on: on.clone(), round: f.check_round }, - f.tooltip.clone(), - plain_value.as_deref().is_some_and(|v| is_on(v, &on)), - ), - }; - let border = widget_border(f.ui_border.as_ref(), true); - (kind, r, border, checked.then(|| on.clone()), tip) - } - Ui::Button => { - let caption = f.caption.as_ref().and_then(|c| c.value.plain()).unwrap_or_default().trim().to_string(); - // A button's box is its field border, around the whole field (its margins pad - // the caption, not the box); without one it is invisible (a link over text, say). - let border = match &f.common.border { - Some(b) => widget_border(Some(b), false), - None => WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }, - }; - (WidgetKind::Button { caption }, rect, border, None, f.tooltip.clone()) - } - Ui::DateTimeEdit => { - let pattern = date_pattern(f.picture.as_deref()); - // The data holds the date in the field's data picture, or else as an ISO date; the - // field shows it in its own pattern. - let data_pattern = data_date_letters(f); - let shown = plain_value - .as_ref() - .map(|v| if data_value.is_some() { iso_to_pattern(&data_to_iso(v, data_pattern.as_deref()), &pattern) } else { v.clone() }); - (WidgetKind::Date(pattern), ui, widget_border(f.ui_border.as_ref(), false), shown, f.tooltip.clone()) - } - Ui::ChoiceList => { - let options: Vec<(String, String)> = f - .items - .iter() - .enumerate() - .map(|(i, shown)| (f.item_values.get(i).cloned().unwrap_or_else(|| shown.clone()), shown.clone())) - .collect(); - // The data holds saved values (several on separate lines); shown text is taken - // too, as a user typing into an editable list leaves it. - let value = plain_value.as_ref().map(|v| f.saved_value(v)); - let kind = WidgetKind::Choice { options, list_box: f.choice.list_box, multi: f.choice.multi, editable: f.choice.editable }; - (kind, ui, widget_border(f.ui_border.as_ref(), false), value, f.tooltip.clone()) - } - Ui::Signature => (WidgetKind::Signature, ui, widget_border(f.ui_border.as_ref(), false), None, f.tooltip.clone()), - Ui::ImageEdit => { - let none = WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }; - (WidgetKind::Image, ui, none, None, f.tooltip.clone()) - } - Ui::PasswordEdit => (WidgetKind::Password, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()), - _ => (WidgetKind::Text, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()), - }; - if rect.w < 1.0 || rect.h < 1.0 { - return Ok(()); - } - let action = if f.ui == Ui::Button { button_action(&f.scripts) } else { None }; - let items: Vec = match &kind { - WidgetKind::CheckBox { on, .. } => vec![on.clone(), f.items.get(1).cloned().unwrap_or_default()], - WidgetKind::Radio { on, .. } => vec![on.clone()], - _ => Vec::new(), - }; - self.fields += 1; - self.push(Item::Widget(Box::new(Widget { - rect, - kind, - name, - som, - tooltip, - face, - h_align, - multiline: f.multiline, - max_chars: f.max_chars, - read_only, - border, - value, - default: template_default, - action, - items, - no_scroll: f.no_scroll, - }))) - } -} - -/// The room a caption without a reserve takes beside a check box: all the box leaves of a -/// content area `w` wide (and `h` tall, when known), the box at the size it can be drawn there. -/// `None` for any other field, or a caption with a reserve of its own. -fn auto_check_reserve(f: &Field, placement: Placement, w: f64, h: Option) -> Option { - if f.ui != Ui::CheckButton || f.caption.as_ref().is_none_or(|c| c.reserve.is_some()) { - return None; - } - let s = f.check_size.unwrap_or(10.0).min(w.max(1.0)).min(h.map_or(f64::INFINITY, |h| h.max(1.0))); - let rest = match (placement, h) { - (Placement::Top | Placement::Bottom, Some(h)) => h - s, - (Placement::Top | Placement::Bottom, None) => return None, - _ => w - s, - }; - Some(rest.max(0.0)) -} - -/// The widget's own border from the `` border element. Without one, a check box gets a -/// solid box, as Designer draws it, and any other field has no box of its own. -fn widget_border(b: Option<&Border>, check: bool) -> WidgetBorder { - let Some(b) = b else { - if !check { - return WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }; - } - return WidgetBorder { color: Some([0.0, 0.0, 0.0]), fill: Some([1.0, 1.0, 1.0]), shape: BorderShape::Full, width: 0.5 }; - }; - let vis = b.visible_edges(); - let shape = match vis { - [false, false, false, false] => BorderShape::None, - [false, false, true, false] => BorderShape::Underline, - _ => BorderShape::Full, - }; - let edge = (0..4).find(|&i| vis[i]).map(|i| b.edges[i]).unwrap_or_default(); - WidgetBorder { - color: (shape != BorderShape::None).then_some(edge.color.0), - fill: b.fill().map(|c| c.0), - shape, - width: edge.thickness.clamp(0.1, 12.0), - } -} diff --git a/crates/xfa/src/layout/common.rs b/crates/xfa/src/layout/common.rs new file mode 100644 index 0000000000..3b60f8a6a0 --- /dev/null +++ b/crates/xfa/src/layout/common.rs @@ -0,0 +1,211 @@ +use super::items::{Action, Item, Rounded}; +use super::*; + +pub(super) fn collect_values(nodes: &[Node], out: &mut HashMap, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for n in nodes { + match n { + Node::Field(f) => { + if let Some(id) = &f.common.id { + out.insert(id.clone(), f.value.plain().unwrap_or_default()); + } + } + Node::Subform(s) => { + collect_values(&s.children, out, depth + 1); + if let Some(ps) = &s.page_set { + for a in &ps.areas { + collect_values(&a.items, out, depth + 1); + } + } + } + Node::Area(a) => collect_values(&a.children, out, depth + 1), + Node::ExclGroup(_) | Node::Draw(_) => {} + } + } +} + +pub(super) fn clamp_opt(v: f64, min: Option, max: Option) -> f64 { + let mut v = v; + if let Some(m) = min { + v = v.max(m); + } + if let Some(m) = max + && m > 0.0 + { + v = v.min(m); + } + v.max(0.0) +} + +/// Does `node` flow (may be split across pages) rather than being placed as one block? +pub(super) fn splittable(node: &Node) -> bool { + matches!(node, Node::Subform(s) if matches!(s.layout, Layout::Tb | Layout::Table) && s.common.h.is_none()) +} + +/// Top and bottom of an item a page break must not cut (text, a picture, a field); `None` +/// for fills and lines, which are cut at the break. +pub(super) fn solid_extent(item: &Item) -> Option<(f64, f64)> { + match item { + Item::Text(s) => Some((s.baseline - s.face.size * 0.8, s.baseline + s.face.size * 0.2)), + Item::Image { rect, .. } => Some((rect.y, rect.bottom())), + Item::Widget(w) => Some((w.rect.y, w.rect.bottom())), + Item::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => None, + } +} + +/// `item` as the part above `cut` and the part below: solid items go whole to the side their +/// top is on, fills and lines crossing the cut are cut in two. +pub(super) fn divide(item: Item, cut: f64) -> (Option, Option) { + if let Some((top, _)) = solid_extent(&item) { + return if top < cut - 0.01 { (Some(item), None) } else { (None, Some(item)) }; + } + match item { + Item::Fill { rect, color } => { + if rect.bottom() <= cut + 0.01 { + (Some(Item::Fill { rect, color }), None) + } else if rect.y >= cut - 0.01 { + (None, Some(Item::Fill { rect, color })) + } else { + let above = Rect::new(rect.x, rect.y, rect.w, cut - rect.y); + let below = Rect::new(rect.x, cut, rect.w, rect.bottom() - cut); + (Some(Item::Fill { rect: above, color }), Some(Item::Fill { rect: below, color })) + } + } + Item::Line { from, to, width, color, dashed } => { + let (lo, hi) = if from.1 <= to.1 { (from, to) } else { (to, from) }; + if lo.1 >= cut { + (None, Some(Item::Line { from, to, width, color, dashed })) + } else if hi.1 <= cut { + (Some(Item::Line { from, to, width, color, dashed }), None) + } else { + // lo.1 < cut < hi.1, so the divisor is positive. + let t = (cut - lo.1) / (hi.1 - lo.1); + let mid = (lo.0 + (hi.0 - lo.0) * t, cut); + (Some(Item::Line { from: lo, to: mid, width, color, dashed }), Some(Item::Line { from: mid, to: hi, width, color, dashed })) + } + } + Item::Rounded(r) => { + if r.rect.bottom() <= cut + 0.01 { + (Some(Item::Rounded(r)), None) + } else if r.rect.y >= cut - 0.01 { + (None, Some(Item::Rounded(r))) + } else { + // Each part keeps the corners on its side, rounded no more than its own height + // allows, and is open where it was cut. + let [tl, tr, br, bl] = r.radii; + let above = Rect::new(r.rect.x, r.rect.y, r.rect.w, cut - r.rect.y); + let below = Rect::new(r.rect.x, cut, r.rect.w, r.rect.bottom() - cut); + let fit = |v: f64, h: f64| v.min(h.max(0.0)); + let upper = Rounded { rect: above, radii: [fit(tl, above.h), fit(tr, above.h), 0.0, 0.0], open_bottom: true, ..(*r).clone() }; + let lower = Rounded { rect: below, radii: [0.0, 0.0, fit(br, below.h), fit(bl, below.h)], open_top: true, ..*r }; + (Some(Item::Rounded(Box::new(upper))), Some(Item::Rounded(Box::new(lower)))) + } + } + other => (Some(other), None), + } +} + +/// `item` moved by (`dx`, `dy`). +pub(super) fn moved(mut item: Item, dx: f64, dy: f64) -> Item { + match &mut item { + Item::Fill { rect, .. } | Item::Image { rect, .. } => { + rect.x += dx; + rect.y += dy; + } + Item::Rounded(r) => { + r.rect.x += dx; + r.rect.y += dy; + } + Item::Line { from, to, .. } => { + *from = (from.0 + dx, from.1 + dy); + *to = (to.0 + dx, to.1 + dy); + } + Item::Text(s) => { + s.x += dx; + s.baseline += dy; + } + Item::Widget(w) => { + w.rect.x += dx; + w.rect.y += dy; + } + } + item +} + +pub(super) fn is_row(node: &Node) -> bool { + matches!(node, Node::Subform(s) if s.layout == Layout::Row) +} + +/// The Acrobat date pattern for an XFA picture clause such as `date{YYYY-MM-DD}`. +pub(crate) fn date_pattern(picture: Option<&str>) -> String { + picture.and_then(date_letters).unwrap_or_else(|| "yyyy-mm-dd".into()) +} + +/// What scripts read as a field's `formattedValue` when its display picture is `picture`: the +/// ISO date `raw` shown in a date picture (`date{YYYYMMDD}` turns `2026-10-20` into +/// `20261020`; of several alternatives, the first). `None` for other kinds of picture, for a +/// clause this can't read or show (month and weekday names) and for a value that isn't an ISO +/// date: the raw value stands then. +pub fn formatted_value(picture: &str, raw: &str) -> Option { + let first = picture.split('|').next()?.trim(); + let rest = first.strip_prefix("date")?; + if !rest.starts_with(['{', '(']) { + return None; + } + let pattern = date_pattern(Some(first)); + // A picture with parts this can't show yet (month or weekday names, `MMM` or `EEE`) would + // leave them as letters: the raw value stands then. + let shows = |t: &String| !t.chars().all(|c| c.is_ascii_alphabetic()) || matches!(t.as_str(), "yyyy" | "yy" | "mm" | "m" | "dd" | "d"); + if !crate::data::date_tokens(&pattern).iter().all(shows) { + return None; + } + // Only the ISO date's own shape (`2026-10-20`): `2026-1-2` is not one, and stands. + let b = raw.as_bytes(); + let iso = b.len() == 10 && b.iter().enumerate().all(|(i, c)| if i == 4 || i == 7 { *c == b'-' } else { c.is_ascii_digit() }); + if !iso { + return None; + } + let shown = iso_to_pattern(raw, &pattern); + (shown != raw).then_some(shown) +} + +/// The native PDF action a button's click script amounts to (a reset, print, save or link), +/// else the script itself to run; `None` without a click script. +pub(super) fn button_action(scripts: &[Script]) -> Option { + let s = scripts.iter().find(|s| s.activity == "click")?; + let t = &s.text; + // Only a single-statement idiom becomes a native PDF action; anything else is run by the + // XFA scripting engine. + let one = t.trim().trim_end_matches(';'); + let single = !one.contains(';') && !one.contains('\n'); + if !single { + return (!t.trim().is_empty()).then(|| Action::Script(t.clone())); + } + if t.contains("resetData") { + Some(Action::Reset) + } else if t.contains("host.print") || t.contains("execMenuItem(\"Print\")") { + Some(Action::Print) + } else if t.contains("SaveAs") || t.contains("execMenuItem(\"Save\")") { + Some(Action::SaveAs) + } else if let Some(i) = t.find("launchURL(") { + let rest = &t[i + "launchURL(".len()..]; + let q = rest.find(['"', '\''])?; + let quote = rest[q..].chars().next()?; + let body = &rest[q + 1..]; + let end = body.find(quote)?; + Some(Action::Url(body[..end].to_string())) + } else if t.trim().is_empty() { + None + } else { + Some(Action::Script(t.clone())) + } +} + +/// A PDF field name: no periods (they separate hierarchy levels), no control characters. +pub(super) fn clean_name(name: &str) -> String { + let s: String = name.chars().filter(|c| !c.is_control()).map(|c| if c == '.' { '_' } else { c }).collect(); + let s = s.trim().to_string(); + if s.is_empty() { "field".into() } else { s } +} diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs new file mode 100644 index 0000000000..4f318a73d6 --- /dev/null +++ b/crates/xfa/src/layout/draw.rs @@ -0,0 +1,324 @@ +use super::common::clean_name; +use super::items::{Item, Rounded}; +use super::*; + +impl Layouter<'_> { + // ───────────────────────────────────────────────────────────────────────── drawing + + pub(super) fn draw_row(&mut self, row: &Subform, cols: &[f64], rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + if depth > MAX_DEPTH { + return Ok(()); + } + if let Some(b) = &row.common.border { + self.draw_border(b, rect)?; + } + let ctx = ctx.with_som(&row.common, sib); + let inner = rect.inset(&row.common.margin); + let (cells, _) = self.cells(row, cols, inner.w, &ctx, depth); + let sibs = SomIndices::of(&row.children); + for (i, r) in cells { + if let Some(k) = row.children.get(i) { + // Cells stretch to the row height. + self.draw_node(k, Rect::new(inner.x + r.x, inner.y, r.w, inner.h), &ctx, depth + 1, sibs.at(i))?; + } + } + Ok(()) + } + + pub(super) fn draw_positioned_child(&mut self, k: &Node, inner: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + let c = k.common(); + if !c.presence.occupies() { + return Ok(()); + } + let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); + let (x, y) = c.top_left(w, h); + self.draw_node(k, Rect::new(inner.x + x, inner.y + y, w, h), ctx, depth + 1, sib) + } + + pub(super) fn draw_node(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + if depth > MAX_DEPTH { + return Ok(()); + } + let c = node.common(); + if c.presence == Presence::Invisible { + return Ok(()); + } + match node { + Node::Draw(d) => { + let start = self.item_count(); + self.draw_draw(d, rect, ctx)?; + // Text and pictures stay with their border; a bare line or rectangle is cut. + if matches!(d.value, Value::Text(_) | Value::Rich(_) | Value::Image { .. }) { + self.keep_whole(start, rect); + } + Ok(()) + } + Node::Field(f) => { + let start = self.item_count(); + self.draw_field(f, rect, ctx, None, sib)?; + self.keep_whole(start, rect); + Ok(()) + } + Node::ExclGroup(g) => self.draw_group(g, rect, ctx, depth, sib), + Node::Area(a) => { + let ctx = ctx.with_som(&a.common, sib); + let sibs = SomIndices::of(&a.children); + for (i, k) in a.children.iter().enumerate() { + self.draw_positioned_child(k, rect, &ctx, depth + 1, sibs.at(i))?; + } + Ok(()) + } + Node::Subform(s) => { + // Every drawn instance spends budget, as a flowed one does, so nested repeats that + // place nothing still stop. + self.measures.set(self.measures.get().saturating_add(1)); + self.check_measures()?; + if let Some(b) = &s.common.border { + self.draw_border(b, rect)?; + } + let parent_ctx = ctx; + let ctx = ctx.with_som(&s.common, sib); + let inner = rect.inset(&s.common.margin); + let sibs = SomIndices::of(&s.children); + match s.layout { + Layout::Positioned => { + for (i, k) in s.children.iter().enumerate() { + self.draw_positioned_child(k, inner, &ctx, depth + 1, sibs.at(i))?; + } + } + Layout::Tb => { + let mut y = inner.y; + for (i, k) in s.children.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + // A repeating subform here (a table in an area of a positioned page, + // say) gets its instances, as in a flowed one, one under another. + for instance in 0..self.instances(k, &ctx.som) { + self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sibs.at(i) + instance)?; + y += h; + } + } + } + Layout::LrTb | Layout::RlTb => { + // A repeating subform here (rows of fields under a heading) gets its + // instances, as in the other layouts. + let (placed, _) = self.pack_instances(&s.children, inner.w, &ctx, depth, &ctx.som); + for (i, instance, r) in placed { + if let Some(k) = s.children.get(i) { + let x = if s.layout == Layout::RlTb { inner.right() - r.x - r.w } else { inner.x + r.x }; + self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &ctx, depth + 1, sibs.at(i) + instance)?; + } + } + } + Layout::Table => { + let cols = self.columns(s, inner.w); + let mut y = inner.y; + for (i, k) in s.children.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let csib = sibs.at(i); + if let Node::Subform(r) = k + && r.layout == Layout::Row + { + let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1); + self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib)?; + y += h; + } else { + let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, csib)?; + y += h; + } + } + } + Layout::Row => { + let cols = self.columns(s, inner.w); + self.draw_row(s, &cols, rect, parent_ctx, depth, sib)?; + } + } + Ok(()) + } + } + } + + /// Draw border `b` around `rect`: its fill, then each shown edge as a line inside the box, or + /// the whole box as one rounded shape when its corners are rounded. + pub(super) fn draw_border(&mut self, b: &Border, rect: Rect) -> Result<(), XfaError> { + let vis = b.visible_edges(); + // Rounded corners: the box is one shape, filled and edged along its curves, when its + // edges are all alike (all shown and the same, or all hidden). Edges that differ keep + // square corners. + let radii = b.radii(rect.w, rect.h); + let alike = b.edges.iter().all(|e| *e == b.edges[0]); + if radii.iter().any(|r| *r > 0.0) && (vis == [true; 4] && alike || vis == [false; 4]) { + let e = b.edges[0]; + let edge = vis[0].then(|| { + let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 }; + (e.thickness.clamp(0.1, 20.0), color, matches!(e.stroke, Stroke::Dashed | Stroke::Dotted)) + }); + let shape = Rounded { + rect, + radii, + inverted: b.corners.map(|c| c.inverted), + fill: b.fill().map(|f| f.0), + edge, + open_top: false, + open_bottom: false, + }; + return self.push(Item::Rounded(Box::new(shape))); + } + if let Some(fill) = b.fill() { + self.push(Item::Fill { rect, color: fill.0 })?; + } + let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); + // Top, right, bottom, left; lines sit inside the box. + let segs = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x0, y1), (x1, y1)), ((x0, y0), (x0, y1))]; + for (i, seg) in segs.iter().enumerate() { + if !vis[i] { + continue; + } + let e = b.edges[i]; + let t = e.thickness.clamp(0.1, 20.0); + let (dx, dy) = match i { + 0 => (0.0, t / 2.0), + 1 => (-t / 2.0, 0.0), + 2 => (0.0, -t / 2.0), + _ => (t / 2.0, 0.0), + }; + let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 }; + self.push(Item::Line { + from: (seg.0.0 + dx, seg.0.1 + dy), + to: (seg.1.0 + dx, seg.1.1 + dy), + width: t, + color, + dashed: matches!(e.stroke, Stroke::Dashed | Stroke::Dotted), + })?; + } + Ok(()) + } + + pub(super) fn draw_value_text(&mut self, v: &Value, rect: Rect, font: &Font, para: &Para) -> Result<(), XfaError> { + let rich = match v { + Value::Text(t) => text::plain(t), + Value::Rich(r) => r.clone(), + _ => return Ok(()), + }; + let face = Face::of(font); + // Text that doesn't fit the height it was given (the template's face is narrower than + // the standard one drawn in its place, so it wraps to more lines) is drawn smaller, + // down to 70%, rather than over what lies below. + let fits = |k: f64| text::measure_height(&rich, rect.w / k, para, &face, &|id, max| self.embed(id, max)) <= rect.h / k + 1.0; + let scale = if rect.h < 4.0 || fits(1.0) { 1.0 } else { [0.95, 0.9, 0.85, 0.8, 0.75].into_iter().find(|k| fits(*k)).unwrap_or(0.7) }; + let mut block = text::layout(&rich, Rect::new(rect.x, rect.y, rect.w / scale, rect.h / scale), para, &face, &|id, max| self.embed(id, max)); + if scale < 1.0 { + for s in &mut block.spans { + s.x = rect.x + (s.x - rect.x) * scale; + s.baseline = rect.y + (s.baseline - rect.y) * scale; + s.face.size *= scale; + } + } + self.push_block(block) + } + + fn draw_draw(&mut self, d: &Draw, rect: Rect, ctx: &Ctx) -> Result<(), XfaError> { + if let Some(b) = &d.common.border { + self.draw_border(b, rect)?; + } + let ctx = ctx.child(&d.common); + let inner = rect.inset(&d.common.margin); + match &d.value { + Value::Text(_) | Value::Rich(_) => self.draw_value_text(&d.value, inner, &ctx.font, &ctx.para), + Value::Image { content_type, data } => self.draw_image(data, content_type, inner), + Value::Line { slope_up, edge } => { + if !edge.visible() { + return Ok(()); + } + let (from, to) = if *slope_up { + ((inner.x, inner.bottom()), (inner.right(), inner.y)) + } else { + ((inner.x, inner.y), (inner.right(), inner.bottom())) + }; + self.push(Item::Line { from, to, width: edge.thickness.clamp(0.1, 20.0), color: edge.color.0, dashed: edge.stroke == Stroke::Dashed }) + } + Value::Rectangle(b) => self.draw_border(b, inner), + Value::Empty => Ok(()), + } + } + + fn draw_group(&mut self, g: &ExclGroup, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + if let Some(b) = &g.common.border { + self.draw_border(b, rect)?; + } + let ctx = ctx.with_som(&g.common, sib); + // The group's data node holds the selected button's on value. + let selected: Option = + self.data.and_then(|d| d.text_at(&data_path(self.tpl, &ctx.som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + let inner = rect.inset(&g.common.margin); + let group = self.unique_name(g.common.name.as_deref().unwrap_or("group")); + let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); + let placed: Vec<(usize, Rect)> = match g.layout { + Layout::Tb => { + let mut y = 0.0; + let mut out = Vec::new(); + for (i, k) in nodes.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + out.push((i, Rect::new(0.0, y, w, h))); + y += h; + } + out + } + Layout::Positioned => nodes + .iter() + .enumerate() + .filter(|(_, k)| k.common().presence.occupies()) + .map(|(i, k)| { + let c = k.common(); + let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1); + let (x, y) = c.top_left(w, h); + (i, Rect::new(x, y, w, h)) + }) + .collect(), + _ => self.pack(&nodes, inner.w, &ctx, depth).0, + }; + let sibs = SomIndices::of(&nodes); + for (i, r) in placed { + if let Some(f) = g.fields.get(i) { + let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| (i + 1).to_string()); + let tip = f.tooltip.clone().or_else(|| g.tooltip.clone()); + let chosen = selected.as_deref() == Some(on.as_str()); + self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sibs.at(i))?; + } + } + Ok(()) + } + + /// A picture scaled to fit `inner`, keeping its shape, from its top left. + pub(super) fn draw_image(&mut self, data: &[u8], content_type: &str, inner: Rect) -> Result<(), XfaError> { + self.image_bytes = self.image_bytes.saturating_add(data.len()); + if self.image_bytes > MAX_IMAGE_BYTES { + return Err(XfaError::TooLarge("the form's pictures are too large together to lay out".into())); + } + let fitted = match crate::image::size(data) { + Some((iw, ih)) if iw > 0 && ih > 0 && inner.w > 0.0 && inner.h > 0.0 => { + let k = (inner.w / iw as f64).min(inner.h / ih as f64); + Rect::new(inner.x, inner.y, iw as f64 * k, ih as f64 * k) + } + _ => inner, + }; + self.push(Item::Image { rect: fitted, data: Arc::new(data.to_vec()), content_type: content_type.to_string() }) + } + + /// A field name made from `base` that no earlier field of the form has: the first keeps it, + /// later ones get `_2`, `_3`, …. + pub(super) fn unique_name(&mut self, base: &str) -> String { + let base = clean_name(base); + let n = self.names.entry(base.clone()).or_insert(0); + *n += 1; + if *n == 1 { base } else { format!("{base}_{n}") } + } +} diff --git a/crates/xfa/src/layout/field.rs b/crates/xfa/src/layout/field.rs new file mode 100644 index 0000000000..40ec1497de --- /dev/null +++ b/crates/xfa/src/layout/field.rs @@ -0,0 +1,305 @@ +use super::common::{button_action, date_pattern}; +use super::items::{BorderShape, Item, Widget, WidgetBorder, WidgetKind}; +use super::*; + +impl Layouter<'_> { + /// Draw field `f` at `rect`: its border (a button's is its widget's box), its caption, then its + /// widget, or its value as plain text when it is non-interactive, a barcode, or a text field + /// taller than its content area. `radio` is set for a group's radio button; `sib` is its SOM index. + pub(super) fn draw_field( + &mut self, + f: &Field, + rect: Rect, + ctx: &Ctx, + radio: Option<(&str, &str, Option, bool)>, + sib: usize, + ) -> Result<(), XfaError> { + if let Some(b) = &f.common.border + && f.ui != Ui::Button + { + self.draw_border(b, rect)?; + } + let fctx = ctx.child(&f.common); + let inner = rect.inset(&f.common.margin); + let (mut ui, mut cap_rect) = (inner, None); + if let Some((placement, reserve)) = self.caption_reserve(f, inner.w, ctx) { + let reserve = auto_check_reserve(f, placement, inner.w, Some(inner.h)).unwrap_or(reserve); + let r = reserve.max(0.0); + match placement { + Placement::Top => { + cap_rect = Some(Rect::new(inner.x, inner.y, inner.w, r.min(inner.h))); + ui = Rect::new(inner.x, inner.y + r, inner.w, inner.h - r); + } + Placement::Bottom => { + cap_rect = Some(Rect::new(inner.x, inner.bottom() - r.min(inner.h), inner.w, r.min(inner.h))); + ui = Rect::new(inner.x, inner.y, inner.w, inner.h - r); + } + Placement::Right => { + cap_rect = Some(Rect::new(inner.right() - r.min(inner.w), inner.y, r.min(inner.w), inner.h)); + ui = Rect::new(inner.x, inner.y, inner.w - r, inner.h); + } + _ => { + cap_rect = Some(Rect::new(inner.x, inner.y, r.min(inner.w), inner.h)); + ui = Rect::new(inner.x + r, inner.y, inner.w - r, inner.h); + } + } + } + if let (Some(cr), Some(cap)) = (cap_rect, &f.caption) + && cap.presence == Presence::Visible + { + let font = ctx.font.apply(&cap.font); + let para = ctx.para.apply(&cap.para); + self.draw_value_text(&cap.value, cr, &font, ¶)?; + } + let som_path = || { + if fctx.som.is_empty() { + format!("{}[{sib}]", f.common.name.as_deref().unwrap_or("field")) + } else { + format!("{}.{}[{sib}]", fctx.som, f.common.name.as_deref().unwrap_or("field")) + } + }; + match f.ui { + Ui::Unknown => { + self.warn_once("a field of an unknown kind is left blank"); + return Ok(()); + } + Ui::ImageEdit => { + // The picture the data holds (base64, as Designer saves it) or the template's. + let field_name = f.common.name.clone().unwrap_or_else(|| "an image field".into()); + let text: Vec = self + .data + .and_then(|d| d.text_at(&data_path(self.tpl, &som_path()))) + .map(|t| t.bytes().filter(|b| !b.is_ascii_whitespace()).collect()) + .unwrap_or_default(); + let from_data = if text.is_empty() { + None + } else if text.len() > MAX_DATA_IMAGE / 3 * 4 + 4 { + self.warn_once(format!("{field_name}: the picture in the data is larger than 32 MB and is left out")); + None + } else { + match base64::engine::general_purpose::STANDARD.decode(&text) { + Ok(d) if crate::image::kind(&d).is_some() => Some(d), + Ok(_) => { + self.warn_once(format!("{field_name}: the picture in the data is not a JPEG, PNG, GIF, BMP or TIFF and is left out")); + None + } + Err(_) => { + self.warn_once(format!("{field_name}: the data holds no readable picture (not base64) and is left out")); + None + } + } + }; + let box_ = ui.inset(&f.ui_margin); + match (&from_data, &f.value) { + (Some(d), _) => self.draw_image(d, "", box_)?, + (None, Value::Image { content_type, data }) => self.draw_image(data, content_type, box_)?, + _ => {} + } + // Then the field itself, which clicking gives a new picture. + } + Ui::Barcode => { + // The value as text, where the bars would be. + self.warn_once("barcode fields show their value as text, not as bars"); + let value = self + .data + .and_then(|d| d.text_at(&data_path(self.tpl, &som_path()))) + .map(str::to_string) + .or_else(|| f.value.plain()) + .unwrap_or_default(); + if !value.trim().is_empty() { + let mut para = fctx.para.clone(); + para.h_align = HAlign::Center; + self.draw_value_text(&Value::Text(value), ui.inset(&f.ui_margin), &fctx.font, ¶)?; + } + return Ok(()); + } + _ => {} + } + if f.access == Access::NonInteractive && f.ui != Ui::Button { + // Shown as text, not as a field. + return self.draw_value_text(&f.value, ui.inset(&f.ui_margin), &fctx.font, &fctx.para); + } + // A text field that grew past what a content area holds can't be one widget: its text + // is drawn as lines the page breaks fall between, and stays what the data holds. + if f.grows_with_text() && rect.h > self.cursor().0.h + 0.01 { + let value = + self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som_path()))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); + if let Some(value) = value.filter(|t| !t.trim().is_empty()) { + let name = f.common.name.clone().unwrap_or_else(|| "a text field".into()); + self.warn_once(format!("{name}: its text is longer than a page; it is shown across pages and can't be edited here")); + let m = Margin { + top: f.ui_margin.top + 2.0, + bottom: f.ui_margin.bottom + 2.0, + left: f.ui_margin.left + 2.0, + right: f.ui_margin.right + 2.0, + }; + let para = Para { v_align: VAlign::Top, ..fctx.para.clone() }; + // In the face it was measured in, so it takes the lines it was given. + let face = Self::field_text_face(f, &fctx.font); + let block = text::layout(&text::plain(value.trim_end()), ui.inset(&m), ¶, &face, &|_, _| String::new()); + return self.push_block(block); + } + } + let face = match (&f.ui, &f.caption) { + // Buttons show their caption in the caption's font. + (Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)).unscaled(), + _ => Face::of(&fctx.font).unscaled(), + }; + let h_align = match (&f.ui, &f.caption) { + (Ui::Button, Some(cap)) => ctx.para.apply(&cap.para).h_align, + _ => fctx.para.h_align, + }; + let name = self.unique_name(f.common.name.as_deref().unwrap_or("field")); + let som = som_path(); + let read_only = matches!(f.access, Access::ReadOnly | Access::Protected); + // The data's value wins over the template's default. + let data_value: Option = + self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + // A choice list's default is a saved value too. + let template_default = f.value.plain().map(|v| f.saved_value(v.trim())).filter(|v| !v.is_empty()); + let plain_value = data_value.clone().or_else(|| template_default.clone()); + let (kind, rect, border, value, tooltip) = match f.ui { + Ui::CheckButton => { + let s = f.check_size.unwrap_or(10.0).min(ui.w.max(1.0)).min(ui.h.max(1.0)); + // Without a caption the box sits at the right of the content area, as if a + // caption on its left took the rest, so the paragraph's alignment has no room to + // act on; beside a caption that alignment places it. The box's own margin doesn't + // move it. + let x = match (cap_rect, fctx.para.h_align) { + (None, _) | (Some(_), HAlign::Right) => ui.right() - s, + (Some(_), HAlign::Center) => ui.x + (ui.w - s) / 2.0, + (Some(_), HAlign::Left | HAlign::Justify) => ui.x, + }; + let y = match fctx.para.v_align { + VAlign::Top => ui.y, + VAlign::Middle => ui.y + (ui.h - s) / 2.0, + VAlign::Bottom => ui.bottom() - s, + }; + let r = Rect::new(x, y, s, s); + let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "1".into()); + let (kind, tip, checked) = match radio { + Some((group, on_value, tip, chosen)) => ( + WidgetKind::Radio { group: group.to_string(), on: on_value.to_string(), round: f.check_round }, + tip.or_else(|| f.tooltip.clone()), + chosen, + ), + None => ( + WidgetKind::CheckBox { on: on.clone(), round: f.check_round }, + f.tooltip.clone(), + plain_value.as_deref().is_some_and(|v| is_on(v, &on)), + ), + }; + let border = widget_border(f.ui_border.as_ref(), true); + (kind, r, border, checked.then(|| on.clone()), tip) + } + Ui::Button => { + let caption = f.caption.as_ref().and_then(|c| c.value.plain()).unwrap_or_default().trim().to_string(); + // A button's box is its field border, around the whole field (its margins pad + // the caption, not the box); without one it is invisible (a link over text, say). + let border = match &f.common.border { + Some(b) => widget_border(Some(b), false), + None => WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }, + }; + (WidgetKind::Button { caption }, rect, border, None, f.tooltip.clone()) + } + Ui::DateTimeEdit => { + let pattern = date_pattern(f.picture.as_deref()); + // The data holds the date in the field's data picture, or else as an ISO date; the + // field shows it in its own pattern. + let data_pattern = data_date_letters(f); + let shown = plain_value + .as_ref() + .map(|v| if data_value.is_some() { iso_to_pattern(&data_to_iso(v, data_pattern.as_deref()), &pattern) } else { v.clone() }); + (WidgetKind::Date(pattern), ui, widget_border(f.ui_border.as_ref(), false), shown, f.tooltip.clone()) + } + Ui::ChoiceList => { + let options: Vec<(String, String)> = f + .items + .iter() + .enumerate() + .map(|(i, shown)| (f.item_values.get(i).cloned().unwrap_or_else(|| shown.clone()), shown.clone())) + .collect(); + // The data holds saved values (several on separate lines); shown text is taken + // too, as a user typing into an editable list leaves it. + let value = plain_value.as_ref().map(|v| f.saved_value(v)); + let kind = WidgetKind::Choice { options, list_box: f.choice.list_box, multi: f.choice.multi, editable: f.choice.editable }; + (kind, ui, widget_border(f.ui_border.as_ref(), false), value, f.tooltip.clone()) + } + Ui::Signature => (WidgetKind::Signature, ui, widget_border(f.ui_border.as_ref(), false), None, f.tooltip.clone()), + Ui::ImageEdit => { + let none = WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }; + (WidgetKind::Image, ui, none, None, f.tooltip.clone()) + } + Ui::PasswordEdit => (WidgetKind::Password, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()), + _ => (WidgetKind::Text, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()), + }; + if rect.w < 1.0 || rect.h < 1.0 { + return Ok(()); + } + let action = if f.ui == Ui::Button { button_action(&f.scripts) } else { None }; + let items: Vec = match &kind { + WidgetKind::CheckBox { on, .. } => vec![on.clone(), f.items.get(1).cloned().unwrap_or_default()], + WidgetKind::Radio { on, .. } => vec![on.clone()], + _ => Vec::new(), + }; + self.fields += 1; + self.push(Item::Widget(Box::new(Widget { + rect, + kind, + name, + som, + tooltip, + face, + h_align, + multiline: f.multiline, + max_chars: f.max_chars, + read_only, + border, + value, + default: template_default, + action, + items, + no_scroll: f.no_scroll, + }))) + } +} + +/// The room a caption without a reserve takes beside a check box: all the box leaves of a +/// content area `w` wide (and `h` tall, when known), the box at the size it can be drawn there. +/// `None` for any other field, or a caption with a reserve of its own. +pub(super) fn auto_check_reserve(f: &Field, placement: Placement, w: f64, h: Option) -> Option { + if f.ui != Ui::CheckButton || f.caption.as_ref().is_none_or(|c| c.reserve.is_some()) { + return None; + } + let s = f.check_size.unwrap_or(10.0).min(w.max(1.0)).min(h.map_or(f64::INFINITY, |h| h.max(1.0))); + let rest = match (placement, h) { + (Placement::Top | Placement::Bottom, Some(h)) => h - s, + (Placement::Top | Placement::Bottom, None) => return None, + _ => w - s, + }; + Some(rest.max(0.0)) +} + +/// The widget's own border from the `` border element. Without one, a check box gets a +/// solid box, as Designer draws it, and any other field has no box of its own. +fn widget_border(b: Option<&Border>, check: bool) -> WidgetBorder { + let Some(b) = b else { + if !check { + return WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }; + } + return WidgetBorder { color: Some([0.0, 0.0, 0.0]), fill: Some([1.0, 1.0, 1.0]), shape: BorderShape::Full, width: 0.5 }; + }; + let vis = b.visible_edges(); + let shape = match vis { + [false, false, false, false] => BorderShape::None, + [false, false, true, false] => BorderShape::Underline, + _ => BorderShape::Full, + }; + let edge = (0..4).find(|&i| vis[i]).map(|i| b.edges[i]).unwrap_or_default(); + WidgetBorder { + color: (shape != BorderShape::None).then_some(edge.color.0), + fill: b.fill().map(|c| c.0), + shape, + width: edge.thickness.clamp(0.1, 12.0), + } +} diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs new file mode 100644 index 0000000000..48c57cd888 --- /dev/null +++ b/crates/xfa/src/layout/flow.rs @@ -0,0 +1,310 @@ +use super::common::{clamp_opt, divide, is_row, moved, solid_extent, splittable}; +use super::items::Item; +use super::*; + +impl Layouter<'_> { + /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, + /// in the next when it fits there, and across as many as it takes when it is taller than a + /// content area (a positioned or fixed-height subform holding more than a page). + fn flow_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + let (rect, y, _) = self.cursor(); + if y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { + self.make_room(h, 0.0)?; + } + let (rect, y, _) = self.cursor(); + let first = self.pages.last().map_or(0, |p| p.items.len()); + self.leaves.clear(); + self.draw_node(node, Rect::new(x, y, w, h), ctx, depth, sib)?; + let page_h = self.pages.last().map_or(f64::INFINITY, |p| p.height); + // What was drawn, when it is lower than the block's own height says (content that + // outgrows a fixed height) and ends off the page. + let drawn = self + .pages + .last() + .and_then(|p| p.items.get(first..)) + .unwrap_or_default() + .iter() + .filter_map(solid_extent) + .map(|e| e.1) + .fold(y + h, f64::max); + if y + h <= rect.bottom() + 0.01 && drawn <= page_h + 0.01 { + self.leaves.clear(); + self.place(h); + return Ok(()); + } + self.split_block(first, y, drawn) + } + + /// Remember that the items the last page got since `start` are one field or draw at + /// `rect`, to be kept together by a page break. + pub(super) fn keep_whole(&mut self, start: usize, rect: Rect) { + let end = self.pages.last().map_or(0, |p| p.items.len()); + if end > start { + self.leaves.push((start, end, rect.y, rect.bottom())); + } + } + + pub(super) fn item_count(&self) -> usize { + self.pages.last().map_or(0, |p| p.items.len()) + } + + /// Spread the items the last page holds from index `first` on (a block drawn from `top` to + /// `end`, past the bottom of its content area) over this and the following content areas. + /// Breaks fall between text, pictures and fields, never through one that fits in an area; + /// fills and lines are cut at the break. + fn split_block(&mut self, first: usize, top: f64, end: f64) -> Result<(), XfaError> { + let items: Vec = match self.pages.last_mut() { + Some(p) if first <= p.items.len() => p.items.split_off(first), + _ => return Ok(()), + }; + // Each item with the extent of the field or draw it belongs to: such a leaf moves + // whole (its border with its text), unless it is taller than a content area. + let area_h = self.cursor().0.h; + let mut extents: Vec> = vec![None; items.len()]; + for (start, end, top, bottom) in std::mem::take(&mut self.leaves) { + if bottom - top <= area_h + 0.01 + && let Some(slots) = start.checked_sub(first).and_then(|s| extents.get_mut(s..end.saturating_sub(first))) + { + slots.fill(Some((top, bottom))); + } + } + let mut rest: Vec<(Item, Option<(f64, f64)>)> = items.into_iter().zip(extents).collect(); + let origin_x = self.cursor().0.x; + // An item's place on the current page is its drawn place moved by (dx, -shift). + let (mut dx, mut shift) = (0.0, 0.0); + let mut seg_top = top; + let mut first_segment = true; + loop { + let (rect, _, placed) = self.cursor(); + let limit = rect.bottom() + shift; + let mut cut = limit; + if rect.h < 1.0 { + // No room to split into: leave the rest here. + cut = f64::INFINITY; + } else { + // The lowest break at or above the bottom that crosses no solid item: going + // through them from the lowest top up, a break moved to an item's top can't + // cross one already passed. + let mut solid: Vec<(f64, f64)> = + rest.iter().filter_map(|(item, kept)| kept.or_else(|| solid_extent(item))).filter(|e| e.0 < limit - 0.01).collect(); + solid.sort_by(|a, b| b.0.total_cmp(&a.0)); + for (t, b) in solid { + if t < cut - 0.01 && b > cut + 0.01 { + cut = t; + } + } + if cut <= seg_top + 0.01 { + // An item taller than what is left. Start it in a fresh area if this one + // already holds something; otherwise cut through it. + cut = if first_segment && placed && seg_top > rect.y + 0.01 { seg_top } else { limit }; + } + } + let mut here = Vec::new(); + let mut below = Vec::new(); + for (item, kept) in rest { + if let Some((top, _)) = kept { + if top < cut - 0.01 { + here.push(item) + } else { + below.push((item, kept)) + } + continue; + } + match divide(item, cut) { + (Some(a), None) => here.push(a), + (None, Some(b)) => below.push((b, None)), + (Some(a), Some(b)) => { + here.push(a); + below.push((b, None)); + } + (None, None) => {} + } + } + if let Some(p) = self.pages.last_mut() { + p.items.extend(here.into_iter().map(|it| moved(it, dx, -shift))); + } + if below.is_empty() { + if let Some(c) = &mut self.cur { + c.y = (end - shift).min(c.rect.bottom()).max(c.y); + c.placed = true; + } + self.overflowed(end - shift); + return Ok(()); + } + rest = below; + seg_top = cut; + first_segment = false; + self.next_area()?; + let (rect, _, _) = self.cursor(); + shift = cut - rect.y; + dx = rect.x - origin_x; + } + } + + fn page_has_flow(&self) -> bool { + self.cur.as_ref().is_some_and(|c| c.placed || c.content > 0) + } + + // ───────────────────────────────────────────────────────────────────────── flow + + /// Flow `node` (a subform; anything else is ignored) into the content areas. + pub(super) fn flow_subform(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + if depth > MAX_DEPTH { + return Ok(()); + } + let Node::Subform(sf) = node else { return Ok(()) }; + self.check_measures()?; + if sf.break_before_page && self.page_has_flow() { + self.break_frames(0.0, true)?; + } + // Data adds instances of a repeating subform (rows added in another viewer). + let instances = self.instances(node, &ctx.som); + self.warn_truncated(node, &ctx.som, instances); + let ctx_parent = ctx; + for i in 0..instances { + // Every instance spends budget, so nested repeats that place nothing still stop. + self.measures.set(self.measures.get().saturating_add(1)); + self.check_measures()?; + let ctx = ctx_parent.with_som(&sf.common, sib + i); + let (rect, _, _) = self.cursor(); + let (x, w) = avail.unwrap_or((rect.x, rect.w)); + let w = clamp_opt(sf.common.w.unwrap_or(w), sf.common.min_w, sf.common.max_w).min(w.max(1.0)); + if !matches!(sf.layout, Layout::Tb | Layout::Table) || sf.common.h.is_some() { + // Not splittable: one block. + let (_, h) = self.size_of(node, w, ctx_parent, depth); + self.flow_block(node, x, (w, h), ctx_parent, depth, sib + i)?; + continue; + } + let m = sf.common.margin; + // Its border and fill go round what it takes from here, a part on each content area. + let framed = match self.flow_mark() { + Some(part) => { + self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false }); + true + } + None => false, + }; + self.advance(m.top); + let inner_x = x + m.left; + let inner_w = (w - m.horizontal()).max(1.0); + match sf.layout { + Layout::Table => self.flow_table(sf, inner_x, inner_w, &ctx, depth)?, + _ => { + let sibs = SomIndices::of(&sf.children); + for (ci, child) in sf.children.iter().enumerate() { + if !child.common().presence.occupies() { + continue; + } + let csib = sibs.at(ci); + if let Node::Subform(_) = child + && splittable(child) + { + self.flow_subform(child, Some((inner_x, inner_w)), &ctx, depth + 1, csib)?; + continue; + } + let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1); + self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?; + } + } + } + self.advance(m.bottom); + if framed && let Some(frame) = self.frames.pop() { + self.finish_frame(frame)?; + } + } + if sf.break_after_page { + self.break_frames(0.0, true)?; + } + Ok(()) + } + + pub(super) fn columns(&self, sf: &Subform, inner_w: f64) -> Vec { + if !sf.column_widths.is_empty() { + return sf.column_widths.clone(); + } + let cells = sf.children.iter().filter_map(|c| if let Node::Subform(r) = c { Some(r.children.len()) } else { None }).max().unwrap_or(1).max(1); + vec![inner_w / cells as f64; cells] + } + + fn flow_table(&mut self, sf: &Subform, x: f64, inner_w: f64, ctx: &Ctx, depth: usize) -> Result<(), XfaError> { + let cols = self.columns(sf, inner_w); + let leader = + sf.overflow_leader.as_ref().and_then(|name| sf.children.iter().position(|c| c.common().name.as_deref() == Some(name) && is_row(c))); + // The overflow leader starts each part after the first, before the row that moved. + let lead = leader.and_then(|li| match sf.children.get(li) { + Some(Node::Subform(l)) => Some((li, l.as_ref())), + _ => None, + }); + let lead_h = lead.map_or(0.0, |(_, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1)); + let sibs = SomIndices::of(&sf.children); + for (ci, child) in sf.children.iter().enumerate() { + if !child.common().presence.occupies() { + continue; + } + let csib = sibs.at(ci); + if let Node::Subform(row) = child + && row.layout == Layout::Row + { + // Rows added as data (in another viewer) repeat the row. + let instances = self.instances(child, &ctx.som); + self.warn_truncated(child, &ctx.som, instances); + for inst in 0..instances { + let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); + let led = lead.filter(|(li, _)| *li != ci); + if self.make_room(h, led.map_or(0.0, |_| lead_h))? + && let Some((li, l)) = led + { + let (_, y, _) = self.cursor(); + self.draw_row(l, &cols, Rect::new(x, y, inner_w, lead_h), ctx, depth + 1, sibs.at(li))?; + self.place(lead_h); + } + let (_, y, _) = self.cursor(); + self.draw_row(row, &cols, Rect::new(x, y, inner_w, h), ctx, depth + 1, csib + inst)?; + self.place(h); + } + continue; + } + if splittable(child) + && let Node::Subform(_) = child + { + self.flow_subform(child, Some((x, inner_w)), ctx, depth + 1, csib)?; + continue; + } + let (cw, ch) = self.size_of(child, inner_w, ctx, depth + 1); + self.flow_block(child, x, (cw, ch), ctx, depth + 1, csib)?; + } + Ok(()) + } + + // ───────────────────────────────────────────────────────────────────────── sizing + + /// The size a node takes when given `avail_w` of width. Containers are measured once per + /// (node, width, depth): measuring a width-less container measures its content twice (for + /// its width, then its height), which would otherwise be exponential in the nesting. + /// How many instances of `node` to lay out under the container at `parent_som`: a + /// repeating subform's `occur` and data instances ([`instance_count`]); one for anything else. + pub(super) fn instances(&self, node: &Node, parent_som: &str) -> usize { + let Node::Subform(sf) = node else { return 1 }; + let from_data = match (&sf.common.name, self.data) { + (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), + _ => 0, + }; + instance_count(&sf.occur, from_data) + } + + /// The data holds more instances of `node` than `laid_out`: say so, since calculations + /// then see only those. + fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) { + let Node::Subform(sf) = node else { return }; + let from_data = match (&sf.common.name, self.data) { + (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), + _ => 0, + }; + if from_data > laid_out { + self.warn_once(format!( + "{}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those)", + sf.common.name.as_deref().unwrap_or("a subform") + )); + } + } +} diff --git a/crates/xfa/src/layout/items.rs b/crates/xfa/src/layout/items.rs new file mode 100644 index 0000000000..51383792c1 --- /dev/null +++ b/crates/xfa/src/layout/items.rs @@ -0,0 +1,145 @@ +use super::*; + +/// How far below its baseline an underline is drawn, in the text's size: an underline is the +/// level line pushed just before its span, this far below it (the PDF writer tells it apart from +/// a rule by this). +pub(crate) const UNDERLINE_DROP: f64 = 0.1; + +#[derive(Clone, Debug, PartialEq)] +pub enum Item { + Fill { + rect: Rect, + color: [f64; 3], + }, + Line { + from: (f64, f64), + to: (f64, f64), + width: f64, + color: [f64; 3], + dashed: bool, + }, + /// A box with rounded (or inverted) corners: its fill, and its edge drawn inside it. + Rounded(Box), + Text(Span), + Image { + rect: Rect, + data: Arc>, + content_type: String, + }, + Widget(Box), +} + +/// A box with rounded corners ([`Item::Rounded`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Rounded { + pub rect: Rect, + /// Top-left, top-right, bottom-right and bottom-left radii, each at most half the box's + /// shorter side. + pub radii: [f64; 4], + /// Corners whose curve goes into the box rather than around it, in the same order. + pub inverted: [bool; 4], + pub fill: Option<[f64; 3]>, + /// The edge: its width, colour and whether it is dashed. + pub edge: Option<(f64, [f64; 3], bool)>, + /// A page break cut the box here: that side has no edge, and its corners are square. + pub open_top: bool, + pub open_bottom: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum WidgetKind { + Text, + /// A text field whose characters are shown as asterisks. + Password, + /// A drop-down (`list_box` false) or list box: `options` are (saved value, shown text). + Choice { + options: Vec<(String, String)>, + list_box: bool, + multi: bool, + editable: bool, + }, + /// A signature field, to be signed in the app. + Signature, + /// An image field: a push button that picks a picture (the picture itself is page content). + Image, + /// A date field with its Acrobat format pattern (`yyyy-mm-dd`). + Date(String), + CheckBox { + on: String, + round: bool, + }, + Radio { + group: String, + on: String, + round: bool, + }, + Button { + caption: String, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BorderShape { + None, + Full, + Underline, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WidgetBorder { + pub color: Option<[f64; 3]>, + pub fill: Option<[f64; 3]>, + pub shape: BorderShape, + pub width: f64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + Reset, + Print, + SaveAs, + Url(String), + /// Any other click script: run by the XFA scripting engine. + Script(String), +} + +/// An interactive field to create as an AcroForm widget. +#[derive(Clone, Debug, PartialEq)] +pub struct Widget { + pub rect: Rect, + pub kind: WidgetKind, + /// The PDF field name (unique in the form, no periods). + pub name: String, + /// The template's scripting object model path, for data round trips. + pub som: String, + pub tooltip: Option, + pub face: Face, + pub h_align: HAlign, + pub multiline: bool, + pub max_chars: Option, + pub read_only: bool, + pub border: WidgetBorder, + /// The field's value: saved (bound) values for choice lists, newline-separated when several. + pub value: Option, + /// The template's default value (what a reset restores); data values are not defaults. + pub default: Option, + pub action: Option, + /// Check boxes: the on and off values of the data; radio buttons: the on value. + pub items: Vec, + /// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`). + pub no_scroll: bool, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Page { + pub width: f64, + pub height: f64, + pub items: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Form { + pub pages: Vec, + pub fields: usize, + pub warnings: Vec, +} diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs new file mode 100644 index 0000000000..5bccf4005f --- /dev/null +++ b/crates/xfa/src/layout/measure.rs @@ -0,0 +1,366 @@ +use super::field::auto_check_reserve; +use super::common::clamp_opt; +use super::*; + +impl Layouter<'_> { + /// The width and height `node` takes in `avail_w`: its own size, else what its content needs + /// (containers are measured once per width and depth, and measuring stops at its caps). + pub(super) fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { + let c = node.common(); + let spent = self.measures.get().saturating_add(1); + self.measures.set(spent); + if depth > MAX_DEPTH || spent > MAX_MEASURES { + return (c.w.unwrap_or(avail_w), c.h.unwrap_or(0.0)); + } + // Only containers recurse; their addresses are stable for the whole layout (the + // template is borrowed, the root and page areas are held by the layouter). + let key = match node { + Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth)), + Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth)), + _ => None, + }; + if let Some(key) = key + && let Ok(cache) = self.measured.try_borrow() + && let Some(m) = cache.get(&key) + && m.font == ctx.font + && m.para == ctx.para + { + return m.size; + } + let size = self.measure(node, avail_w, ctx, depth); + if let Some(key) = key + && let Ok(mut cache) = self.measured.try_borrow_mut() + { + cache.insert(key, Measured { font: ctx.font.clone(), para: ctx.para.clone(), size }); + } + size + } + + /// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_of`] caches it). + /// Without a width, a container, a text draw or a button is as wide as its content (at most + /// `avail_w`) and anything else takes `avail_w`; without a height, it is as tall as its content. + fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { + let c = node.common(); + // The node's own SOM: its repeating children count their instances under it, and a + // growing text field finds its value at it. Measurements are cached per template node, + // so instance 0 stands for all of them. + let ctx = ctx.with_som(c, 0); + let som = ctx.som.clone(); + // A container without a width is as wide as its content (a radio group beside its + // question, say), and so is a text label or a button (as wide as its caption), within + // what is available; any other field without one takes what is available. + let w = match c.w { + Some(w) => w, + None => match node { + Node::Subform(_) | Node::ExclGroup(_) | Node::Area(_) => { + let inner = (avail_w - c.margin.horizontal()).max(1.0); + (self.content_width(node, inner, &ctx, depth) + c.margin.horizontal()).min(avail_w) + } + Node::Draw(d) => self.natural_width(&d.value, &ctx.font, &ctx.para).map_or(avail_w, |t| (t + c.margin.horizontal()).min(avail_w)), + Node::Field(f) if f.ui == Ui::Button => f + .caption + .as_ref() + .and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para))) + .map_or(avail_w, |t| (t + c.margin.horizontal() + f.ui_margin.horizontal()).min(avail_w)), + Node::Field(_) => avail_w, + }, + }; + let w = clamp_opt(w, c.min_w, c.max_w); + let h = match c.h { + Some(h) => h, + None => { + let inner_w = (w - c.margin.horizontal()).max(1.0); + let content = match node { + Node::Draw(d) => self.value_height(&d.value, inner_w, &ctx), + Node::Field(f) => self.field_height(f, inner_w, &ctx), + Node::ExclGroup(g) => self.group_height(g, inner_w, &ctx, depth), + Node::Area(a) => self.positioned_height(&a.children, inner_w, &ctx, depth), + Node::Subform(s) => match s.layout { + Layout::Positioned => self.positioned_height(&s.children, inner_w, &ctx, depth), + // Each instance of a repeating child takes its height again. + Layout::Tb => s + .children + .iter() + .filter(|k| k.common().presence.occupies()) + .map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1 * self.instances(k, &som) as f64) + .sum(), + Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, &ctx, depth, &som).1, + Layout::Table => { + let cols = self.columns(s, inner_w); + s.children + .iter() + .filter(|k| k.common().presence.occupies()) + .map(|k| match k { + Node::Subform(r) if r.layout == Layout::Row => self.row_height(r, &cols, inner_w, &ctx, depth + 1), + _ => self.size_of(k, inner_w, &ctx, depth + 1).1, + }) + .sum() + } + Layout::Row => self.row_height(s, &self.columns(s, inner_w), inner_w, &ctx, depth + 1), + }, + }; + content + c.margin.vertical() + } + }; + // A flowed subform is as tall as what it holds: other viewers do not stretch one to + // the `minH` a designer left on it. + let min_h = match node { + Node::Subform(s) if c.h.is_none() && s.layout != Layout::Positioned => None, + _ => c.min_h, + }; + (w, clamp_opt(h, min_h, c.max_h)) + } + + /// The natural width of a container's content within `avail_w`. + fn content_width(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> f64 { + let group: Vec; + let (children, layout): (&[Node], Layout) = match node { + Node::Subform(s) => { + if s.layout == Layout::Table && !s.column_widths.is_empty() { + return s.column_widths.iter().sum::().min(avail_w); + } + (&s.children, s.layout) + } + Node::ExclGroup(g) => { + // Fields are leaves: measuring these copies caches nothing. + group = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); + (&group, g.layout) + } + Node::Area(a) => (&a.children, Layout::Positioned), + _ => return avail_w, + }; + let visible = children.iter().filter(|k| k.common().presence.occupies()); + match layout { + Layout::LrTb | Layout::RlTb | Layout::Row => { + let sum: f64 = visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).sum(); + if sum <= avail_w + 0.05 { sum } else { avail_w } + } + Layout::Positioned => visible + .map(|k| { + let (w, h) = self.size_of(k, avail_w, ctx, depth + 1); + k.common().top_left(w, h).0 + w + }) + .fold(0.0, f64::max) + .min(avail_w), + Layout::Tb | Layout::Table => visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).fold(0.0, f64::max).min(avail_w), + } + } + + /// How wide text `v` is unwrapped, in `font` and `para`, with a hair to spare so that laying + /// it out at that width keeps it on its lines; `None` for what isn't text, or text that draws + /// nothing. + fn natural_width(&self, v: &Value, font: &Font, para: &Para) -> Option { + let rich = match v { + Value::Text(t) => text::plain(t), + Value::Rich(r) => r.clone(), + _ => return None, + }; + // No text (a bordered spacer or rule): nothing to size to, so it keeps what it has room for. + let w = text::natural_width(&rich, para, &Face::of(font), &|id, max| self.embed(id, max)); + (w > 0.0).then_some(w + 0.01) + } + + /// How tall value `v` is laid out `w` wide: text wrapped in the context's font and paragraph, + /// an image at its aspect ratio, and nothing for lines, rectangles and empty values. + fn value_height(&self, v: &Value, w: f64, ctx: &Ctx) -> f64 { + match v { + Value::Text(t) => text::measure_height(&text::plain(t), w, &ctx.para, &Face::of(&ctx.font), &|id, max| self.embed(id, max)), + Value::Rich(r) => text::measure_height(r, w, &ctx.para, &Face::of(&ctx.font), &|id, max| self.embed(id, max)), + Value::Image { data, .. } => crate::image::size(data).map_or(w, |(iw, ih)| if iw > 0 { w * ih as f64 / iw as f64 } else { w }), + Value::Line { .. } | Value::Rectangle(_) | Value::Empty => 0.0, + } + } + + /// Where field `f`'s caption goes and how much room it takes from the field's box, or `None` + /// when it has no caption that takes room (a button's is drawn inside its box). + pub(super) fn caption_reserve(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> Option<(Placement, f64)> { + let cap = f.caption.as_ref()?; + if !cap.presence.occupies() || f.ui == Ui::Button { + return None; + } + if cap.value.is_empty() && cap.reserve.is_none() { + return None; + } + // A check box has no text for an inline caption to run with: it is not shown. + if f.ui == Ui::CheckButton && cap.placement == Placement::Inline { + return None; + } + let placement = if cap.placement == Placement::Inline { Placement::Left } else { cap.placement }; + let font = ctx.font.apply(&cap.font); + let para = ctx.para.apply(&cap.para); + let reserve = cap.reserve.unwrap_or_else(|| match placement { + Placement::Top | Placement::Bottom => { + let rich = match &cap.value { + Value::Rich(r) => r.clone(), + other => text::plain(&other.plain().unwrap_or_default()), + }; + text::measure_height(&rich, inner_w, ¶, &Face::of(&font), &|id, max| self.embed(id, max)) + } + _ => (Face::of(&font).width(&cap.value.plain().unwrap_or_default()) + 2.0).min(inner_w * 0.6), + }); + Some((placement, reserve)) + } + + /// The height of field `f`'s content within `inner_w` (inside its margins): its widget (a check + /// box, a button's caption, or one line of text or three when multi-line, more for a text field + /// that grows) plus a caption above or below it, or the taller of the two for one beside it. + fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { + let font = ctx.font.apply(&f.common.font); + let lines = if f.multiline { 3.0 } else { 1.0 }; + let ui = match f.ui { + Ui::CheckButton => f.check_size.unwrap_or(10.0).max(text::line_height(font.size)), + // A button shows its caption: as tall as the caption's text in its own font. + Ui::Button if f.caption.as_ref().is_some_and(|c| !c.value.is_empty()) => { + let cap = f.caption.as_ref().map_or(0.0, |c| { + let rich = match &c.value { + Value::Rich(r) => r.clone(), + other => text::plain(&other.plain().unwrap_or_default()), + }; + let w = (inner_w - f.ui_margin.horizontal()).max(1.0); + text::measure_height(&rich, w, &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| self.embed(id, max)) + }); + cap + f.ui_margin.vertical() + } + _ => (text::line_height(font.size) * lines).max(self.grown_text_height(f, inner_w, ctx)) + 4.0 + f.ui_margin.vertical(), + }; + match self.caption_reserve(f, inner_w, ctx) { + Some((Placement::Top | Placement::Bottom, r)) => ui + r, + Some((placement, r)) => { + // A check box's caption without a reserve is drawn in all the box leaves. + let w = match auto_check_reserve(f, placement, inner_w, None) { + Some(auto) => auto, + None => inner_w - r, + }; + let cap = f.caption.as_ref().map(|c| { + let rich = match &c.value { + Value::Rich(r) => r.clone(), + other => text::plain(&other.plain().unwrap_or_default()), + }; + text::measure_height(&rich, w.max(1.0), &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| { + self.embed(id, max) + }) + }); + ui.max(cap.unwrap_or(0.0)) + } + None => ui, + } + } + + /// The face a field's text is laid out in: at its horizontal scale when the layout draws + /// the text itself (a non-interactive field), as designed when the forms layer draws it in + /// a widget, whose appearances have no horizontal scaling. + pub(super) fn field_text_face(f: &Field, font: &Font) -> Face { + let face = Face::of(font); + if f.access == Access::NonInteractive { face } else { face.unscaled() } + } + + /// The height the text of a multi-line text field takes (its value in the data, else the + /// template's), for a field without a fixed height, which grows to hold its text. `ctx` + /// is the field's own (its SOM ends in the field). Zero for anything else. + fn grown_text_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { + if !f.grows_with_text() || f.common.name.is_none() { + return 0.0; + } + let value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &ctx.som))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); + let Some(value) = value.filter(|t| !t.trim().is_empty()) else { return 0.0 }; + // Beside a caption the text has what the caption leaves; a widget's text has 2pt of + // padding on each side, a non-interactive field's (drawn as text) none, so it wraps + // where it is drawn. + let beside = match self.caption_reserve(f, inner_w, ctx) { + Some((Placement::Top | Placement::Bottom, _)) | None => 0.0, + Some((_, r)) => r, + }; + let padding = if f.access == Access::NonInteractive { 0.0 } else { 4.0 }; + let w = (inner_w - beside - f.ui_margin.horizontal() - padding).max(1.0); + let font = ctx.font.apply(&f.common.font); + text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Self::field_text_face(f, &font), &|_, _| String::new()) + } + + /// The height of an exclusion group's fields at `inner_w`, by the group's layout: stacked + /// top to bottom, at their own positions, or packed in rows. + fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); + match g.layout { + Layout::Tb => nodes.iter().filter(|k| k.common().presence.occupies()).map(|k| self.size_of(k, inner_w, ctx, depth + 1).1).sum(), + Layout::Positioned => self.positioned_height(&nodes, inner_w, ctx, depth), + _ => self.packed_height(&nodes, inner_w, ctx, depth), + } + } + + fn positioned_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + children + .iter() + .filter(|k| k.common().presence.occupies()) + .map(|k| { + let c = k.common(); + let (w, h) = self.size_of(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); + c.top_left(w, h).1 + h + }) + .fold(0.0, f64::max) + } + + /// Left-to-right packing: rows of children, wrapped at the available width, as (child, + /// instance, place). A repeating subform is packed once per instance it has under the + /// container at `som`. + pub(super) fn pack_instances(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize, som: &str) -> (Vec<(usize, usize, Rect)>, f64) { + let mut out = Vec::new(); + let (mut x, mut y, mut line_h) = (0.0, 0.0, 0.0); + for (i, k) in children.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let (w, h) = self.size_of(k, inner_w, ctx, depth + 1); + for instance in 0..self.instances(k, som) { + if x > 0.0 && x + w > inner_w + 0.05 { + y += line_h; + x = 0.0; + line_h = 0.0; + } + out.push((i, instance, Rect::new(x, y, w, h))); + x += w; + line_h = line_h.max(h); + } + } + (out, y + line_h) + } + + /// [`Self::pack_instances`] for children that don't repeat (the fields of a group). + pub(super) fn pack(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { + let (placed, h) = self.pack_instances(children, inner_w, ctx, depth, ""); + (placed.into_iter().map(|(i, _, r)| (i, r)).collect(), h) + } + + fn packed_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + self.pack(children, inner_w, ctx, depth).1 + } + + /// Cell rectangles of a row (relative to the row's inner box) and the row height. + pub(super) fn cells(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { + let ctx = ctx.child(&row.common); + let mut out = Vec::new(); + let mut col = 0usize; + let mut x = 0.0; + let mut h: f64 = 0.0; + for (i, k) in row.children.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let span = k.common().col_span.max(1); + let w = if col < cols.len() { cols.iter().skip(col).take(span).sum::() } else { k.common().w.unwrap_or((inner_w - x).max(1.0)) }; + let (_, ch) = self.size_of(k, w, &ctx, depth + 1); + out.push((i, Rect::new(x, 0.0, w, ch))); + h = h.max(ch); + x += w; + col += span; + } + let h = match row.common.h { + Some(rh) => rh, + None => h + row.common.margin.vertical(), + }; + (out, clamp_opt(h, row.common.min_h, row.common.max_h)) + } + + pub(super) fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + self.cells(row, cols, inner_w, ctx, depth).1 + } +} diff --git a/crates/xfa/src/layout/pages.rs b/crates/xfa/src/layout/pages.rs new file mode 100644 index 0000000000..13047058b3 --- /dev/null +++ b/crates/xfa/src/layout/pages.rs @@ -0,0 +1,280 @@ +use super::items::{Item, Page, UNDERLINE_DROP}; +use super::*; + +impl Layouter<'_> { + pub(super) fn warn_once(&mut self, msg: impl Into) { + let m = msg.into(); + if self.warned.insert(m.clone()) && self.warnings.len() < 100 { + self.warnings.push(m); + } + } + + fn page_no(&self) -> usize { + self.pages.len() + } + + /// The text an embedded field `id` shows, at most `max` bytes of a field value (the page + /// number and count are a few bytes; the text layout cuts them). + pub(super) fn embed(&self, id: &str, max: usize) -> String { + let v = match self.tpl.page_roles.iter().find(|(i, _)| i == id).map(|(_, r)| *r) { + Some(PageRole::Number) => self.page_no().to_string(), + Some(PageRole::Count) => PAGE_COUNT_MARK.to_string(), + None => self.values.get(id).map(|v| text::prefix(v, max).to_string()).unwrap_or_default(), + }; + #[cfg(test)] + EMBED_COPIED.with(|c| c.set(c.get().saturating_add(v.len() as u64))); + v + } + + /// `TooLarge` once the measurement budget is spent (sizes measured after that are guesses). + pub(super) fn check_measures(&self) -> Result<(), XfaError> { + if self.measures.get() > MAX_MEASURES { + return Err(XfaError::TooLarge("the form is nested too deeply to lay out".into())); + } + Ok(()) + } + + pub(super) fn push(&mut self, item: Item) -> Result<(), XfaError> { + self.check_measures()?; + self.items += 1; + if self.items > MAX_ITEMS { + return Err(XfaError::TooLarge("the form has too many objects to lay out".into())); + } + if let Some(p) = self.pages.last_mut() { + p.items.push(item); + } + Ok(()) + } + + pub(super) fn push_block(&mut self, b: Block) -> Result<(), XfaError> { + for s in b.spans { + if s.face.underline { + let w = s.face.width(&s.text); + let y = s.baseline + s.face.size * UNDERLINE_DROP; + self.push(Item::Line { from: (s.x, y), to: (s.x + w, y), width: (s.face.size * 0.06).max(0.3), color: s.face.color, dashed: false })?; + } + if !s.text.trim().is_empty() { + self.push(Item::Text(s))?; + } + } + Ok(()) + } + + // ───────────────────────────────────────────────────────────────────────── pages + + pub(super) fn new_page(&mut self) -> Result<(), XfaError> { + let idx = + (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); + self.new_page_of(idx) + } + + /// Once the body has been laid out, give every page area that has been used fewer times than + /// its `occur min` the pages it still needs, in template order, after the body's pages. + pub(super) fn satisfy_page_area_minimums(&mut self) -> Result<(), XfaError> { + let areas = Rc::clone(&self.areas); + for (idx, area) in areas.iter().enumerate() { + while self.uses.get(idx).is_some_and(|u| *u < area.occur_min) { + self.new_page_of(idx)?; + } + } + Ok(()) + } + + /// Start a page from page area `idx`. + fn new_page_of(&mut self, idx: usize) -> Result<(), XfaError> { + if self.pages.len() >= MAX_PAGES { + return Err(XfaError::TooLarge(format!("the form lays out to more than {MAX_PAGES} pages"))); + } + let areas = Rc::clone(&self.areas); + let Some(area) = areas.get(idx) else { + return Err(XfaError::Malformed("the template has no page area".into())); + }; + if let Some(u) = self.uses.get_mut(idx) { + *u += 1; + } + self.pages.push(Page { width: area.width, height: area.height, items: Vec::new() }); + let content = area.content.first().copied().unwrap_or(Rect::new(0.0, 0.0, area.width, area.height)); + self.cur = Some(Cursor { area: idx, content: 0, rect: content, y: content.y, placed: false }); + // Master page content: positioned draws and fields. + let ctx = Ctx { font: Font::default(), para: Para::default(), som: area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_default() }; + let page_rect = Rect::new(0.0, 0.0, area.width, area.height); + let sibs = SomIndices::of(&area.items); + for (i, item) in area.items.iter().enumerate() { + self.draw_positioned_child(item, page_rect, &ctx, 1, sibs.at(i))?; + } + Ok(()) + } + + /// Move to the next content area, or the next page. + pub(super) fn next_area(&mut self) -> Result<(), XfaError> { + if let Some(c) = &self.cur + && let Some(area) = self.areas.get(c.area) + && let Some(next) = area.content.get(c.content + 1).copied() + { + let i = c.content + 1; + self.cur = Some(Cursor { area: c.area, content: i, rect: next, y: next.y, placed: false }); + return Ok(()); + } + self.new_page() + } + + /// The current content area, how far down it the flow is, and whether anything has been + /// placed in it; before the first page, an empty letter-size page from its top. + pub(super) fn cursor(&self) -> (Rect, f64, bool) { + match &self.cur { + Some(c) => (c.rect, c.y, c.placed), + None => (Rect::new(0.0, 0.0, 612.0, 792.0), 0.0, false), + } + } + + /// Where the flow is now ([`FlowMark`]); `None` before the first page. + pub(super) fn flow_mark(&self) -> Option { + let c = self.cur.as_ref()?; + Some(FlowMark { page: self.pages.len(), area: c.area, content: c.content, y: c.y, items: self.pages.last().map_or(0, |p| p.items.len()) }) + } + + /// Whether the flow is still in the content area `m` was taken in. + fn on_area(&self, m: &FlowMark) -> bool { + self.pages.len() == m.page && self.cur.as_ref().is_some_and(|c| c.area == m.area && c.content == m.content) + } + + /// Whether page `page` (an index) got anything from item `at` on. + fn has_items_from(&self, page: usize, at: usize) -> bool { + self.pages.get(page).is_some_and(|p| p.items.len() > at) + } + + /// Break the flowed subforms being laid out ([`Self::frames`]) across content areas, as + /// another viewer breaks such a section on a synthetic form: each one's part here ends after + /// its content and its bottom margin (the innermost first, each inside the next), the flow + /// moves to the next content area, and each starts a new part there after its top margin + /// (the outermost first). The top margins take only what the `need` points of what comes next + /// leave of the new area: a child that fits a fresh area but not after the margins would + /// otherwise run past its bottom, out of its border. `to_page` moves to a new page instead (a + /// child's own page break), which breaks the subforms around it the same way. + pub(super) fn break_frames(&mut self, need: f64, to_page: bool) -> Result<(), XfaError> { + let (rect, mut y, _) = self.cursor(); + let page = self.pages.len().saturating_sub(1); + let here: Vec = self.frames.iter().map(|f| self.on_area(&f.part)).collect(); + let filled: Vec = self.frames.iter().map(|f| self.has_items_from(page, f.part.items)).collect(); + for ((f, here), filled) in self.frames.iter_mut().zip(here).zip(filled).rev() { + y = (y + f.margin.bottom).min(rect.bottom()); + if !here { + f.lost = true; + } else if filled && y - f.part.y >= 0.01 { + f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, y - f.part.y))); + } + } + if to_page { + self.new_page()?; + } else { + self.next_area()?; + } + let (rect, y, _) = self.cursor(); + let mut room = (rect.bottom() - y - need).max(0.0); + for i in 0..self.frames.len() { + let Some(mark) = self.flow_mark() else { break }; + let Some(f) = self.frames.get_mut(i) else { break }; + f.part = mark; + // `max` before `min`: a NaN margin counts as none and leaves `room` a number. + let top = f.margin.top.max(0.0).min(room); + room = (room - top).max(0.0); + self.advance(top); + } + Ok(()) + } + + /// Close flowed subform `f` once its content is placed: its last part ends where the flow + /// is, and its border (edges and fill) is drawn round each part, closed on every side, under + /// that part's content. One holding something that didn't fit its content area ([`Frame::lost`]) + /// is drawn without its border, and the layout says so. + pub(super) fn finish_frame(&mut self, mut f: Frame) -> Result<(), XfaError> { + let Some(b) = f.border.take() else { return Ok(()) }; + if b.fill().is_none() && b.visible_edges() == [false; 4] { + return Ok(()); + } + let (area, end, _) = self.cursor(); + // The bottom margin stays in the content area, as at a break; content that itself ran + // past it has already cost the section its border ([`Self::overflowed`]). + let end = end.min(area.bottom()).max(end - f.margin.bottom); + let page = self.pages.len().saturating_sub(1); + if !self.on_area(&f.part) { + f.lost = true; + } else if self.has_items_from(page, f.part.items) && end - f.part.y >= 0.01 { + f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, end - f.part.y))); + } + if f.lost { + self.warn_once("a bordered section holding an item that doesn't fit on its page is drawn without its border"); + return Ok(()); + } + // The last part first: a part's place on its page stays where it was found. + for (page, at, rect) in f.parts.into_iter().rev() { + self.insert_border(&b, rect, page, at)?; + } + Ok(()) + } + + /// Draw border `b` round `rect` on page `page` (an index), under what that page got from item + /// `at` on. + fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> { + let before = self.pages.last().map_or(0, |p| p.items.len()); + self.draw_border(b, rect)?; + let drawn: Vec = match self.pages.last_mut() { + Some(p) => { + let from = before.min(p.items.len()); + p.items.drain(from..).collect() + } + None => return Ok(()), + }; + if let Some(target) = self.pages.get_mut(page) { + let at = at.min(target.items.len()); + target.items.splice(at..at, drawn); + } + Ok(()) + } + + /// Move the cursor down (margins and spacing: the area still counts as empty). + pub(super) fn advance(&mut self, dy: f64) { + if let Some(c) = &mut self.cur { + c.y += dy.max(0.0); + } + } + + /// Move past something that was placed. + pub(super) fn place(&mut self, dy: f64) { + if let Some(c) = &mut self.cur { + c.y += dy.max(0.0); + c.placed = true; + } + let (_, y, _) = self.cursor(); + self.overflowed(y); + } + + /// Something was placed down to `bottom`: past the content area's bottom, the flowed subforms + /// it is inside can't hold it in a part, and are drawn without their border. + pub(super) fn overflowed(&mut self, bottom: f64) { + let (rect, _, _) = self.cursor(); + if bottom > rect.bottom() + 0.01 { + for f in &mut self.frames { + f.lost = true; + } + } + } + + /// Make room for `h`, with `after` more drawn before it should it move (a table's overflow + /// leader): stay, or move to the next content area / page. Returns whether a break happened. + /// Something that fits a fresh area breaks the flowed subforms it is in with it + /// ([`Self::break_frames`]), a table's rows included; one taller than an area just moves on, + /// and those subforms are drawn without their border ([`Self::finish_frame`]). + pub(super) fn make_room(&mut self, h: f64, after: f64) -> Result { + let (rect, y, placed) = self.cursor(); + if y + h <= rect.bottom() + 0.01 || !placed { + return Ok(false); + } + if h <= rect.h + 0.01 { + self.break_frames(h + after, false)?; + } else { + self.next_area()?; + } + Ok(true) + } +} From 0988209798f0ccc9826aab3894865ae6b1dedbed Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:11:40 +0000 Subject: [PATCH 085/246] refactor(xfa): split pdf.rs into pdf/ New modules: helpers, strip. --- crates/xfa/src/pdf.rs | 490 +---------------------------------- crates/xfa/src/pdf/common.rs | 200 ++++++++++++++ crates/xfa/src/pdf/strip.rs | 282 ++++++++++++++++++++ 3 files changed, 492 insertions(+), 480 deletions(-) create mode 100644 crates/xfa/src/pdf/common.rs create mode 100644 crates/xfa/src/pdf/strip.rs diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 23e21fc331..ab5d94bb45 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -12,6 +12,16 @@ use crate::layout::{Action, BorderShape, Form, Item, Page, Rounded, Widget, Widg use crate::model::Rect; use crate::text::{Face, Family, Span}; +mod common; +mod strip; + +pub use common::jpeg_size; +pub(crate) use common::rounded; +use common::{all_faces, content_hash, empty_form, hex32, jpeg_info, js_action, n, parse_hex32, rgb, rgb_array, standard_font, state_name}; +pub(crate) use strip::USAGE_RIGHTS_REMOVED; +use strip::drop_usage_rights; +pub use strip::{Stripped, XfaShape, existing_layout, strip, xfa_shape}; + /// Private key on generated fields: the template's SOM path, so data can be put back. pub const SOM_KEY: &[u8] = b"PCSom"; /// Private key on the AcroForm: what laying the form out produced, so a reopened file is not @@ -26,209 +36,6 @@ pub const CLICK_KEY: &[u8] = b"PCXfaClick"; /// fields they hold. pub const NODE_KEY: &[u8] = b"PCXfaNode"; -/// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT -/// markers). `None` for anything else. -pub fn jpeg_size(data: &[u8]) -> Option<(u32, u32)> { - jpeg_frame(data).map(|(w, h, _)| (w, h)) -} - -fn jpeg_frame(data: &[u8]) -> Option<(u32, u32, u8)> { - jpeg_info(data).map(|(w, h, c, _)| (w, h, c)) -} - -/// The SHA-256 of a picture's bytes, to find its XObject again. -fn content_hash(data: &[u8]) -> [u8; 32] { - use sha2::Digest; - sha2::Sha256::digest(data).into() -} - -fn hex32(h: &[u8; 32]) -> Vec { - h.iter().flat_map(|b| format!("{b:02x}").into_bytes()).collect() -} - -fn parse_hex32(s: &[u8]) -> Option<[u8; 32]> { - let mut out = [0u8; 32]; - if s.len() != 64 || !s.iter().all(u8::is_ascii_hexdigit) { - return None; - } - for (o, pair) in out.iter_mut().zip(s.as_chunks::<2>().0) { - *o = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?; - } - Some(out) -} - -/// Width, height, components and whether an Adobe APP14 segment is present (such CMYK JPEGs -/// store inverted values). -fn jpeg_info(data: &[u8]) -> Option<(u32, u32, u8, bool)> { - if data.get(0..2)? != [0xFF, 0xD8] { - return None; - } - let mut i = 2usize; - let mut guard = 0; - let mut adobe = false; - while i + 4 <= data.len() && guard < 10_000 { - guard += 1; - if *data.get(i)? != 0xFF { - i += 1; - continue; - } - let marker = *data.get(i + 1)?; - if marker == 0xFF { - i += 1; - continue; - } - if (0xD0..=0xD9).contains(&marker) || marker == 0x01 { - i += 2; - continue; - } - let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize; - if marker == 0xEE && data.get(i + 4..i + 9) == Some(b"Adobe") { - adobe = true; - } - let sof = matches!(marker, 0xC0..=0xCF) && !matches!(marker, 0xC4 | 0xC8 | 0xCC); - if sof { - let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]) as u32; - let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]) as u32; - let comps = *data.get(i + 9)?; - return (w > 0 && h > 0).then_some((w, h, comps, adobe)); - } - i = i.checked_add(2)?.checked_add(len.max(2))?; - } - None -} - -/// Format a number for content streams. -fn n(v: f64) -> String { - let v = if v.is_finite() { v } else { 0.0 }; - let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -fn rgb(c: [f64; 3]) -> String { - format!("{} {} {}", n(c[0]), n(c[1]), n(c[2])) -} - -fn rgb_array(c: [f64; 3]) -> Object { - Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()) -} - -fn standard_font(base: &str) -> Object { - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - Object::Dict(f) -} - -/// Every face the content streams or `/DA` strings may name. -fn all_faces() -> Vec<(&'static str, &'static str)> { - let mut out = Vec::new(); - for fam in [Family::Helvetica, Family::Courier, Family::Times] { - for (b, i) in [(false, false), (true, false), (false, true), (true, true)] { - out.push((fam.resource_name(b, i), fam.base_font(b, i))); - } - } - out -} - -/// The content that draws rounded box `r` on a page `page_h` tall: its fill along its curves, -/// then its edge inside it. A side a page break cut has no edge. -pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String { - let mut c = String::new(); - if let Some(color) = r.fill { - c.push_str(&format!("{} rg {}f\n", rgb(color), outline(r.rect, r.radii, r.inverted, (false, false), page_h))); - } - if let Some((w, color, dashed)) = r.edge { - // The edge's centre line, half its width inside the box, so the edge stays in: a corner - // curving around the box tightens by as much, and one curving into it widens. - let inset = Rect::new(r.rect.x + w / 2.0, r.rect.y + w / 2.0, (r.rect.w - w).max(0.0), (r.rect.h - w).max(0.0)); - let most = inset.w.min(inset.h) / 2.0; - let radii: [f64; 4] = std::array::from_fn(|i| match (r.radii[i], r.inverted[i]) { - (v, _) if v <= 0.0 => 0.0, - (v, true) => (v + w / 2.0).min(most), - (v, false) => (v - w / 2.0).clamp(0.0, most), - }); - let path = outline(inset, radii, r.inverted, (r.open_top, r.open_bottom), page_h); - c.push_str(&format!("q {} RG {} w {}{path}S Q\n", rgb(color), n(w), if dashed { "[2 2] 0 d " } else { "" })); - } - c -} - -/// The path around `rect` (top-left based, on a page `page_h` tall) with the corners `radii` -/// (top-left, top-right, bottom-right, bottom-left), each curving around the box or, when -/// `inverted`, into it. `open` leaves out the top or bottom side (and doesn't close the path): -/// those are where a page break cut the box. Ends with a space, for the paint operator. -fn outline(rect: Rect, radii: [f64; 4], inverted: [bool; 4], open: (bool, bool), page_h: f64) -> String { - let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); - let pt = |q: (f64, f64)| format!("{} {}", n(q.0), n(page_h - q.1)); - // Corner `i`: its point, then where its curve starts and ends, going clockwise. - let corner = |i: usize| { - let r = radii.get(i).copied().unwrap_or(0.0); - match i { - 0 => ((x0, y0), (x0, y0 + r), (x0 + r, y0)), - 1 => ((x1, y0), (x1 - r, y0), (x1, y0 + r)), - 2 => ((x1, y1), (x1, y1 - r), (x1 - r, y1)), - _ => ((x0, y1), (x0 + r, y1), (x0, y1 - r)), - } - }; - // Corner `i`'s curve, from its start: a quarter circle as one Bézier curve. - let curve = |i: usize| { - if radii.get(i).copied().unwrap_or(0.0) <= 0.0 { - return String::new(); - } - let (p, a, b) = corner(i); - const K: f64 = 0.5523; - let (c1, c2) = if inverted.get(i).copied().unwrap_or(false) { - // Centred on the corner itself. - ((a.0 + (b.0 - p.0) * K, a.1 + (b.1 - p.1) * K), (b.0 + (a.0 - p.0) * K, b.1 + (a.1 - p.1) * K)) - } else { - ((a.0 + (p.0 - a.0) * K, a.1 + (p.1 - a.1) * K), (b.0 + (p.0 - b.0) * K, b.1 + (p.1 - b.1) * K)) - }; - format!("{} {} {} c ", pt(c1), pt(c2), pt(b)) - }; - // Along corners `from`, joining them with straight sides. - let along = |from: &[usize]| { - let mut s = String::new(); - for &i in from { - s.push_str(&format!("{} l {}", pt(corner(i).1), curve(i))); - } - s - }; - match open { - (false, false) => format!("{} m {}{}{}{}h ", pt(corner(0).1), curve(0), along(&[1]), along(&[2]), along(&[3])), - (true, false) => format!("{} m {}{} l ", pt((x1, y0)), along(&[2, 3]), pt((x0, y0))), - (false, true) => format!("{} m {}{} l ", pt((x0, y1)), along(&[0, 1]), pt((x1, y1))), - (true, true) => format!("{} m {} l {} m {} l ", pt((x0, y0)), pt((x0, y1)), pt((x1, y0)), pt((x1, y1))), - } -} - -/// A PDF appearance-state name for a check button's on value. -fn state_name(on: &str) -> String { - let s: String = on.trim().chars().map(|c| if c.is_whitespace() || "()<>[]{}/%#".contains(c) || c.is_control() { '_' } else { c }).collect(); - if s.is_empty() || s == "Off" { "Yes".into() } else { s } -} - -fn empty_form(doc: &mut Document, w: f64, h: f64) -> Object { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("XObject")); - d.set(b"Subtype".to_vec(), Object::name("Form")); - d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|v| Object::Real(*v)).collect())); - Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new())))) -} - -fn js_action(js: &str) -> Object { - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), Object::String(PdfString::literal(js.as_bytes().to_vec()))); - Object::Dict(a) -} - -/// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). -pub(crate) const USAGE_RIGHTS_REMOVED: &str = - "the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked"; - pub struct Written { pub pages: usize, pub fields: usize, @@ -1133,283 +940,6 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result Ok(Written { pages: count, fields: field_count, warnings, usage_rights }) } -/// What [`strip`] took out, for the user to be told (the packets held more than the fields). -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Stripped { - /// The packets removed, by name (`template`, `datasets`, `config`, `signature`, …). - pub packets: Vec, - /// Data values in the datasets no field held (what the form's data had that its fields - /// didn't show); `None` when the template could not be read to tell. - pub unbound_data: Option, - /// Buttons whose click ran an XFA script, and image fields: they do nothing from now on. - pub script_buttons: usize, - pub image_fields: usize, - /// What the layout left out (pictures, rows past the cap), now for good. - pub layout_warnings: Vec, - /// Reader usage rights (`/Perms /UR3`) were removed: changing the form voids them anyway. - pub usage_rights: bool, - /// Private keys removed from field dictionaries (`/PCSom`, `/PCXfaClick`, …). - pub field_keys: usize, -} - -impl Stripped { - /// What was dropped, as lines for the user (empty when nothing worth saying). - pub fn notes(&self) -> Vec { - let mut out = Vec::new(); - if !self.packets.is_empty() { - out.push(format!("The XFA was taken out of the form: its {} packet(s) are gone from the saved file", self.packets.join(", "))); - } - match self.unbound_data { - Some(n) if n > 0 => out.push(format!("{n} value(s) the form's XFA data held that no field showed are gone with it")), - None => out.push("The template could not be read, so data the fields didn't show may be gone with the XFA".into()), - _ => {} - } - if self.script_buttons + self.image_fields > 0 { - out.push(format!("{} button(s) that ran XFA scripts and {} image field(s) do nothing now", self.script_buttons, self.image_fields)); - } - for w in &self.layout_warnings { - out.push(format!("Kept as laid out: {w}")); - } - if self.usage_rights { - out.push("Adobe Reader extended features were removed (changing the form voids them)".into()); - } - out - } -} - -/// What a document's XFA entry says, from its dictionaries alone (no packet decoded). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct XfaShape { - /// `/NeedsRendering true`: the form expects to be laid out from the template. - pub needs_rendering: bool, - /// The AcroForm has fields of its own. - pub has_fields: bool, - /// PdfCraft laid the form out (the layout mark is there). - pub laid_out: bool, -} - -impl XfaShape { - /// Whether taking the XFA out leaves a form every viewer shows: the pages PdfCraft laid out - /// (with their fields), or a static form's own pages and fields. A dynamic form that was - /// never laid out has only placeholder pages: stripping it would leave nothing. - pub fn strippable(self) -> bool { - self.laid_out || (!self.needs_rendering && self.has_fields) - } -} - -/// The document's XFA entry, if any, without decoding a packet. -pub fn xfa_shape(doc: &Document) -> Option { - let root = doc.root()?; - let catalog = doc.get(root); - let catalog = catalog.as_dict()?; - let acro = doc.resolve(catalog.get(b"AcroForm")?); - let acro = acro.as_dict()?; - acro.get(b"XFA")?; - let bool_of = |v: Option<&Object>| matches!(v.map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))); - let needs_rendering = bool_of(catalog.get(b"NeedsRendering")) || bool_of(acro.get(b"NeedsRendering")); - let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false); - Some(XfaShape { needs_rendering, has_fields, laid_out: acro.contains(LAYOUT_KEY) }) -} - -/// Most field dictionaries walked for their private keys. -const MAX_STRIP_FIELDS: usize = 1_000_000; - -/// Take the XFA out of a document, leaving what every viewer shows: the pages as laid out -/// and, when the fields were kept, an ordinary AcroForm. The packets (template, data and -/// all), `/NeedsRendering`, the layout mark, the overrides and Reader usage rights go, and -/// the fields lose the private keys PdfCraft gave them. A flattened form (no fields left) -/// becomes a plain PDF; one with its fields becomes a static form that Adobe's viewers no -/// longer lay out again. What the packets held beyond the fields is counted in the report, -/// so the user can be told; the caller decides whether the form is one to strip -/// ([`XfaShape::strippable`]). A document without XFA is left as it is. -pub fn strip(doc: &mut Document) -> Result { - let mut out = Stripped::default(); - let Some(root) = doc.root() else { return Ok(out) }; - let Some(mut catalog) = doc.get(root).as_dict().cloned() else { return Ok(out) }; - let acro_obj = catalog.get(b"AcroForm").cloned(); - let Some(acro_obj) = acro_obj else { return Ok(out) }; - let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(out) }; - let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(out) }; - // What is about to go, while it can still be read. - out.unbound_data = unbound_data(doc); - out.layout_warnings = existing_layout(doc).map(|r| r.warnings).unwrap_or_default(); - let mut streams: Vec = Vec::new(); - match &*doc.resolve(&xfa) { - Object::Array(items) => { - let mut i = 0; - while i < items.len() { - let name = items.get(i).and_then(|n| n.as_string()).map(|s| s.to_text()); - let stream = if name.is_some() { items.get(i + 1) } else { items.get(i) }; - if let Some(n) = &name { - out.packets.push(n.clone()); - } - if let Some(r) = stream.and_then(Object::as_ref) { - streams.push(r); - } - i += if name.is_some() { 2 } else { 1 }; - } - } - Object::Stream(s) => { - if let Some(r) = xfa.as_ref() { - streams.push(r); - } - // One XDP: the packets it holds are the envelope's children. - let text = s.decoded_within(64 << 20).ok().and_then(|b| crate::packets::decode(&b)).map(|(t, _)| t).unwrap_or_default(); - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 1_000_000 }; - if crate::xml_nesting_depth(&text) <= crate::MAX_XML_DEPTH - && let Ok(parsed) = roxmltree::Document::parse_with_options(&text, opts) - { - let root = parsed.root_element(); - if root.tag_name().name() == "xdp" { - out.packets.extend(root.children().filter(|c| c.is_element()).map(|c| c.tag_name().name().to_string()).take(32)); - } else { - out.packets.push(root.tag_name().name().to_string()); - } - } - if out.packets.is_empty() { - out.packets.push("xdp".into()); - } - } - _ => {} - } - acro.remove(b"XFA"); - for key in [b"NeedsRendering".as_slice(), LAYOUT_KEY, crate::script::OVERRIDES_KEY] { - acro.remove(key); - } - // The private keys on every field and widget, down the tree (each dictionary once). - let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); - let mut seen = std::collections::HashSet::new(); - let mut todo: Vec = Vec::new(); - for r in fields.iter().filter_map(Object::as_ref) { - if seen.insert(r) && seen.len() <= MAX_STRIP_FIELDS { - todo.push(r); - } - } - while let Some(r) = todo.pop() { - let Some(d) = doc.get(r).as_dict().cloned() else { continue }; - if d.contains(CLICK_KEY) { - out.script_buttons += 1; - } - if d.contains(IMAGE_FIELD_KEY) { - out.image_fields += 1; - } - let private: Vec> = - [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, NODE_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect(); - if !private.is_empty() { - out.field_keys += private.len(); - doc.update_dict(r, |d| { - for k in &private { - d.remove(k); - } - })?; - } - if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { - for k in kids.iter().filter_map(Object::as_ref) { - if seen.insert(k) && seen.len() <= MAX_STRIP_FIELDS { - todo.push(k); - } - } - } - } - match acro_obj.as_ref() { - Some(r) => doc.set(r, Object::Dict(acro)), - None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)), - } - catalog.remove(b"NeedsRendering"); - out.usage_rights = drop_usage_rights(doc, &mut catalog); - doc.set(root, Object::Dict(catalog)); - // The packet streams are referenced from nowhere else: freed, so an incremental save - // doesn't carry an unreachable copy of the data. - for r in streams { - doc.free(r); - } - doc.require_full_save(); - Ok(out) -} - -/// Values in the datasets that no field of the laid-out form holds (leaf text nodes whose -/// data path no field binds to). `None` when the template can't be read. -fn unbound_data(doc: &Document) -> Option { - let data = crate::script::data_of(doc)?; - let tpl = crate::script::template_of(doc).ok()?; - let bound: std::collections::HashSet = - crate::script::fields_by_som(doc).keys().map(|som| crate::data::data_path(&tpl, som)).collect(); - fn walk( - n: &crate::data::DataNode, - path: &mut crate::data::DataPath, - bound: &std::collections::HashSet, - count: &mut usize, - depth: usize, - ) { - if depth > 64 { - return; - } - let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); - for c in &n.children { - let idx = seen.entry(c.name.as_str()).or_insert(0); - path.push((c.name.clone(), *idx)); - *idx += 1; - if c.children.is_empty() { - if !c.text.trim().is_empty() && !bound.contains(path) { - *count += 1; - } - } else { - walk(c, path, bound, count, depth + 1); - } - path.pop(); - } - } - let mut count = 0; - walk(&data, &mut Vec::new(), &bound, &mut count, 0); - Some(count) -} - -/// Take the usage rights (`/Perms /UR3`, or the older `/UR`) off a catalog, keeping the rest of -/// `/Perms` (a certification's `/DocMDP`). They are signed over the form as it was: once its -/// pages or fields change they are void, and a viewer may lock every field of a form whose -/// usage rights are void, so they go, and the caller says so. Whether there were any. -fn drop_usage_rights(doc: &mut Document, catalog: &mut Dict) -> bool { - let Some(entry) = catalog.get(b"Perms").cloned() else { return false }; - let Some(mut perms) = doc.resolve(&entry).as_dict().cloned() else { return false }; - if !perms.contains(b"UR3") && !perms.contains(b"UR") { - return false; - } - perms.remove(b"UR3"); - perms.remove(b"UR"); - match entry.as_ref() { - _ if perms.is_empty() => { - catalog.remove(b"Perms"); - } - Some(r) => doc.set(r, Object::Dict(perms)), - None => catalog.set(b"Perms".to_vec(), Object::Dict(perms)), - } - true -} - -/// The layout a document already carries from an earlier pass, if any. -pub fn existing_layout(doc: &Document) -> Option { - let root = doc.root()?; - let catalog = doc.get(root); - let acro = doc.resolve(catalog.as_dict()?.get(b"AcroForm")?); - let mark = doc.resolve(acro.as_dict()?.get(LAYOUT_KEY)?); - let mark = mark.as_dict()?; - let warnings = mark - .get(b"Warnings") - .map(|w| doc.resolve(w)) - .and_then(|w| w.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|w| w.as_string().map(|s| s.to_text())) - .take(100) - .collect(); - Some(crate::Report { - pages: mark.int(b"Pages").unwrap_or(0).clamp(0, 100_000) as usize, - fields: mark.int(b"Fields").unwrap_or(0).clamp(0, 1_000_000) as usize, - template_bytes: 0, - warnings, - }) -} - /// The rectangle of a widget in PDF user space (for tests). pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] { [r.x, page_height - r.bottom(), r.right(), page_height - r.y] diff --git a/crates/xfa/src/pdf/common.rs b/crates/xfa/src/pdf/common.rs new file mode 100644 index 0000000000..e1325b19ae --- /dev/null +++ b/crates/xfa/src/pdf/common.rs @@ -0,0 +1,200 @@ +use super::*; + +/// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT +/// markers). `None` for anything else. +pub fn jpeg_size(data: &[u8]) -> Option<(u32, u32)> { + jpeg_frame(data).map(|(w, h, _)| (w, h)) +} + +fn jpeg_frame(data: &[u8]) -> Option<(u32, u32, u8)> { + jpeg_info(data).map(|(w, h, c, _)| (w, h, c)) +} + +/// The SHA-256 of a picture's bytes, to find its XObject again. +pub(super) fn content_hash(data: &[u8]) -> [u8; 32] { + use sha2::Digest; + sha2::Sha256::digest(data).into() +} + +pub(super) fn hex32(h: &[u8; 32]) -> Vec { + h.iter().flat_map(|b| format!("{b:02x}").into_bytes()).collect() +} + +pub(super) fn parse_hex32(s: &[u8]) -> Option<[u8; 32]> { + let mut out = [0u8; 32]; + if s.len() != 64 || !s.iter().all(u8::is_ascii_hexdigit) { + return None; + } + for (o, pair) in out.iter_mut().zip(s.as_chunks::<2>().0) { + *o = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?; + } + Some(out) +} + +/// Width, height, components and whether an Adobe APP14 segment is present (such CMYK JPEGs +/// store inverted values). +pub(super) fn jpeg_info(data: &[u8]) -> Option<(u32, u32, u8, bool)> { + if data.get(0..2)? != [0xFF, 0xD8] { + return None; + } + let mut i = 2usize; + let mut guard = 0; + let mut adobe = false; + while i + 4 <= data.len() && guard < 10_000 { + guard += 1; + if *data.get(i)? != 0xFF { + i += 1; + continue; + } + let marker = *data.get(i + 1)?; + if marker == 0xFF { + i += 1; + continue; + } + if (0xD0..=0xD9).contains(&marker) || marker == 0x01 { + i += 2; + continue; + } + let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize; + if marker == 0xEE && data.get(i + 4..i + 9) == Some(b"Adobe") { + adobe = true; + } + let sof = matches!(marker, 0xC0..=0xCF) && !matches!(marker, 0xC4 | 0xC8 | 0xCC); + if sof { + let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]) as u32; + let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]) as u32; + let comps = *data.get(i + 9)?; + return (w > 0 && h > 0).then_some((w, h, comps, adobe)); + } + i = i.checked_add(2)?.checked_add(len.max(2))?; + } + None +} + +/// Format a number for content streams. +pub(super) fn n(v: f64) -> String { + let v = if v.is_finite() { v } else { 0.0 }; + let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +pub(super) fn rgb(c: [f64; 3]) -> String { + format!("{} {} {}", n(c[0]), n(c[1]), n(c[2])) +} + +pub(super) fn rgb_array(c: [f64; 3]) -> Object { + Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()) +} + +pub(super) fn standard_font(base: &str) -> Object { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + Object::Dict(f) +} + +/// Every face the content streams or `/DA` strings may name. +pub(super) fn all_faces() -> Vec<(&'static str, &'static str)> { + let mut out = Vec::new(); + for fam in [Family::Helvetica, Family::Courier, Family::Times] { + for (b, i) in [(false, false), (true, false), (false, true), (true, true)] { + out.push((fam.resource_name(b, i), fam.base_font(b, i))); + } + } + out +} + +/// The content that draws rounded box `r` on a page `page_h` tall: its fill along its curves, +/// then its edge inside it. A side a page break cut has no edge. +pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String { + let mut c = String::new(); + if let Some(color) = r.fill { + c.push_str(&format!("{} rg {}f\n", rgb(color), outline(r.rect, r.radii, r.inverted, (false, false), page_h))); + } + if let Some((w, color, dashed)) = r.edge { + // The edge's centre line, half its width inside the box, so the edge stays in: a corner + // curving around the box tightens by as much, and one curving into it widens. + let inset = Rect::new(r.rect.x + w / 2.0, r.rect.y + w / 2.0, (r.rect.w - w).max(0.0), (r.rect.h - w).max(0.0)); + let most = inset.w.min(inset.h) / 2.0; + let radii: [f64; 4] = std::array::from_fn(|i| match (r.radii[i], r.inverted[i]) { + (v, _) if v <= 0.0 => 0.0, + (v, true) => (v + w / 2.0).min(most), + (v, false) => (v - w / 2.0).clamp(0.0, most), + }); + let path = outline(inset, radii, r.inverted, (r.open_top, r.open_bottom), page_h); + c.push_str(&format!("q {} RG {} w {}{path}S Q\n", rgb(color), n(w), if dashed { "[2 2] 0 d " } else { "" })); + } + c +} + +/// The path around `rect` (top-left based, on a page `page_h` tall) with the corners `radii` +/// (top-left, top-right, bottom-right, bottom-left), each curving around the box or, when +/// `inverted`, into it. `open` leaves out the top or bottom side (and doesn't close the path): +/// those are where a page break cut the box. Ends with a space, for the paint operator. +fn outline(rect: Rect, radii: [f64; 4], inverted: [bool; 4], open: (bool, bool), page_h: f64) -> String { + let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); + let pt = |q: (f64, f64)| format!("{} {}", n(q.0), n(page_h - q.1)); + // Corner `i`: its point, then where its curve starts and ends, going clockwise. + let corner = |i: usize| { + let r = radii.get(i).copied().unwrap_or(0.0); + match i { + 0 => ((x0, y0), (x0, y0 + r), (x0 + r, y0)), + 1 => ((x1, y0), (x1 - r, y0), (x1, y0 + r)), + 2 => ((x1, y1), (x1, y1 - r), (x1 - r, y1)), + _ => ((x0, y1), (x0 + r, y1), (x0, y1 - r)), + } + }; + // Corner `i`'s curve, from its start: a quarter circle as one Bézier curve. + let curve = |i: usize| { + if radii.get(i).copied().unwrap_or(0.0) <= 0.0 { + return String::new(); + } + let (p, a, b) = corner(i); + const K: f64 = 0.5523; + let (c1, c2) = if inverted.get(i).copied().unwrap_or(false) { + // Centred on the corner itself. + ((a.0 + (b.0 - p.0) * K, a.1 + (b.1 - p.1) * K), (b.0 + (a.0 - p.0) * K, b.1 + (a.1 - p.1) * K)) + } else { + ((a.0 + (p.0 - a.0) * K, a.1 + (p.1 - a.1) * K), (b.0 + (p.0 - b.0) * K, b.1 + (p.1 - b.1) * K)) + }; + format!("{} {} {} c ", pt(c1), pt(c2), pt(b)) + }; + // Along corners `from`, joining them with straight sides. + let along = |from: &[usize]| { + let mut s = String::new(); + for &i in from { + s.push_str(&format!("{} l {}", pt(corner(i).1), curve(i))); + } + s + }; + match open { + (false, false) => format!("{} m {}{}{}{}h ", pt(corner(0).1), curve(0), along(&[1]), along(&[2]), along(&[3])), + (true, false) => format!("{} m {}{} l ", pt((x1, y0)), along(&[2, 3]), pt((x0, y0))), + (false, true) => format!("{} m {}{} l ", pt((x0, y1)), along(&[0, 1]), pt((x1, y1))), + (true, true) => format!("{} m {} l {} m {} l ", pt((x0, y0)), pt((x0, y1)), pt((x1, y0)), pt((x1, y1))), + } +} + +/// A PDF appearance-state name for a check button's on value. +pub(super) fn state_name(on: &str) -> String { + let s: String = on.trim().chars().map(|c| if c.is_whitespace() || "()<>[]{}/%#".contains(c) || c.is_control() { '_' } else { c }).collect(); + if s.is_empty() || s == "Off" { "Yes".into() } else { s } +} + +pub(super) fn empty_form(doc: &mut Document, w: f64, h: f64) -> Object { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|v| Object::Real(*v)).collect())); + Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new())))) +} + +pub(super) fn js_action(js: &str) -> Object { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), Object::String(PdfString::literal(js.as_bytes().to_vec()))); + Object::Dict(a) +} diff --git a/crates/xfa/src/pdf/strip.rs b/crates/xfa/src/pdf/strip.rs new file mode 100644 index 0000000000..de395e37e9 --- /dev/null +++ b/crates/xfa/src/pdf/strip.rs @@ -0,0 +1,282 @@ +use super::*; + +/// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). +pub(crate) const USAGE_RIGHTS_REMOVED: &str = + "the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked"; + +/// What [`strip`] took out, for the user to be told (the packets held more than the fields). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Stripped { + /// The packets removed, by name (`template`, `datasets`, `config`, `signature`, …). + pub packets: Vec, + /// Data values in the datasets no field held (what the form's data had that its fields + /// didn't show); `None` when the template could not be read to tell. + pub unbound_data: Option, + /// Buttons whose click ran an XFA script, and image fields: they do nothing from now on. + pub script_buttons: usize, + pub image_fields: usize, + /// What the layout left out (pictures, rows past the cap), now for good. + pub layout_warnings: Vec, + /// Reader usage rights (`/Perms /UR3`) were removed: changing the form voids them anyway. + pub usage_rights: bool, + /// Private keys removed from field dictionaries (`/PCSom`, `/PCXfaClick`, …). + pub field_keys: usize, +} + +impl Stripped { + /// What was dropped, as lines for the user (empty when nothing worth saying). + pub fn notes(&self) -> Vec { + let mut out = Vec::new(); + if !self.packets.is_empty() { + out.push(format!("The XFA was taken out of the form: its {} packet(s) are gone from the saved file", self.packets.join(", "))); + } + match self.unbound_data { + Some(n) if n > 0 => out.push(format!("{n} value(s) the form's XFA data held that no field showed are gone with it")), + None => out.push("The template could not be read, so data the fields didn't show may be gone with the XFA".into()), + _ => {} + } + if self.script_buttons + self.image_fields > 0 { + out.push(format!("{} button(s) that ran XFA scripts and {} image field(s) do nothing now", self.script_buttons, self.image_fields)); + } + for w in &self.layout_warnings { + out.push(format!("Kept as laid out: {w}")); + } + if self.usage_rights { + out.push("Adobe Reader extended features were removed (changing the form voids them)".into()); + } + out + } +} + +/// What a document's XFA entry says, from its dictionaries alone (no packet decoded). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct XfaShape { + /// `/NeedsRendering true`: the form expects to be laid out from the template. + pub needs_rendering: bool, + /// The AcroForm has fields of its own. + pub has_fields: bool, + /// PdfCraft laid the form out (the layout mark is there). + pub laid_out: bool, +} + +impl XfaShape { + /// Whether taking the XFA out leaves a form every viewer shows: the pages PdfCraft laid out + /// (with their fields), or a static form's own pages and fields. A dynamic form that was + /// never laid out has only placeholder pages: stripping it would leave nothing. + pub fn strippable(self) -> bool { + self.laid_out || (!self.needs_rendering && self.has_fields) + } +} + +/// The document's XFA entry, if any, without decoding a packet. +pub fn xfa_shape(doc: &Document) -> Option { + let root = doc.root()?; + let catalog = doc.get(root); + let catalog = catalog.as_dict()?; + let acro = doc.resolve(catalog.get(b"AcroForm")?); + let acro = acro.as_dict()?; + acro.get(b"XFA")?; + let bool_of = |v: Option<&Object>| matches!(v.map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))); + let needs_rendering = bool_of(catalog.get(b"NeedsRendering")) || bool_of(acro.get(b"NeedsRendering")); + let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false); + Some(XfaShape { needs_rendering, has_fields, laid_out: acro.contains(LAYOUT_KEY) }) +} + +/// Most field dictionaries walked for their private keys. +const MAX_STRIP_FIELDS: usize = 1_000_000; + +/// Take the XFA out of a document, leaving what every viewer shows: the pages as laid out +/// and, when the fields were kept, an ordinary AcroForm. The packets (template, data and +/// all), `/NeedsRendering`, the layout mark, the overrides and Reader usage rights go, and +/// the fields lose the private keys PdfCraft gave them. A flattened form (no fields left) +/// becomes a plain PDF; one with its fields becomes a static form that Adobe's viewers no +/// longer lay out again. What the packets held beyond the fields is counted in the report, +/// so the user can be told; the caller decides whether the form is one to strip +/// ([`XfaShape::strippable`]). A document without XFA is left as it is. +pub fn strip(doc: &mut Document) -> Result { + let mut out = Stripped::default(); + let Some(root) = doc.root() else { return Ok(out) }; + let Some(mut catalog) = doc.get(root).as_dict().cloned() else { return Ok(out) }; + let acro_obj = catalog.get(b"AcroForm").cloned(); + let Some(acro_obj) = acro_obj else { return Ok(out) }; + let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(out) }; + let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(out) }; + // What is about to go, while it can still be read. + out.unbound_data = unbound_data(doc); + out.layout_warnings = existing_layout(doc).map(|r| r.warnings).unwrap_or_default(); + let mut streams: Vec = Vec::new(); + match &*doc.resolve(&xfa) { + Object::Array(items) => { + let mut i = 0; + while i < items.len() { + let name = items.get(i).and_then(|n| n.as_string()).map(|s| s.to_text()); + let stream = if name.is_some() { items.get(i + 1) } else { items.get(i) }; + if let Some(n) = &name { + out.packets.push(n.clone()); + } + if let Some(r) = stream.and_then(Object::as_ref) { + streams.push(r); + } + i += if name.is_some() { 2 } else { 1 }; + } + } + Object::Stream(s) => { + if let Some(r) = xfa.as_ref() { + streams.push(r); + } + // One XDP: the packets it holds are the envelope's children. + let text = s.decoded_within(64 << 20).ok().and_then(|b| crate::packets::decode(&b)).map(|(t, _)| t).unwrap_or_default(); + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 1_000_000 }; + if crate::xml_nesting_depth(&text) <= crate::MAX_XML_DEPTH + && let Ok(parsed) = roxmltree::Document::parse_with_options(&text, opts) + { + let root = parsed.root_element(); + if root.tag_name().name() == "xdp" { + out.packets.extend(root.children().filter(|c| c.is_element()).map(|c| c.tag_name().name().to_string()).take(32)); + } else { + out.packets.push(root.tag_name().name().to_string()); + } + } + if out.packets.is_empty() { + out.packets.push("xdp".into()); + } + } + _ => {} + } + acro.remove(b"XFA"); + for key in [b"NeedsRendering".as_slice(), LAYOUT_KEY, crate::script::OVERRIDES_KEY] { + acro.remove(key); + } + // The private keys on every field and widget, down the tree (each dictionary once). + let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); + let mut seen = std::collections::HashSet::new(); + let mut todo: Vec = Vec::new(); + for r in fields.iter().filter_map(Object::as_ref) { + if seen.insert(r) && seen.len() <= MAX_STRIP_FIELDS { + todo.push(r); + } + } + while let Some(r) = todo.pop() { + let Some(d) = doc.get(r).as_dict().cloned() else { continue }; + if d.contains(CLICK_KEY) { + out.script_buttons += 1; + } + if d.contains(IMAGE_FIELD_KEY) { + out.image_fields += 1; + } + let private: Vec> = + [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, NODE_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect(); + if !private.is_empty() { + out.field_keys += private.len(); + doc.update_dict(r, |d| { + for k in &private { + d.remove(k); + } + })?; + } + if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + for k in kids.iter().filter_map(Object::as_ref) { + if seen.insert(k) && seen.len() <= MAX_STRIP_FIELDS { + todo.push(k); + } + } + } + } + match acro_obj.as_ref() { + Some(r) => doc.set(r, Object::Dict(acro)), + None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)), + } + catalog.remove(b"NeedsRendering"); + out.usage_rights = drop_usage_rights(doc, &mut catalog); + doc.set(root, Object::Dict(catalog)); + // The packet streams are referenced from nowhere else: freed, so an incremental save + // doesn't carry an unreachable copy of the data. + for r in streams { + doc.free(r); + } + doc.require_full_save(); + Ok(out) +} + +/// Values in the datasets that no field of the laid-out form holds (leaf text nodes whose +/// data path no field binds to). `None` when the template can't be read. +fn unbound_data(doc: &Document) -> Option { + let data = crate::script::data_of(doc)?; + let tpl = crate::script::template_of(doc).ok()?; + let bound: std::collections::HashSet = + crate::script::fields_by_som(doc).keys().map(|som| crate::data::data_path(&tpl, som)).collect(); + fn walk( + n: &crate::data::DataNode, + path: &mut crate::data::DataPath, + bound: &std::collections::HashSet, + count: &mut usize, + depth: usize, + ) { + if depth > 64 { + return; + } + let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for c in &n.children { + let idx = seen.entry(c.name.as_str()).or_insert(0); + path.push((c.name.clone(), *idx)); + *idx += 1; + if c.children.is_empty() { + if !c.text.trim().is_empty() && !bound.contains(path) { + *count += 1; + } + } else { + walk(c, path, bound, count, depth + 1); + } + path.pop(); + } + } + let mut count = 0; + walk(&data, &mut Vec::new(), &bound, &mut count, 0); + Some(count) +} + +/// Take the usage rights (`/Perms /UR3`, or the older `/UR`) off a catalog, keeping the rest of +/// `/Perms` (a certification's `/DocMDP`). They are signed over the form as it was: once its +/// pages or fields change they are void, and a viewer may lock every field of a form whose +/// usage rights are void, so they go, and the caller says so. Whether there were any. +pub(super) fn drop_usage_rights(doc: &mut Document, catalog: &mut Dict) -> bool { + let Some(entry) = catalog.get(b"Perms").cloned() else { return false }; + let Some(mut perms) = doc.resolve(&entry).as_dict().cloned() else { return false }; + if !perms.contains(b"UR3") && !perms.contains(b"UR") { + return false; + } + perms.remove(b"UR3"); + perms.remove(b"UR"); + match entry.as_ref() { + _ if perms.is_empty() => { + catalog.remove(b"Perms"); + } + Some(r) => doc.set(r, Object::Dict(perms)), + None => catalog.set(b"Perms".to_vec(), Object::Dict(perms)), + } + true +} + +/// The layout a document already carries from an earlier pass, if any. +pub fn existing_layout(doc: &Document) -> Option { + let root = doc.root()?; + let catalog = doc.get(root); + let acro = doc.resolve(catalog.as_dict()?.get(b"AcroForm")?); + let mark = doc.resolve(acro.as_dict()?.get(LAYOUT_KEY)?); + let mark = mark.as_dict()?; + let warnings = mark + .get(b"Warnings") + .map(|w| doc.resolve(w)) + .and_then(|w| w.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|w| w.as_string().map(|s| s.to_text())) + .take(100) + .collect(); + Some(crate::Report { + pages: mark.int(b"Pages").unwrap_or(0).clamp(0, 100_000) as usize, + fields: mark.int(b"Fields").unwrap_or(0).clamp(0, 1_000_000) as usize, + template_bytes: 0, + warnings, + }) +} From f64ee67181ee2807bc80d5455224ce231ba3b29c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:12:15 +0000 Subject: [PATCH 086/246] docs(parity): point evidence at combine_ui's moved tests --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 10c46d4dff..a50c905fc4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3051,7 +3051,7 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/ui-egui/tests/it/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/it/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/it/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/it/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/it/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/it/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid/tests.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid/tests.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid/tests.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/it/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/src/combine_grid/tests.rs::the_cards_zoom_far_enough_to_read_a_value", "crates/ui-egui/tests/it/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/it/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/it/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/it/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/it/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/it/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/it/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/it/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/it/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/it/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/it/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/it/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/it/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/it/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/it/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/it/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid/tests.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/it/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/it/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/it/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/it/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/it/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"] +evidence = ["crates/ui-egui/tests/it/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/it/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/it/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/it/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/it/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/it/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid/tests.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid/tests.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid/tests.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/it/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/it/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/src/combine_grid/tests.rs::the_cards_zoom_far_enough_to_read_a_value", "crates/ui-egui/tests/it/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/it/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/it/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/it/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/it/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/it/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/it/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/it/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/it/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/it/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/it/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/it/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/it/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/it/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/it/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/it/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/it/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/it/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid/tests.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/it/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/it/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui/tests.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui/tests.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui/tests.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/it/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/it/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/it/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"] notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 500% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; the buttons step 60/80/100/125/150/175/200/250/300/400/500%, kept in the settings, --combine-zoom 60..500), keeping the card under the pointer in place. 200% used to be the ceiling, which was too small to read values off an invoice while reordering the pages of a statement (#2531). Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 1536 px (room for a Letter page at 500% on a 1.5 pixels-per-point screen); at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. A file that adds more than one page (up to 2000) expands into its pages (hover bar, right-click, or Expand all / Collapse all in the toolbar): one card per page it adds, in its range's order, each saying Page N and the file's name, on a tinted band; a click on a page selects that page, Up/Down step rows of cards, dragging a file's card moves the whole file (a drop among a file's pages goes to its nearer end), and its magnifier opens the preview on that page; expanding is a view, not an undo step, and changes nothing in the combined file; a page's render reuses its file's open document for a moment rather than reading the file again. With a file expanded, single pages are selected (click, Ctrl, Shift), dragged anywhere (a page moved between another file's pages splits its file into parts, shown as rows in the list with their own page ranges; parts side by side join again) and removed (a page card's trash, the toolbar, Delete), each one undo step, never changing the files themselves; a split file still counts and combines as one file." [[feature]] From d84f8baf9d645fa0ef478f67e74c4a0b699583e8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:29:44 +0000 Subject: [PATCH 087/246] refactor(engine): split session.rs into session/ New modules: session/view.rs, session/editing.rs, session/save.rs, session/files.rs (all impl Session methods). --- crates/engine/src/session.rs | 1552 +------------------------- crates/engine/src/session/editing.rs | 521 +++++++++ crates/engine/src/session/files.rs | 397 +++++++ crates/engine/src/session/save.rs | 335 ++++++ crates/engine/src/session/view.rs | 304 +++++ 5 files changed, 1562 insertions(+), 1547 deletions(-) create mode 100644 crates/engine/src/session/editing.rs create mode 100644 crates/engine/src/session/files.rs create mode 100644 crates/engine/src/session/save.rs create mode 100644 crates/engine/src/session/view.rs 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 + } +} From b51aee6153b997c38215e48b34dc354adbee6c64 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:33:23 +0000 Subject: [PATCH 088/246] refactor(engine): split edit.rs into edit/ New modules: edit/labels.rs (labels, impl Edit, add_annotation, plural), edit/run.rs (check_permission, EditCtx, run_edit). --- crates/engine/src/edit.rs | 1036 +----------------------------- crates/engine/src/edit/labels.rs | 336 ++++++++++ crates/engine/src/edit/run.rs | 696 ++++++++++++++++++++ 3 files changed, 1038 insertions(+), 1030 deletions(-) create mode 100644 crates/engine/src/edit/labels.rs create mode 100644 crates/engine/src/edit/run.rs diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index faf4712078..c31984ca19 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -9,6 +9,12 @@ use crate::{ image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; +mod labels; +mod run; + +pub use labels::{BATCH_LABELS, EDIT_LABELS}; +pub(crate) use run::{EditCtx, check_permission, run_edit}; + /// Edits that can be applied to a document. Page indices are 0-based. #[derive(Clone, Debug, PartialEq)] pub enum Edit { @@ -545,1033 +551,3 @@ pub enum Edit { edits: Vec, }, } - -/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not -/// here: its wording is only the hole, because a batch carries its name as text (see -/// [`BATCH_LABELS`]). -pub const EDIT_LABELS: &[&str] = &[ - "Rotate page", - "Change tag properties", - "Rotate pages", - "Delete page", - "Delete pages", - "Move page", - "Move pages", - "Reverse pages", - "Insert blank page", - "Duplicate page", - "Duplicate pages", - "Replace page", - "Replace pages", - "Crop page", - "Crop pages", - "Set page boxes", - "Set page transitions", - "Change Title", - "Change Author", - "Change Subject", - "Change Keywords", - "Change {key}", - "Insert pages from {name}", - "Insert pages from {name} — 1 field renamed", - "Insert pages from {name} — fields renamed", - "Attach {name}", - "Delete attachment {key}", - "Add bookmark", - "Rename bookmark", - "Delete bookmark", - "Move bookmark", - "Set bookmark destination", - "Colour", - "Style", - "New bookmarks from structure", - "Number pages", - "Measure distance", - "Measure perimeter", - "Measure area", - "Set measurement scale", - "Add sticky note", - "Add highlight", - "Add underline", - "Add strikethrough", - "Add squiggly underline", - "Add rectangle", - "Add oval", - "Add arrow", - "Add line", - "Add drawing", - "Add text box", - "Add checkmark", - "Add cross", - "Add dot", - "Add signature", - "Add redaction mark", - "Add cloud", - "Add polygon", - "Add connected lines", - "Add callout", - "Add inserted text", - "Add file attachment", - "Add stamp", - "Delete comment", - "Edit comment", - "Reply", - "Set status None", - "Set status Accepted", - "Set status Rejected", - "Set status Cancelled", - "Set status Completed", - "Replace text", - "Erase", - "Mark with checkmark", - "Remove checkmark", - "Lock comment", - "Unlock comment", - "Move comment", - "Reshape comment", - "Resize comment", - "Change comment properties", - "Fill in {name}", - "Set the image of {name}", - "Clear form", - "Add field", - "Change field properties", - "Delete field", - "Duplicate field", - "Set tab order", - "Set calculation order", - "Change initial view", - "Set alternate text", - "Mark figure as decorative", - "Recognize text", - "Run JavaScript", - "Run form script", - "Save as {level}", - "Remove {standard} identification", - "Edit script of {name}", - "Delete document JavaScript", - "Edit document JavaScript", - "Edit text", - "Load document font", - "Move object", - "Move objects", - "Move image", - "Rotate image", - "Flip image", - "Replace image", - "Delete image", - "Add header & footer", - "Update header & footer", - "Add watermark", - "Update watermark", - "Add background", - "Update background", - "Remove header & footer", - "Remove watermark", - "Remove background", - "Add text", - "Add image", - "Edit content", - "Delete content", - "Paste", - "Apply redactions", - "Remove redaction marks", - "Remove hidden information", - "Import {name}", - "Add link", - "Change link properties", - "Delete link", - "Remove all links", - "Create link", - "Create links", - "Sanitize document", - "Flatten comments", - "Flatten form fields", - "Flatten", - "Flatten Fill & Sign", - "Protect with password", - "Remove security", -]; - -/// The names of the batches built below L7, which an interface shows in the same places as -/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives -/// already worded in the interface's language. -pub const BATCH_LABELS: &[&str] = - &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; - -impl Edit { - /// Label for the Edit menu and history ("Undo Rotate pages"). - pub fn label(&self) -> Label { - match self { - Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), - Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), - Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), - Edit::ReversePages { .. } => Label::of("Reverse pages"), - Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), - Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), - Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), - Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), - Edit::SetPageBox { .. } => Label::of("Set page boxes"), - Edit::SetTransition { .. } => Label::of("Set page transitions"), - // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can - // be said in another language; any other key is the document's and goes in as it stands. - Edit::SetInfo { key, .. } => match key.as_str() { - "Title" => Label::of("Change Title"), - "Author" => Label::of("Change Author"), - "Subject" => Label::of("Change Subject"), - "Keywords" => Label::of("Change Keywords"), - _ => Label::with("Change {key}", key.as_str()), - }, - Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), - Edit::AddBookmark { .. } => Label::of("Add bookmark"), - Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), - Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), - Edit::MoveBookmark { .. } => Label::of("Move bookmark"), - Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), - // The bookmark Style and Colour menus' own names, already said in every language. - Edit::SetBookmarkColor { .. } => Label::of("Colour"), - Edit::SetBookmarkStyle { .. } => Label::of("Style"), - Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), - Edit::NumberPages { .. } => Label::of("Number pages"), - // What was measured is part of the wording too, for the same reason. - Edit::AddMeasurement(m) => Label::of(match m.kind { - pdfcraft_measure::Kind::Distance => "Measure distance", - pdfcraft_measure::Kind::Perimeter => "Measure perimeter", - pdfcraft_measure::Kind::Area => "Measure area", - }), - Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), - Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), - Edit::AddCustomStamp { .. } => Label::of("Add stamp"), - Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), - Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), - Edit::ReplyToAnnotation { .. } => Label::of("Reply"), - // The state's name is part of the wording: a language may need to agree it with the rest. - Edit::SetAnnotationStatus { state, .. } => Label::of(match state { - ReviewState::None => "Set status None", - ReviewState::Accepted => "Set status Accepted", - ReviewState::Rejected => "Set status Rejected", - ReviewState::Cancelled => "Set status Cancelled", - ReviewState::Completed => "Set status Completed", - }), - Edit::ReplaceText { .. } => Label::of("Replace text"), - Edit::EraseInk { .. } => Label::of("Erase"), - Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), - Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), - Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), - Edit::LockAnnotation { .. } => Label::of("Unlock comment"), - Edit::MoveAnnotation { .. } => Label::of("Move comment"), - Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), - Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), - Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { - Label::of("Change comment properties") - } - Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), - Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), - Edit::ResetForm { .. } => Label::of("Clear form"), - Edit::AddField { .. } => Label::of("Add field"), - Edit::SetFieldProps { .. } => Label::of("Change field properties"), - Edit::DeleteField { .. } => Label::of("Delete field"), - Edit::DuplicateField { .. } => Label::of("Duplicate field"), - Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), - Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), - Edit::SetInitialView(_) => Label::of("Change initial view"), - Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), - Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), - Edit::AutoTagText => Label::of("Automatically tag text"), - Edit::SetAltText { .. } => Label::of("Set alternate text"), - Edit::AutoTagFormFields => Label::of("Tag form fields"), - Edit::SetTagProperty { .. } => Label::of("Change tag properties"), - Edit::MoveTag { .. } => Label::of("Move object"), - Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), - Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), - Edit::AddOcrText { .. } => Label::of("Recognize text"), - Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), - Edit::XfaEvent { .. } => Label::of("Run form script"), - // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. - Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), - Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), - Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), - Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), - Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), - Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), - Edit::RegisterTextFont { .. } => Label::of("Load document font"), - Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), - Edit::EditPageImage { change, .. } => Label::of(match change { - ImageEdit::Move(_) => "Move image", - ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", - ImageEdit::Crop(_) => "Crop image", - ImageEdit::Flip { .. } => "Flip image", - ImageEdit::Replace { .. } => "Replace image", - ImageEdit::Delete => "Delete image", - }), - Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), - Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), - Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), - Edit::AddWatermark { .. } => Label::of("Update watermark"), - Edit::AddBackground { replace: false, .. } => Label::of("Add background"), - Edit::AddBackground { .. } => Label::of("Update background"), - Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), - Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), - Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), - Edit::AddText { .. } => Label::of("Add text"), - Edit::AddImage { .. } => Label::of("Add image"), - Edit::UpdateContent { .. } => Label::of("Edit content"), - Edit::DeleteContent { .. } => Label::of("Delete content"), - Edit::ReplaceImage { .. } => Label::of("Replace image"), - Edit::PasteContent { .. } => Label::of("Paste"), - Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), - Edit::ClearRedactions => Label::of("Remove redaction marks"), - Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), - Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), - Edit::AddLink { .. } => Label::of("Add link"), - Edit::SetLink { .. } => Label::of("Change link properties"), - Edit::DeleteLink { .. } => Label::of("Delete link"), - Edit::RemoveLinks { .. } => Label::of("Remove all links"), - Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), - Edit::Sanitize => Label::of("Sanitize document"), - Edit::XfaToStatic => Label::of("Convert to a static form"), - Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), - Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), - Edit::Flatten { .. } => Label::of("Flatten"), - Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), - Edit::Protect(_) => Label::of("Protect with password"), - Edit::RemoveProtection => Label::of("Remove security"), - // A batch carries its own name, already worded by whoever built it. - // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). - Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), - } - } -} - -/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, -/// so a language can give the noun the article and the gender its own grammar asks for. -fn add_annotation(s: &Shape) -> &'static str { - match s { - Shape::Note { .. } => "Add sticky note", - Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", - Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", - Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", - Shape::Rectangle { .. } => "Add rectangle", - Shape::Oval { .. } => "Add oval", - Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", - Shape::Line { .. } => "Add line", - Shape::Ink { .. } => "Add drawing", - Shape::TextBox { .. } => "Add text box", - Shape::Typewriter { .. } => "Add text", - Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", - Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", - Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", - Shape::Mark { mark: FillMark::Line, .. } => "Add line", - Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", - Shape::Redact { .. } => "Add redaction mark", - Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", - Shape::Polygon { cloud: true, .. } => "Add cloud", - Shape::Polygon { .. } => "Add polygon", - Shape::PolyLine { .. } => "Add connected lines", - Shape::Callout { .. } => "Add callout", - Shape::Caret { .. } => "Add inserted text", - Shape::Attachment { .. } => "Add file attachment", - } -} - -/// Whether the opening password allows an edit (§7.6.4.2, Table 22). -pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { - match edit { - Edit::RotatePages { .. } - | Edit::DeletePages { .. } - | Edit::MovePages { .. } - | Edit::ReversePages { .. } - | Edit::InsertBlankPage { .. } - | Edit::DuplicatePages { .. } - | Edit::ReplacePages { .. } - | Edit::SetPageBox { .. } - | Edit::SetTransition { .. } - | Edit::InsertPagesFrom { .. } - // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). - | Edit::AddBookmark { .. } - | Edit::RenameBookmark { .. } - | Edit::DeleteBookmark { .. } - | Edit::MoveBookmark { .. } - | Edit::SetBookmarkPage { .. } - | Edit::SetBookmarkColor { .. } - | Edit::SetBookmarkStyle { .. } - | Edit::BookmarksFromStructure - | Edit::NumberPages { .. } => { - if p.assemble() { - Ok(()) - } else { - Err(EditError::NotPermitted("page changes")) - } - } - Edit::AddMeasurement(_) - | Edit::AddAnnotation(_) - | Edit::AddCustomStamp { .. } - | Edit::DeleteAnnotation { .. } - | Edit::SetAnnotationContents { .. } - | Edit::ReplyToAnnotation { .. } - | Edit::SetAnnotationStatus { .. } - | Edit::MarkAnnotation { .. } - | Edit::ReplaceText { .. } - | Edit::EraseInk { .. } - | Edit::LockAnnotation { .. } - | Edit::MoveAnnotation { .. } - | Edit::ReshapeAnnotation { .. } - | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } - | Edit::FillAnnotation { .. } - | Edit::FontSizeAnnotation { .. } - | Edit::SetAnnotationInfo { .. } - | Edit::SetMeasurementScale { .. } => { - if p.annotate() { - Ok(()) - } else { - Err(EditError::NotPermitted("comments")) - } - } - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { - if p.fill_forms() { - Ok(()) - } else { - Err(EditError::NotPermitted("filling in form fields")) - } - } - Edit::Protect(_) | Edit::RemoveProtection => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("changing security")) - } - } - // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted - // document it is a change of security (#3921). - Edit::ConvertPdfA { .. } => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) - } - } - Edit::SetInfo { .. } - | Edit::AddHeaderFooter { .. } - | Edit::AddWatermark { .. } - | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } - | Edit::AddField { .. } - | Edit::ApplyRedactions { .. } - | Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::MoveObjects { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - | Edit::ClearRedactions - | Edit::RemoveHidden { .. } - | Edit::ImportData { .. } - | Edit::AddLink { .. } - | Edit::SetLink { .. } - | Edit::DeleteLink { .. } - | Edit::RemoveLinks { .. } - | Edit::AddLinks { .. } - | Edit::Sanitize - | Edit::XfaToStatic - | Edit::SetFieldProps { .. } - | Edit::DeleteField { .. } - | Edit::DuplicateField { .. } - | Edit::SetTabOrder { .. } - | Edit::MoveInTabOrder { .. } - | Edit::SetCalculationOrder { .. } - | Edit::SetInitialView(_) - | Edit::AddAttachment { .. } - | Edit::DeleteAttachment { .. } - | Edit::AutoTagText - | Edit::SetAltText { .. } - | Edit::AutoTagFormFields - | Edit::SetTagProperty { .. } - | Edit::MoveTag { .. } - | Edit::MarkDecorative { .. } - | Edit::MarkTagArtifact { .. } - | Edit::AddOcrText { .. } - | Edit::SetDocumentScript { .. } - | Edit::SetFieldScript { .. } - | Edit::RemoveStandardId { .. } - | Edit::EditTextLine { .. } - | Edit::EditTextBlock { .. } - | Edit::RegisterTextFont { .. } - | Edit::EditPageImage { .. } - | Edit::Flatten { .. } - | Edit::FlattenFillSign => { - if p.modify() { - Ok(()) - } else { - Err(EditError::NotPermitted("changes to the document")) - } - } - Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), - } -} - -/// Dates and unique ids stamped onto what an edit creates. -pub(crate) struct EditCtx { - /// Field scripts run (`None`: JavaScript is off) and what they produced. - pub(crate) js: Option, - /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what - /// its scripts produced. - pub(crate) xfa: Option>, - pub(crate) xfa_out: js::JsOutput, - /// The datasets stream this edit wrote (later writes in the edit replace it in place). - pub(crate) xfa_datasets: Option, - /// A form script ran too long and was abandoned: the document's scripts go off. - pub(crate) xfa_ran_away: bool, - /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). - pub(crate) xfa_time_scale: u32, - /// The edit took the XFA out of the form: what went with it, for the document's warnings. - pub(crate) xfa_removed: Vec, - /// Pages in the document, for `xfa.layout.pageCount()`. - pub(crate) pages: usize, - pub(crate) date: Option, - /// Today in local time, for date tokens. - pub(crate) today: (i64, u32, u32), - pub(crate) seed: u64, - pub(crate) count: u64, - /// Fields the edit renamed to keep imported names separate (old → new), for the repair - /// log and the undo label. - pub(crate) renamed_fields: Vec<(String, String)>, - /// The overlay streams the edit's applied redactions added, which alone the renderer's check - /// leaves out. - pub(crate) redaction_overlays: Vec, -} - -impl EditCtx { - pub(crate) fn new(now: Option, salt: u64) -> Self { - let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; - if let Some(t) = now { - seed ^= (t as u64).rotate_left(17); - } - #[cfg(not(target_arch = "wasm32"))] - if now.is_some() { - // Real clock: mix in sub-second time so ids from two sessions don't collide. - seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; - } - Self { - js: None, - xfa: None, - xfa_out: Default::default(), - xfa_datasets: None, - xfa_ran_away: false, - xfa_time_scale: 1, - xfa_removed: Vec::new(), - pages: 0, - date: now.map(pdfcraft_cos::pdf_date), - today: (1970, 1, 1), - seed, - count: 0, - renamed_fields: Vec::new(), - redaction_overlays: Vec::new(), - } - } - - /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the - /// operating system, so no extra dependency is needed. - fn entropy(&mut self) -> [u8; 32] { - use std::hash::{BuildHasher, Hasher}; - let mut out = [0u8; 32]; - for (i, chunk) in out.chunks_mut(8).enumerate() { - let mut h = std::collections::hash_map::RandomState::new().build_hasher(); - h.write_u64(self.seed ^ i as u64); - h.write_u64(self.count); - self.count += 1; - chunk.copy_from_slice(&h.finish().to_le_bytes()); - } - out - } - - /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. - fn meta(&mut self) -> pdfcraft_annot::Meta { - let mut next = || { - self.count += 1; - let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - }; - let (a, b) = (next(), next()); - let id = format!( - "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", - a >> 32, - (a >> 16) & 0xffff, - a & 0xfff, - 0x8000 | (b >> 48) & 0x3fff, - b & 0xffff_ffff_ffff - ); - pdfcraft_annot::Meta { date: self.date.clone(), id } - } -} - -/// Perform an edit on a working copy (the caller discards it on error). -pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { - match edit { - Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, - Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, - Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, - Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, - Edit::InsertBlankPage { at, width, height } => { - pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; - } - Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, - Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, - Edit::ReplacePages { pages, name, bytes, src_pages } => { - let src = open_source(name, bytes)?; - pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; - } - Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, - Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, - Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { - let src = open_source(name, bytes)?; - let pages = match pages { - Some(p) => p.clone(), - None => (0..pdfcraft_organize::page_count(&src)?).collect(), - }; - // The file's bookmarks come along under one named after it, as in Combine Files. - let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); - let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; - cx.renamed_fields = outcome.renamed_fields; - } - Edit::AddBookmark { parent, index, title, page, left, top } => { - pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; - } - Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?, - Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?, - Edit::MoveBookmark { from, to_parent, index } => { - pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; - } - Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, - Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, - Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, - Edit::BookmarksFromStructure => { - let entries: Vec<_> = pdfcraft_a11y::headings(doc) - .into_iter() - .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) - .collect(); - pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; - } - Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, - Edit::AddMeasurement(m) => { - measure::add(doc, m, &cx.meta())?; - } - Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, - Edit::AddAnnotation(a) => { - pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; - } - Edit::AddCustomStamp { page, rect, name, file, author } => { - let src = mark_source(doc, file)?; - let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); - let natural = rect[2] == rect[0] && rect[3] == rect[1]; - let rotation = pdfcraft_organize::page_rotation(doc, *page)?; - // The picture as displayed: a quarter turn swaps its sides in user space. - let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; - let rect = if natural { - let k = (200.0 / sw.max(sh)).min(1.0); - let (w, h) = (dw * k, dh * k); - [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] - } else { - fit_picture(*rect, dw, dh) - }; - let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; - let style = pdfcraft_annot::Style::default_for(&shape); - let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; - let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; - pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; - } - Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, - Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, - Edit::ReplyToAnnotation { page, index, text, author } => { - pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; - } - Edit::SetAnnotationStatus { page, index, state, author } => { - pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; - } - Edit::EraseInk { page, index, path, radius } => { - pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; - } - Edit::AddAttachment { name, bytes, description, mime } => { - pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; - } - Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, - Edit::ReplaceText { page, quads, text, author, strike, caret } => { - // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by - // the strikeout's `/NM` (#2666). - let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; - } - Edit::MarkAnnotation { page, index, marked, author } => { - pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; - } - Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, - Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, - Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, - Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, - Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { - pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; - } - Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, - Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, - Edit::SetAnnotationInfo { page, index, author, subject, icon } => { - pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; - } - Edit::SetFieldValue { name, value } => { - let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { - Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), - None => pdfcraft_forms::set_value(doc, name, value), - }; - match set(doc, value) { - Ok(()) => {} - // A date the field's format doesn't read may be typed by another of an XFA date - // field's patterns: it is kept in the first. Only a format refusal is tried - // again: it comes before any of the field's scripts run, so they run once. - Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { - Some(typed) => set(doc, &typed)?, - None => return Err(refused.into()), - }, - Err(refused) => return Err(refused.into()), - } - } - Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { - Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, - None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, - }, - Edit::XfaEvent { som, activity } => { - let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; - 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 }; - let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - ran.map_err(EditError::Invalid)?; - } - Edit::SetFieldImage { name, image } => { - let (img, _) = embed_image(doc, name, image)?; - let px = match &*doc.get(img) { - pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), - _ => (1, 1), - }; - // An XFA image field keeps the picture in the data too (as Designer saves it), and a - // laid-out form shows it from there: so only pictures the layout can show, and only - // for image fields (a laid-out one carries its SOM path; a static form's field name - // is its path). - let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); - let image_field = is_xfa - && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); - if image_field { - let Some(content_type) = image_content_type(image) else { - return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); - }; - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); - let path = match cx.xfa.as_deref() { - Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), - None => pdfcraft_xfa::som_to_path(&som), - }; - let r = - pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; - if !r.written && r.warnings.is_empty() { - return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); - } - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - xfa_after_data_change(doc, cx, false)?; - } else { - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - } - } - Edit::ResetForm { names } => { - pdfcraft_forms::reset(doc, names.as_deref())?; - } - Edit::AddField { page, rect, kind, name } => { - pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; - } - Edit::SetFieldProps { name, props } => { - pdfcraft_forms::set_props(doc, name, props)?; - } - Edit::DuplicateField { name, pages } => { - if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { - return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); - } - } - Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, - Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, - Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, - // As after a script edit: the values the new order gives show at once. - Edit::SetCalculationOrder { names } => { - pdfcraft_forms::set_calculation_order(doc, names)?; - match cx.js.as_mut() { - Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, - None => pdfcraft_forms::recalculate(doc)?, - }; - } - Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, - Edit::AutoTagText => { - a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::SetAltText { figure, alt } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AutoTagFormFields => { - a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; - } - Edit::SetTagProperty { object, property, value } => { - a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MoveTag { object, anchor, placement } => { - a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::EditTextLine { page, line, text } => { - pdfcraft_edit::replace_line(doc, *page, *line, text)?; - } - Edit::SetFieldScript { name, event, script } => { - pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; - match cx.js.as_mut() { - Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, - None => pdfcraft_forms::recalculate(doc)?, - }; - } - Edit::ConvertPdfA { level } => { - pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; - } - Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, - Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, - Edit::AddOcrText { page, words } => { - pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; - } - Edit::EditPageImage { page, index, change } => { - let img = pdfcraft_edit::page_images(doc, *page)? - .into_iter() - .nth(*index) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; - let c = match change { - ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), - ImageEdit::Rotate(q) => { - pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) - } - ImageEdit::RotateBy(degrees) => { - let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; - pdfcraft_edit::ImageChange::Transform(t) - } - ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( - img.visible_rect.unwrap_or(img.rect), - 0, - *horizontal, - !*horizontal, - )), - ImageEdit::Replace { name, bytes } => { - if img.is_form { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( - "grouped Form artwork cannot be replaced with a bitmap".into(), - ))); - } - pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) - } - ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), - ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, - }; - pdfcraft_edit::change_image(doc, *page, *index, &c)?; - } - Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, - Edit::EditTextBlock { page, block, text, style } => { - pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; - } - Edit::RegisterTextFont { page, bytes } => { - pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; - } - Edit::MarkDecorative { figure } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MarkTagArtifact { object } => { - a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AddHeaderFooter { pages, settings, replace } => { - let date = cx.today; - pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; - } - Edit::AddWatermark { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? - } - Edit::AddBackground { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_background(doc, pages, &s, *replace)? - } - Edit::RemoveMarks { kind } => { - let n = pdfcraft_model::pages(doc).len(); - if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); - } - } - Edit::AddText { page, text } => { - pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; - } - Edit::AddImage { page, rect, name, bytes } => { - let (image, natural) = embed_image(doc, name, bytes)?; - let rect = match rect { - Some(r) => *r, - None => { - let p = pdfcraft_model::pages(doc).swap_remove(*page); - let (pw, ph) = p.display_size(doc); - let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); - let (w, h) = (natural.0 * k, natural.1 * k); - [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] - } - }; - pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; - } - Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, - Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, - Edit::ReplaceImage { page, index, name, bytes } => { - let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); - let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); - }; - let (image, _) = embed_image(doc, name, bytes)?; - pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; - } - Edit::PasteContent { page, content, file } => { - let content = match content { - AddedContent::Text(_) => content.clone(), - AddedContent::Image(img) => { - let Some((name, bytes)) = file else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); - }; - // The same picture in this document (copied here, pasted here before, or an - // identical file): drawn again from the one image object rather than stored twice. - let image = if copy_image::is_picture(doc, img.image, bytes) { - img.image - } else { - match copy_image::added_picture(doc, bytes) { - Some(image) => image, - None => embed_image(doc, name, bytes)?.0, - } - }; - AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) - } - }; - pdfcraft_edit::add_content(doc, *page, &content)?; - } - Edit::ApplyRedactions { pages } => { - cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; - } - Edit::ClearRedactions => { - pdfcraft_redact::clear_marks(doc, None)?; - } - Edit::AddLink { page, rect, action, style } => { - pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; - } - Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, - Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, - Edit::RemoveLinks { pages } => { - if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); - } - } - Edit::AddLinks { links, style } => { - if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); - } - } - Edit::ImportData { bytes, .. } => { - // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; - // XFDF, FDF, Acrobat's XML and tables go the usual way. - let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); - match text { - Some(Ok(text)) if is_xfa_data_file(&text) => { - let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); - let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) - .map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); - xfa_after_data_change(doc, cx, true)?; - } - Some(Err(why)) => return Err(EditError::Invalid(why)), - _ => { - pdfcraft_xfdf::import(doc, bytes)?; - } - } - } - Edit::RemoveHidden { which } => { - pdfcraft_redact::sanitize::remove_hidden(doc, which)?; - } - Edit::Sanitize => { - pdfcraft_redact::sanitize::sanitize(doc)?; - } - Edit::Flatten { comments, fields } => { - // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers - // would lay the form out again from its template over the baked pages. A dynamic - // form that was never laid out keeps its XFA: without it only placeholders remain. - let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; - match shape { - Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, - Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), - None => {} - } - } - Edit::XfaToStatic => { - let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { - return Err(EditError::Invalid("this document is not an XFA form".into())); - }; - if !shape.strippable() { - return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); - } - strip_xfa(doc, cx)?; - } - Edit::FlattenFillSign => { - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; - } - Edit::Protect(p) => { - p.validate()?; - let seed = cx.entropy(); - // Without a permissions password nothing is restricted, so the owner password is a - // random one nobody needs. - let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); - let params = pdfcraft_cos::NewEncryption { - algorithm: p.algorithm, - user_password: p.open_password.as_deref().unwrap_or(""), - owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), - permissions: p.permission_bits(), - encrypt_metadata: p.encrypt_metadata, - seed, - }; - doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; - } - Edit::RemoveProtection => { - if doc.output_handler().is_none() { - return Err(EditError::Protection("the document isn't protected".into())); - } - doc.remove_encryption(); - } - Edit::Batch { edits, .. } => { - for e in edits { - run_edit(doc, e, cx)?; - } - } - } - Ok(()) -} - -/// The singular or the plural wording, by count. Both are written out: a language forms its plural -/// its own way, and `one` and `many` may differ in more than an "s". -fn plural(one: &'static str, many: &'static str, n: usize) -> Label { - Label::of(if n == 1 { one } else { many }) -} diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs new file mode 100644 index 0000000000..5e25ae465b --- /dev/null +++ b/crates/engine/src/edit/labels.rs @@ -0,0 +1,336 @@ +use super::*; + +/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not +/// here: its wording is only the hole, because a batch carries its name as text (see +/// [`BATCH_LABELS`]). +pub const EDIT_LABELS: &[&str] = &[ + "Rotate page", + "Change tag properties", + "Rotate pages", + "Delete page", + "Delete pages", + "Move page", + "Move pages", + "Reverse pages", + "Insert blank page", + "Duplicate page", + "Duplicate pages", + "Replace page", + "Replace pages", + "Crop page", + "Crop pages", + "Set page boxes", + "Set page transitions", + "Change Title", + "Change Author", + "Change Subject", + "Change Keywords", + "Change {key}", + "Insert pages from {name}", + "Insert pages from {name} — 1 field renamed", + "Insert pages from {name} — fields renamed", + "Attach {name}", + "Delete attachment {key}", + "Add bookmark", + "Rename bookmark", + "Delete bookmark", + "Move bookmark", + "Set bookmark destination", + "Colour", + "Style", + "New bookmarks from structure", + "Number pages", + "Measure distance", + "Measure perimeter", + "Measure area", + "Set measurement scale", + "Add sticky note", + "Add highlight", + "Add underline", + "Add strikethrough", + "Add squiggly underline", + "Add rectangle", + "Add oval", + "Add arrow", + "Add line", + "Add drawing", + "Add text box", + "Add checkmark", + "Add cross", + "Add dot", + "Add signature", + "Add redaction mark", + "Add cloud", + "Add polygon", + "Add connected lines", + "Add callout", + "Add inserted text", + "Add file attachment", + "Add stamp", + "Delete comment", + "Edit comment", + "Reply", + "Set status None", + "Set status Accepted", + "Set status Rejected", + "Set status Cancelled", + "Set status Completed", + "Replace text", + "Erase", + "Mark with checkmark", + "Remove checkmark", + "Lock comment", + "Unlock comment", + "Move comment", + "Reshape comment", + "Resize comment", + "Change comment properties", + "Fill in {name}", + "Set the image of {name}", + "Clear form", + "Add field", + "Change field properties", + "Delete field", + "Duplicate field", + "Set tab order", + "Set calculation order", + "Change initial view", + "Set alternate text", + "Mark figure as decorative", + "Recognize text", + "Run JavaScript", + "Run form script", + "Save as {level}", + "Remove {standard} identification", + "Edit script of {name}", + "Delete document JavaScript", + "Edit document JavaScript", + "Edit text", + "Load document font", + "Move object", + "Move objects", + "Move image", + "Rotate image", + "Flip image", + "Replace image", + "Delete image", + "Add header & footer", + "Update header & footer", + "Add watermark", + "Update watermark", + "Add background", + "Update background", + "Remove header & footer", + "Remove watermark", + "Remove background", + "Add text", + "Add image", + "Edit content", + "Delete content", + "Paste", + "Apply redactions", + "Remove redaction marks", + "Remove hidden information", + "Import {name}", + "Add link", + "Change link properties", + "Delete link", + "Remove all links", + "Create link", + "Create links", + "Sanitize document", + "Flatten comments", + "Flatten form fields", + "Flatten", + "Flatten Fill & Sign", + "Protect with password", + "Remove security", +]; + +/// The names of the batches built below L7, which an interface shows in the same places as +/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives +/// already worded in the interface's language. +pub const BATCH_LABELS: &[&str] = + &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; + +impl Edit { + /// Label for the Edit menu and history ("Undo Rotate pages"). + pub fn label(&self) -> Label { + match self { + Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), + Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), + Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), + Edit::ReversePages { .. } => Label::of("Reverse pages"), + Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), + Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), + Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), + Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), + Edit::SetPageBox { .. } => Label::of("Set page boxes"), + Edit::SetTransition { .. } => Label::of("Set page transitions"), + // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can + // be said in another language; any other key is the document's and goes in as it stands. + Edit::SetInfo { key, .. } => match key.as_str() { + "Title" => Label::of("Change Title"), + "Author" => Label::of("Change Author"), + "Subject" => Label::of("Change Subject"), + "Keywords" => Label::of("Change Keywords"), + _ => Label::with("Change {key}", key.as_str()), + }, + Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), + Edit::AddBookmark { .. } => Label::of("Add bookmark"), + Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), + Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), + Edit::MoveBookmark { .. } => Label::of("Move bookmark"), + Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), + // The bookmark Style and Colour menus' own names, already said in every language. + Edit::SetBookmarkColor { .. } => Label::of("Colour"), + Edit::SetBookmarkStyle { .. } => Label::of("Style"), + Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), + Edit::NumberPages { .. } => Label::of("Number pages"), + // What was measured is part of the wording too, for the same reason. + Edit::AddMeasurement(m) => Label::of(match m.kind { + pdfcraft_measure::Kind::Distance => "Measure distance", + pdfcraft_measure::Kind::Perimeter => "Measure perimeter", + pdfcraft_measure::Kind::Area => "Measure area", + }), + Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), + Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), + Edit::AddCustomStamp { .. } => Label::of("Add stamp"), + Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), + Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), + Edit::ReplyToAnnotation { .. } => Label::of("Reply"), + // The state's name is part of the wording: a language may need to agree it with the rest. + Edit::SetAnnotationStatus { state, .. } => Label::of(match state { + ReviewState::None => "Set status None", + ReviewState::Accepted => "Set status Accepted", + ReviewState::Rejected => "Set status Rejected", + ReviewState::Cancelled => "Set status Cancelled", + ReviewState::Completed => "Set status Completed", + }), + Edit::ReplaceText { .. } => Label::of("Replace text"), + Edit::EraseInk { .. } => Label::of("Erase"), + Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), + Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), + Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), + Edit::LockAnnotation { .. } => Label::of("Unlock comment"), + Edit::MoveAnnotation { .. } => Label::of("Move comment"), + Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), + Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), + Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { + Label::of("Change comment properties") + } + Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), + Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), + Edit::ResetForm { .. } => Label::of("Clear form"), + Edit::AddField { .. } => Label::of("Add field"), + Edit::SetFieldProps { .. } => Label::of("Change field properties"), + Edit::DeleteField { .. } => Label::of("Delete field"), + Edit::DuplicateField { .. } => Label::of("Duplicate field"), + Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), + Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), + Edit::SetInitialView(_) => Label::of("Change initial view"), + Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), + Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), + Edit::AutoTagText => Label::of("Automatically tag text"), + Edit::SetAltText { .. } => Label::of("Set alternate text"), + Edit::AutoTagFormFields => Label::of("Tag form fields"), + Edit::SetTagProperty { .. } => Label::of("Change tag properties"), + Edit::MoveTag { .. } => Label::of("Move object"), + Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), + Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), + Edit::AddOcrText { .. } => Label::of("Recognize text"), + Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), + Edit::XfaEvent { .. } => Label::of("Run form script"), + // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. + Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), + Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), + Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), + Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), + Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), + Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), + Edit::RegisterTextFont { .. } => Label::of("Load document font"), + Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), + Edit::EditPageImage { change, .. } => Label::of(match change { + ImageEdit::Move(_) => "Move image", + ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", + ImageEdit::Crop(_) => "Crop image", + ImageEdit::Flip { .. } => "Flip image", + ImageEdit::Replace { .. } => "Replace image", + ImageEdit::Delete => "Delete image", + }), + Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), + Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), + Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), + Edit::AddWatermark { .. } => Label::of("Update watermark"), + Edit::AddBackground { replace: false, .. } => Label::of("Add background"), + Edit::AddBackground { .. } => Label::of("Update background"), + Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), + Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), + Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), + Edit::AddText { .. } => Label::of("Add text"), + Edit::AddImage { .. } => Label::of("Add image"), + Edit::UpdateContent { .. } => Label::of("Edit content"), + Edit::DeleteContent { .. } => Label::of("Delete content"), + Edit::ReplaceImage { .. } => Label::of("Replace image"), + Edit::PasteContent { .. } => Label::of("Paste"), + Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), + Edit::ClearRedactions => Label::of("Remove redaction marks"), + Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), + Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), + Edit::AddLink { .. } => Label::of("Add link"), + Edit::SetLink { .. } => Label::of("Change link properties"), + Edit::DeleteLink { .. } => Label::of("Delete link"), + Edit::RemoveLinks { .. } => Label::of("Remove all links"), + Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), + Edit::Sanitize => Label::of("Sanitize document"), + Edit::XfaToStatic => Label::of("Convert to a static form"), + Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), + Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), + Edit::Flatten { .. } => Label::of("Flatten"), + Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), + Edit::Protect(_) => Label::of("Protect with password"), + Edit::RemoveProtection => Label::of("Remove security"), + // A batch carries its own name, already worded by whoever built it. + // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). + Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), + } + } +} + +/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, +/// so a language can give the noun the article and the gender its own grammar asks for. +fn add_annotation(s: &Shape) -> &'static str { + match s { + Shape::Note { .. } => "Add sticky note", + Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", + Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", + Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", + Shape::Rectangle { .. } => "Add rectangle", + Shape::Oval { .. } => "Add oval", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", + Shape::Line { .. } => "Add line", + Shape::Ink { .. } => "Add drawing", + Shape::TextBox { .. } => "Add text box", + Shape::Typewriter { .. } => "Add text", + Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", + Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", + Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", + Shape::Mark { mark: FillMark::Line, .. } => "Add line", + Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", + Shape::Redact { .. } => "Add redaction mark", + Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", + Shape::Polygon { cloud: true, .. } => "Add cloud", + Shape::Polygon { .. } => "Add polygon", + Shape::PolyLine { .. } => "Add connected lines", + Shape::Callout { .. } => "Add callout", + Shape::Caret { .. } => "Add inserted text", + Shape::Attachment { .. } => "Add file attachment", + } +} + +/// The singular or the plural wording, by count. Both are written out: a language forms its plural +/// its own way, and `one` and `many` may differ in more than an "s". +fn plural(one: &'static str, many: &'static str, n: usize) -> Label { + Label::of(if n == 1 { one } else { many }) +} diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs new file mode 100644 index 0000000000..7901a3b7d8 --- /dev/null +++ b/crates/engine/src/edit/run.rs @@ -0,0 +1,696 @@ +use super::*; + +/// Whether the opening password allows an edit (§7.6.4.2, Table 22). +pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { + match edit { + Edit::RotatePages { .. } + | Edit::DeletePages { .. } + | Edit::MovePages { .. } + | Edit::ReversePages { .. } + | Edit::InsertBlankPage { .. } + | Edit::DuplicatePages { .. } + | Edit::ReplacePages { .. } + | Edit::SetPageBox { .. } + | Edit::SetTransition { .. } + | Edit::InsertPagesFrom { .. } + // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). + | Edit::AddBookmark { .. } + | Edit::RenameBookmark { .. } + | Edit::DeleteBookmark { .. } + | Edit::MoveBookmark { .. } + | Edit::SetBookmarkPage { .. } + | Edit::SetBookmarkColor { .. } + | Edit::SetBookmarkStyle { .. } + | Edit::BookmarksFromStructure + | Edit::NumberPages { .. } => { + if p.assemble() { + Ok(()) + } else { + Err(EditError::NotPermitted("page changes")) + } + } + Edit::AddMeasurement(_) + | Edit::AddAnnotation(_) + | Edit::AddCustomStamp { .. } + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } + | Edit::LockAnnotation { .. } + | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } + | Edit::SetMeasurementScale { .. } => { + if p.annotate() { + Ok(()) + } else { + Err(EditError::NotPermitted("comments")) + } + } + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { + if p.fill_forms() { + Ok(()) + } else { + Err(EditError::NotPermitted("filling in form fields")) + } + } + Edit::Protect(_) | Edit::RemoveProtection => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("changing security")) + } + } + // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted + // document it is a change of security (#3921). + Edit::ConvertPdfA { .. } => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) + } + } + Edit::SetInfo { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } + | Edit::AddField { .. } + | Edit::ApplyRedactions { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::MoveObjects { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::ClearRedactions + | Edit::RemoveHidden { .. } + | Edit::ImportData { .. } + | Edit::AddLink { .. } + | Edit::SetLink { .. } + | Edit::DeleteLink { .. } + | Edit::RemoveLinks { .. } + | Edit::AddLinks { .. } + | Edit::Sanitize + | Edit::XfaToStatic + | Edit::SetFieldProps { .. } + | Edit::DeleteField { .. } + | Edit::DuplicateField { .. } + | Edit::SetTabOrder { .. } + | Edit::MoveInTabOrder { .. } + | Edit::SetCalculationOrder { .. } + | Edit::SetInitialView(_) + | Edit::AddAttachment { .. } + | Edit::DeleteAttachment { .. } + | Edit::AutoTagText + | Edit::SetAltText { .. } + | Edit::AutoTagFormFields + | Edit::SetTagProperty { .. } + | Edit::MoveTag { .. } + | Edit::MarkDecorative { .. } + | Edit::MarkTagArtifact { .. } + | Edit::AddOcrText { .. } + | Edit::SetDocumentScript { .. } + | Edit::SetFieldScript { .. } + | Edit::RemoveStandardId { .. } + | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } + | Edit::RegisterTextFont { .. } + | Edit::EditPageImage { .. } + | Edit::Flatten { .. } + | Edit::FlattenFillSign => { + if p.modify() { + Ok(()) + } else { + Err(EditError::NotPermitted("changes to the document")) + } + } + Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), + } +} + +/// Dates and unique ids stamped onto what an edit creates. +pub(crate) struct EditCtx { + /// Field scripts run (`None`: JavaScript is off) and what they produced. + pub(crate) js: Option, + /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what + /// its scripts produced. + pub(crate) xfa: Option>, + pub(crate) xfa_out: js::JsOutput, + /// The datasets stream this edit wrote (later writes in the edit replace it in place). + pub(crate) xfa_datasets: Option, + /// A form script ran too long and was abandoned: the document's scripts go off. + pub(crate) xfa_ran_away: bool, + /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). + pub(crate) xfa_time_scale: u32, + /// The edit took the XFA out of the form: what went with it, for the document's warnings. + pub(crate) xfa_removed: Vec, + /// Pages in the document, for `xfa.layout.pageCount()`. + pub(crate) pages: usize, + pub(crate) date: Option, + /// Today in local time, for date tokens. + pub(crate) today: (i64, u32, u32), + pub(crate) seed: u64, + pub(crate) count: u64, + /// Fields the edit renamed to keep imported names separate (old → new), for the repair + /// log and the undo label. + pub(crate) renamed_fields: Vec<(String, String)>, + /// The overlay streams the edit's applied redactions added, which alone the renderer's check + /// leaves out. + pub(crate) redaction_overlays: Vec, +} + +impl EditCtx { + pub(crate) fn new(now: Option, salt: u64) -> Self { + let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; + if let Some(t) = now { + seed ^= (t as u64).rotate_left(17); + } + #[cfg(not(target_arch = "wasm32"))] + if now.is_some() { + // Real clock: mix in sub-second time so ids from two sessions don't collide. + seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; + } + Self { + js: None, + xfa: None, + xfa_out: Default::default(), + xfa_datasets: None, + xfa_ran_away: false, + xfa_time_scale: 1, + xfa_removed: Vec::new(), + pages: 0, + date: now.map(pdfcraft_cos::pdf_date), + today: (1970, 1, 1), + seed, + count: 0, + renamed_fields: Vec::new(), + redaction_overlays: Vec::new(), + } + } + + /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the + /// operating system, so no extra dependency is needed. + fn entropy(&mut self) -> [u8; 32] { + use std::hash::{BuildHasher, Hasher}; + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(self.seed ^ i as u64); + h.write_u64(self.count); + self.count += 1; + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + out + } + + /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. + fn meta(&mut self) -> pdfcraft_annot::Meta { + let mut next = || { + self.count += 1; + let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + let (a, b) = (next(), next()); + let id = format!( + "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", + a >> 32, + (a >> 16) & 0xffff, + a & 0xfff, + 0x8000 | (b >> 48) & 0x3fff, + b & 0xffff_ffff_ffff + ); + pdfcraft_annot::Meta { date: self.date.clone(), id } + } +} + +/// Perform an edit on a working copy (the caller discards it on error). +pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { + match edit { + Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, + Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, + Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, + Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, + Edit::InsertBlankPage { at, width, height } => { + pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; + } + Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, + Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, + Edit::ReplacePages { pages, name, bytes, src_pages } => { + let src = open_source(name, bytes)?; + pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; + } + Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, + Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, + Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { + let src = open_source(name, bytes)?; + let pages = match pages { + Some(p) => p.clone(), + None => (0..pdfcraft_organize::page_count(&src)?).collect(), + }; + // The file's bookmarks come along under one named after it, as in Combine Files. + let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); + let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; + cx.renamed_fields = outcome.renamed_fields; + } + Edit::AddBookmark { parent, index, title, page, left, top } => { + pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; + } + Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?, + Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?, + Edit::MoveBookmark { from, to_parent, index } => { + pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; + } + Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, + Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, + Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, + Edit::BookmarksFromStructure => { + let entries: Vec<_> = pdfcraft_a11y::headings(doc) + .into_iter() + .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) + .collect(); + pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; + } + Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, + Edit::AddMeasurement(m) => { + measure::add(doc, m, &cx.meta())?; + } + Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, + Edit::AddAnnotation(a) => { + pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; + } + Edit::AddCustomStamp { page, rect, name, file, author } => { + let src = mark_source(doc, file)?; + let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + let natural = rect[2] == rect[0] && rect[3] == rect[1]; + let rotation = pdfcraft_organize::page_rotation(doc, *page)?; + // The picture as displayed: a quarter turn swaps its sides in user space. + let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; + let rect = if natural { + let k = (200.0 / sw.max(sh)).min(1.0); + let (w, h) = (dw * k, dh * k); + [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] + } else { + fit_picture(*rect, dw, dh) + }; + let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; + let style = pdfcraft_annot::Style::default_for(&shape); + let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; + let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; + pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; + } + Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, + Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, + Edit::ReplyToAnnotation { page, index, text, author } => { + pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; + } + Edit::SetAnnotationStatus { page, index, state, author } => { + pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; + } + Edit::EraseInk { page, index, path, radius } => { + pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; + } + Edit::AddAttachment { name, bytes, description, mime } => { + pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; + } + Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, + Edit::ReplaceText { page, quads, text, author, strike, caret } => { + // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by + // the strikeout's `/NM` (#2666). + let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); + pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; + } + Edit::MarkAnnotation { page, index, marked, author } => { + pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; + } + Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, + Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, + Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, + Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, + Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { + pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; + } + Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, + Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, + Edit::SetAnnotationInfo { page, index, author, subject, icon } => { + pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; + } + Edit::SetFieldValue { name, value } => { + let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { + Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), + None => pdfcraft_forms::set_value(doc, name, value), + }; + match set(doc, value) { + Ok(()) => {} + // A date the field's format doesn't read may be typed by another of an XFA date + // field's patterns: it is kept in the first. Only a format refusal is tried + // again: it comes before any of the field's scripts run, so they run once. + Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { + Some(typed) => set(doc, &typed)?, + None => return Err(refused.into()), + }, + Err(refused) => return Err(refused.into()), + } + } + Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { + Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, + None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, + }, + Edit::XfaEvent { som, activity } => { + let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; + 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 }; + let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + ran.map_err(EditError::Invalid)?; + } + Edit::SetFieldImage { name, image } => { + let (img, _) = embed_image(doc, name, image)?; + let px = match &*doc.get(img) { + pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), + _ => (1, 1), + }; + // An XFA image field keeps the picture in the data too (as Designer saves it), and a + // laid-out form shows it from there: so only pictures the layout can show, and only + // for image fields (a laid-out one carries its SOM path; a static form's field name + // is its path). + let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); + let image_field = is_xfa + && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); + if image_field { + let Some(content_type) = image_content_type(image) else { + return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); + }; + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); + let path = match cx.xfa.as_deref() { + Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), + None => pdfcraft_xfa::som_to_path(&som), + }; + let r = + pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; + if !r.written && r.warnings.is_empty() { + return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); + } + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + xfa_after_data_change(doc, cx, false)?; + } else { + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + } + } + Edit::ResetForm { names } => { + pdfcraft_forms::reset(doc, names.as_deref())?; + } + Edit::AddField { page, rect, kind, name } => { + pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; + } + Edit::SetFieldProps { name, props } => { + pdfcraft_forms::set_props(doc, name, props)?; + } + Edit::DuplicateField { name, pages } => { + if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { + return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); + } + } + Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, + Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, + Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, + // As after a script edit: the values the new order gives show at once. + Edit::SetCalculationOrder { names } => { + pdfcraft_forms::set_calculation_order(doc, names)?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, + Edit::AutoTagText => { + a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::SetAltText { figure, alt } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AutoTagFormFields => { + a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; + } + Edit::SetTagProperty { object, property, value } => { + a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MoveTag { object, anchor, placement } => { + a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::EditTextLine { page, line, text } => { + pdfcraft_edit::replace_line(doc, *page, *line, text)?; + } + Edit::SetFieldScript { name, event, script } => { + pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + Edit::ConvertPdfA { level } => { + pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; + } + Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, + Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, + Edit::AddOcrText { page, words } => { + pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; + } + Edit::EditPageImage { page, index, change } => { + let img = pdfcraft_edit::page_images(doc, *page)? + .into_iter() + .nth(*index) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; + let c = match change { + ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), + ImageEdit::Rotate(q) => { + pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) + } + ImageEdit::RotateBy(degrees) => { + let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; + pdfcraft_edit::ImageChange::Transform(t) + } + ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( + img.visible_rect.unwrap_or(img.rect), + 0, + *horizontal, + !*horizontal, + )), + ImageEdit::Replace { name, bytes } => { + if img.is_form { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( + "grouped Form artwork cannot be replaced with a bitmap".into(), + ))); + } + pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) + } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), + ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + }; + pdfcraft_edit::change_image(doc, *page, *index, &c)?; + } + Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, + Edit::EditTextBlock { page, block, text, style } => { + pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; + } + Edit::RegisterTextFont { page, bytes } => { + pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; + } + Edit::MarkDecorative { figure } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MarkTagArtifact { object } => { + a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AddHeaderFooter { pages, settings, replace } => { + let date = cx.today; + pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; + } + Edit::AddWatermark { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? + } + Edit::AddBackground { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_background(doc, pages, &s, *replace)? + } + Edit::RemoveMarks { kind } => { + let n = pdfcraft_model::pages(doc).len(); + if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); + } + } + Edit::AddText { page, text } => { + pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; + } + Edit::AddImage { page, rect, name, bytes } => { + let (image, natural) = embed_image(doc, name, bytes)?; + let rect = match rect { + Some(r) => *r, + None => { + let p = pdfcraft_model::pages(doc).swap_remove(*page); + let (pw, ph) = p.display_size(doc); + let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); + let (w, h) = (natural.0 * k, natural.1 * k); + [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] + } + }; + pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; + } + Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, + Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, + Edit::ReplaceImage { page, index, name, bytes } => { + let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); + let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); + }; + let (image, _) = embed_image(doc, name, bytes)?; + pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; + } + Edit::PasteContent { page, content, file } => { + let content = match content { + AddedContent::Text(_) => content.clone(), + AddedContent::Image(img) => { + let Some((name, bytes)) = file else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); + }; + // The same picture in this document (copied here, pasted here before, or an + // identical file): drawn again from the one image object rather than stored twice. + let image = if copy_image::is_picture(doc, img.image, bytes) { + img.image + } else { + match copy_image::added_picture(doc, bytes) { + Some(image) => image, + None => embed_image(doc, name, bytes)?.0, + } + }; + AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) + } + }; + pdfcraft_edit::add_content(doc, *page, &content)?; + } + Edit::ApplyRedactions { pages } => { + cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; + } + Edit::ClearRedactions => { + pdfcraft_redact::clear_marks(doc, None)?; + } + Edit::AddLink { page, rect, action, style } => { + pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; + } + Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, + Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, + Edit::RemoveLinks { pages } => { + if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); + } + } + Edit::AddLinks { links, style } => { + if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); + } + } + Edit::ImportData { bytes, .. } => { + // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; + // XFDF, FDF, Acrobat's XML and tables go the usual way. + let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); + match text { + Some(Ok(text)) if is_xfa_data_file(&text) => { + let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); + let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) + .map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); + xfa_after_data_change(doc, cx, true)?; + } + Some(Err(why)) => return Err(EditError::Invalid(why)), + _ => { + pdfcraft_xfdf::import(doc, bytes)?; + } + } + } + Edit::RemoveHidden { which } => { + pdfcraft_redact::sanitize::remove_hidden(doc, which)?; + } + Edit::Sanitize => { + pdfcraft_redact::sanitize::sanitize(doc)?; + } + Edit::Flatten { comments, fields } => { + // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers + // would lay the form out again from its template over the baked pages. A dynamic + // form that was never laid out keeps its XFA: without it only placeholders remain. + let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; + match shape { + Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, + Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), + None => {} + } + } + Edit::XfaToStatic => { + let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { + return Err(EditError::Invalid("this document is not an XFA form".into())); + }; + if !shape.strippable() { + return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); + } + strip_xfa(doc, cx)?; + } + Edit::FlattenFillSign => { + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; + } + Edit::Protect(p) => { + p.validate()?; + let seed = cx.entropy(); + // Without a permissions password nothing is restricted, so the owner password is a + // random one nobody needs. + let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); + let params = pdfcraft_cos::NewEncryption { + algorithm: p.algorithm, + user_password: p.open_password.as_deref().unwrap_or(""), + owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), + permissions: p.permission_bits(), + encrypt_metadata: p.encrypt_metadata, + seed, + }; + doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; + } + Edit::RemoveProtection => { + if doc.output_handler().is_none() { + return Err(EditError::Protection("the document isn't protected".into())); + } + doc.remove_encryption(); + } + Edit::Batch { edits, .. } => { + for e in edits { + run_edit(doc, e, cx)?; + } + } + } + Ok(()) +} From 21c1c917a5bc50446b22b63927fef6641ca8c1c2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:35:39 +0000 Subject: [PATCH 089/246] test(automation): skip use lines in the file access scan --- crates/automation/src/file_access.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index 3fa3a90abd..5003e10343 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -423,7 +423,9 @@ mod tests { let lines: Vec<&str> = code.lines().collect(); for (n, line) in lines.iter().enumerate() { let trimmed = line.trim_start(); + // A `use` line only names a helper; the call is checked where it is made. if trimmed.starts_with("//") + || trimmed.starts_with("use ") || !(trimmed.contains("std::fs") || trimmed.contains("File::open") || trimmed.contains("File::create") From 3370aebb6c72234db2cbb1b8de53a7721b836aa8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:51:58 +0000 Subject: [PATCH 090/246] refactor(automation): split lib.rs into src/ New modules: error, dispatch, documents, assemble, pages, paths, json. lib.rs keeps Automation, its builders, mod lines and pub use lines. --- crates/automation/src/assemble.rs | 424 +++++ crates/automation/src/dispatch.rs | 784 ++++++++ crates/automation/src/documents.rs | 606 ++++++ crates/automation/src/error.rs | 33 + crates/automation/src/json.rs | 361 ++++ crates/automation/src/lib.rs | 2733 +--------------------------- crates/automation/src/pages.rs | 363 ++++ crates/automation/src/paths.rs | 180 ++ crates/automation/src/tests.rs | 3 +- 9 files changed, 2775 insertions(+), 2712 deletions(-) create mode 100644 crates/automation/src/assemble.rs create mode 100644 crates/automation/src/dispatch.rs create mode 100644 crates/automation/src/documents.rs create mode 100644 crates/automation/src/error.rs create mode 100644 crates/automation/src/json.rs create mode 100644 crates/automation/src/pages.rs create mode 100644 crates/automation/src/paths.rs diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs new file mode 100644 index 0000000000..40ae65ea87 --- /dev/null +++ b/crates/automation/src/assemble.rs @@ -0,0 +1,424 @@ +use super::error::{Result, ToolError, failed}; +use super::json::{Args, one_based, summary}; +use super::paths::{child, read_input, read_input_bytes, unused, write_atomic}; +use super::*; + +impl Automation { + pub(crate) fn doc_create(&mut self, a: &Args) -> Result { + let (name, bytes) = match a.str("from")? { + "blank" => { + let n = a.opt_int("pages")?.unwrap_or(1).clamp(1, 10_000) as usize; + let (w, h) = (a.opt_num("width")?.unwrap_or(612.0), a.opt_num("height")?.unwrap_or(792.0)); + ("Untitled.pdf".to_string(), self.session.create_blank(w, h, n).map_err(failed)?) + } + "images" => { + let resolution = a.opt_num("dpi")?.map_or(pdfcraft_engine::ImageResolution::Embedded, pdfcraft_engine::ImageResolution::Dpi); + let mut images = Vec::new(); + for p in a.strs("paths")? { + let path = self.resolve(p, false)?; + let bytes = read_input(&path)?; + images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes)); + } + let name = if images.len() == 1 { + format!("{}.pdf", images[0].0.rsplit_once('.').map_or(images[0].0.as_str(), |(s, _)| s)) + } else { + "Images.pdf".into() + }; + (name, self.session.create_from_images_with_resolution(&images, resolution).map_err(failed)?) + } + "text" => { + let (title, text) = match (a.opt_str("text")?, a.opt_str("path")?) { + (Some(t), _) => ("Text".to_string(), t.to_string()), + (None, Some(p)) => { + let path = self.resolve(p, false)?; + let t = read_input(&path)?; + (path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t)) + } + (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), + }; + // The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with + // its line) by `pdfcraft_create::from_text`, as for every other caller. + let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e { + pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m), + e => failed(e), + })?; + (format!("{title}.pdf"), bytes) + } + other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))), + }; + let name = a.opt_str("name")?.map(str::to_owned).unwrap_or(name); + let id = self.session.open_new(name, bytes).map_err(failed)?; + Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?)) + } + + pub(crate) fn page_transition_set(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{Transition, TransitionSpec}; + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let transition = match a.opt_str("style")? { + None | Some("none") => None, + Some(style) => { + let mut t = Transition::new(style); + if let Some(d) = a.opt_num("duration")? { + t.duration = d as f32; + } + if let Some(dm) = a.opt_str("dimension")? { + t.horizontal = match dm { + "horizontal" => true, + "vertical" => false, + other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))), + }; + } + if let Some(m) = a.opt_str("motion")? { + t.inward = match m { + "inward" => true, + "outward" => false, + other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))), + }; + } + match a.get("direction") { + None => {} + Some(Value::String(s)) if s == "none" => t.direction = None, + Some(v) => { + t.direction = Some( + v.as_u64() + .and_then(|d| u16::try_from(d).ok()) + .ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?, + ); + } + } + if let Some(s) = a.opt_num("scale")? { + t.scale = s as f32; + } + if let Some(b) = a.opt_bool("opaque")? { + t.opaque = b; + } + Some(t) + } + }; + let display = a.opt_num("display")?.map(|s| s as f32); + self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } }) + } + + pub(crate) fn page_set_box(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{BoxSpec, PageBox}; + let doc = self.doc(a)?; + let n = doc.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let which = match a.opt_str("box")? { + None => PageBox::Crop, + Some(b) => PageBox::from_name(b).ok_or_else(|| ToolError::InvalidArgs(format!("unknown box {b:?}")))?, + }; + let nums = |key: &str| -> Result> { + a.get(key) + .map(|v| { + let v: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + <[f64; 4]>::try_from(v).map_err(|_| ToolError::InvalidArgs(format!("{key} must be 4 numbers"))) + }) + .transpose() + }; + let spec = match (nums("margins")?, nums("rect")?) { + (Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass margins or rect, not both".into())), + (Some(m), None) => BoxSpec::Margins(m), + (None, Some(r)) => { + // Top-left-origin points on the displayed page → user space (one page at a time). + if pages.len() != 1 { + return Err(ToolError::InvalidArgs("rect applies to a single page; use margins for several".into())); + } + let p = &doc.info.pages[pages[0]]; + let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); + BoxSpec::Rect([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + (None, None) => BoxSpec::Remove, + }; + let mut out = self.apply(a, Edit::SetPageBox { pages, which, spec })?; + out["page_sizes"] = json!(self.doc(a)?.info.pages.iter().map(|p| [p.width, p.height]).collect::>()); + Ok(out) + } + + pub(crate) fn insert_blank(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let at = self.position(a, "at")?; + // Default to the size of the neighbouring page, as Acrobat does; Letter for empty files. + let near = doc.info.pages.get(at.saturating_sub(1)).or(doc.info.pages.first()); + let (w, h) = near.map(|p| (f64::from(p.width), f64::from(p.height))).unwrap_or((612.0, 792.0)); + let width = a.opt_num("width")?.unwrap_or(w); + let height = a.opt_num("height")?.unwrap_or(h); + if !(1.0..=14400.0).contains(&width) || !(1.0..=14400.0).contains(&height) { + return Err(ToolError::InvalidArgs("width and height must be between 1 and 14400 points".into())); + } + self.apply(a, Edit::InsertBlankPage { at, width, height }) + } + + pub(crate) fn insert_file(&mut self, a: &Args) -> Result { + let at = self.position(a, "at")?; + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename }) + } + + /// Write freshly created bytes to `out` and/or open them as a new document. + pub(crate) fn deliver(&mut self, a: &Args, name: &str, bytes: Arc>) -> Result { + let mut result = json!({ "bytes": bytes.len() }); + let out = a.opt_str("out")?; + if let Some(out) = out { + let path = self.resolve(out, true)?; + write_atomic(&path, &bytes)?; + result["path"] = json!(path.to_string_lossy()); + } + if a.opt_bool("open")?.unwrap_or(out.is_none()) { + let id = self.session.open_new(name, bytes).map_err(failed)?; + result["document"] = summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?); + } + Ok(result) + } + + pub(crate) fn doc_create_multiple(&mut self, a: &Args) -> Result { + let paths = a.strs("paths")?; + if paths.is_empty() { + return Err(ToolError::InvalidArgs("paths must list at least one file".into())); + } + let separate = match a.opt_str("mode")? { + None | Some("combine") => false, + Some("separate") => true, + Some(m) => return Err(ToolError::InvalidArgs(format!("mode must be \"combine\" or \"separate\", not {m:?}"))), + }; + if separate { + return self.create_separate(a, &paths); + } + let ranges: Vec> = match a.get("pages") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(), + Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), + }; + // Each file is read, converted and copied in turn, so a long list holds one file at a time. + let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result { + let path = self.resolve(p, false)?; + let bytes = read_input(&path)?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?; + let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + Ok((title, pdf, range)) + }); + let bytes = self.session.combine_each(sources)?.ok_or_else(|| ToolError::InvalidArgs("paths must list at least one file".into()))?; + self.deliver(a, "Combined", bytes) + } + + /// One PDF per file, written into `out_dir`; a file that fails doesn't stop the others. + fn create_separate(&mut self, a: &Args, paths: &[&str]) -> Result { + let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("mode \"separate\" needs out_dir".into()))?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut out = Vec::new(); + for &p in paths { + let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| { + let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?; + let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); + let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?; + Ok((kind, stem, pdf)) + }); + out.push(match converted { + Ok((pdfcraft_engine::SourceKind::Pdf, ..)) => json!({ "path": p, "skipped": "already a PDF" }), + Ok((_, stem, pdf)) => { + let target = unused(&dir, &stem); + write_atomic(&target, &pdf)?; + json!({ "path": p, "output": target.to_string_lossy(), "bytes": pdf.len() }) + } + Err(e) => json!({ "path": p, "error": e }), + }); + } + Ok(json!({ "files": out })) + } + + pub(crate) fn page_extract(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let (id, name) = (doc.id, format!("{} (extract)", doc.name)); + let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "page".into()); + let pages = self.pages(a, "pages")?; + let mut out = if a.opt_bool("separate")?.unwrap_or(false) { + // Each page as its own file. + let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut files = Vec::new(); + for &p in &pages { + let bytes = self.session.extract(id, &[p]).map_err(failed)?; + let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1)); + write_atomic(&path, &bytes)?; + files.push(path.to_string_lossy().into_owned()); + } + json!({ "files": files }) + } else { + let bytes = self.session.extract(id, &pages).map_err(failed)?; + self.deliver(a, &name, bytes)? + }; + if a.opt_bool("delete")?.unwrap_or(false) { + let deleted = self.apply(a, Edit::DeletePages { pages })?; + out["original"] = deleted; + } + Ok(out) + } + + pub(crate) fn doc_combine(&mut self, a: &Args) -> Result { + let paths = a.strs("paths")?; + if paths.len() < 2 { + return Err(ToolError::InvalidArgs("combine needs at least two files".into())); + } + let ranges: Vec> = match a.get("pages") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() => v + .iter() + .enumerate() + .map(|(index, value)| match value { + Value::Null => Ok(None), + Value::String(range) => Ok(Some(range.clone())), + _ => Err(ToolError::InvalidArgs(format!("pages[{index}] must be a range string or null"))), + }) + .collect::>()?, + Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), + }; + let passwords: Vec> = match a.get("passwords") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_string() || x.is_null()) => { + v.iter().map(|x| x.as_str().map(str::to_owned)).collect() + } + Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())), + }; + // Entries sharing a group number are one file split into parts placed apart. + let groups: Option>> = match a.get("groups") { + None | Some(Value::Null) => None, + Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_u64() || x.is_null()) => { + Some(v.iter().map(Value::as_u64).collect()) + } + Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())), + }; + let field_conflicts = match a.opt_str("field_conflicts")? { + None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename, + Some("merge") => pdfcraft_organize::FieldConflicts::Merge, + Some(other) => { + return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}"))); + } + }; + let mut sources: Vec = Vec::new(); + let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new(); + for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() { + let path = self.resolve(p, false)?; + let group = groups.as_ref().and_then(|g| g.get(i).copied().flatten()); + let first = group.and_then(|g| firsts.iter().find(|f| f.0 == g)); + let bytes = match first { + Some((g, first_path, at)) => { + if *first_path != path { + return Err(ToolError::InvalidArgs(format!("groups[{i}]: group {g} is {}, not {}", first_path.display(), path.display()))); + } + sources.get(*at).map(|s| s.1.clone()).ok_or_else(|| failed("group source missing"))? + } + None => { + if let Some(g) = group { + firsts.push((g, path.clone(), sources.len())); + } + let bytes = Arc::new(read_input(&path)?); + // Images and text files are made into PDFs first (#569), as in the Combine files tab. + let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + self.session.as_pdf(&file_name, &bytes).map_err(failed)? + } + }; + let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + sources.push((name, bytes, range)); + } + let passwords: Vec> = passwords.iter().map(Option::as_deref).collect(); + let combined = match groups { + None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts), + Some(groups) => { + // A path without a group is a file of its own: give it a key no group uses. + let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1)); + let mut keys = Vec::with_capacity(groups.len()); + for g in &groups { + keys.push(match g { + Some(g) => *g, + None => { + let own = next.ok_or_else(|| ToolError::InvalidArgs("group numbers are too large".into()))?; + next = own.checked_add(1); + own + } + }); + } + self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts) + } + } + .map_err(failed)?; + let mut result = self.deliver(a, "Combined", combined.bytes)?; + if !combined.renamed_fields.is_empty() { + result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::>()); + } + Ok(result) + } + + pub(crate) fn doc_split(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "part".into()); + let bookmarks = a.opt_bool("bookmarks")?.unwrap_or(false); + let max_mb = a.opt_num("max_mb")?; + let chosen = [a.get("every").is_some(), a.get("before").is_some(), bookmarks, max_mb.is_some()].iter().filter(|x| **x).count(); + if chosen != 1 { + return Err(ToolError::InvalidArgs( + "pass exactly one of every (pages per file), before (page numbers), bookmarks: true, or max_mb".into(), + )); + } + let mut titles: Vec<(usize, String)> = Vec::new(); + let parts = if let Some(mb) = max_mb { + if !mb.is_finite() || mb <= 0.0 { + return Err(ToolError::InvalidArgs("max_mb must be positive".into())); + } + self.session.split_by_size(id, (mb * 1_048_576.0) as usize).map_err(failed)? + } else { + let by = match (a.opt_int("every")?, a.opt_ints("before")?) { + (Some(n), None) if n > 0 => pdfcraft_organize::SplitBy::PageCount(n as usize), + (None, Some(b)) => pdfcraft_organize::SplitBy::Before(one_based(&b)?), + _ if bookmarks => { + titles = self.session.bookmark_splits(id); + if titles.is_empty() { + return Err(failed("the document has no top-level bookmarks")); + } + pdfcraft_organize::SplitBy::Before(titles.iter().map(|t| t.0).collect()) + } + _ => return Err(ToolError::InvalidArgs("every must be a positive page count".into())), + }; + self.session.split(id, &by).map_err(failed)? + }; + let dir = self.resolve(a.str("out_dir")?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut files = Vec::new(); + let mut used = std::collections::HashSet::new(); + for (i, (first, last, bytes)) in parts.iter().enumerate() { + let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); + let mut base = match titles.iter().find(|t| t.0 + 1 == *first) { + Some((_, t)) => format!("{stem}-{}", safe(t)), + None => format!("{stem}-part{}", i + 1), + }; + // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix. + if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) { + base.truncate(end); + } + let file = format!("{base}.pdf"); + // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. + let mut unique = file; + let mut n = 1; + while !used.insert(unique.to_lowercase()) { + n += 1; + unique = format!("{base}-{n}.pdf"); + } + let path = child(&dir, &unique); + write_atomic(&path, bytes)?; + files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last })); + } + Ok(json!({ "files": files })) + } +} diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs new file mode 100644 index 0000000000..26e1f95365 --- /dev/null +++ b/crates/automation/src/dispatch.rs @@ -0,0 +1,784 @@ +use super::error::{Result, ToolError, failed}; +use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary}; +use super::paths::{read_input, write_atomic}; +use super::*; + +/// One piece of a tool's result. +#[derive(Clone, Debug, PartialEq)] +pub enum Content { + /// Structured data (MCP: a text block with the JSON plus `structuredContent`). + Json(Value), + /// A PNG image. + Png { data: Vec, width: u32, height: u32 }, +} + +impl Automation { + /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). + pub fn call(&mut self, name: &str, args: &Value) -> Result> { + let empty = json!({}); + let args = if args.is_null() { &empty } else { args }; + if !args.is_object() { + return Err(ToolError::InvalidArgs("arguments must be a JSON object".into())); + } + let def = tools::find(name).ok_or_else(|| ToolError::UnknownTool(name.into()))?; + tools::check_args(def, args)?; + let a = Args(args); + let out = match name { + "doc_open" => self.doc_open(&a)?, + "doc_batch" => self.doc_batch(&a)?, + "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), + "doc_info" => { + let id = self.doc(&a)?.id; + self.session.refresh_fonts(id); + info(self.doc(&a)?) + } + "doc_close" => self.doc_close(&a)?, + "doc_save" => self.doc_save(&a)?, + "doc_set_info" => { + let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() }; + self.apply(&a, edit)? + } + "page_render" => return self.page_render(&a).map(|c| vec![c]), + "comment_image_preview" => return self.comment_image_preview(&a), + "text_extract" => self.text_extract(&a)?, + "text_word_count" => self.text_word_count(&a)?, + "text_find" => self.text_find(&a)?, + "page_rotate" => { + let degrees = a.int("degrees")?; + if degrees % 90 != 0 { + return Err(ToolError::InvalidArgs("degrees must be a multiple of 90".into())); + } + let doc = self.doc(&a)?; + let base = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let parity = match a.opt_str("subset")?.unwrap_or("all") { + "all" => pdfcraft_engine::PageParity::Both, + "even" => pdfcraft_engine::PageParity::Even, + "odd" => pdfcraft_engine::PageParity::Odd, + s => return Err(ToolError::InvalidArgs(format!("unknown subset {s:?} (all, even, odd)"))), + }; + let orientation = match a.opt_str("orientation")?.unwrap_or("all") { + "all" => pdfcraft_engine::PageOrientation::Both, + "landscape" => pdfcraft_engine::PageOrientation::Landscape, + "portrait" => pdfcraft_engine::PageOrientation::Portrait, + o => return Err(ToolError::InvalidArgs(format!("unknown orientation {o:?} (all, landscape, portrait)"))), + }; + let pages = pdfcraft_engine::filter_pages(&self.doc(&a)?.info, &base, parity, orientation); + if pages.is_empty() { + return Err(failed("no pages match the filters")); + } + let n = pages.len(); + let mut out = self.apply(&a, Edit::RotatePages { pages, degrees })?; + out["rotated"] = json!(n); + out + } + "page_enhance_camera" => { + let page = self.page(&a)?; + let given = a.nums::<8>("corners")?; + let doc = self.doc(&a)?; + let (id, info) = (doc.id, doc.info.pages.get(page).cloned().ok_or_else(|| failed("no such page"))?); + let corners = match given { + Some(c) => [0, 1, 2, 3].map(|i| { + let [x, y] = info.view_to_user(c[2 * i] as f32, c[2 * i + 1] as f32); + [x as f64, y as f64] + }), + None => self.session.detect_camera_corners(id, page).ok_or_else(|| { + failed("the document's edges could not be found on this page: pass `corners` [x1, y1, x2, y2, x3, y3, x4, y4]") + })?, + }; + let level = match a.opt_int("level")? { + None => 0, + Some(l @ 0..=100) => l as u8, + Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), + }; + let look = match a.opt_str("mode")? { + None => pdfcraft_engine::camera_clean::Look::Colour, + Some(m) => pdfcraft_engine::camera_clean::Look::from_name(m) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown mode {m:?} (original, colour, greyscale, bw)")))?, + }; + self.session.enhance_camera_page_at(id, page, corners, look, level).map_err(failed)?; + let r = |v: f32| (v * 100.0).round() / 100.0; + let used: Vec = corners.iter().flat_map(|&[x, y]| info.user_to_view(x as f32, y as f32)).map(r).collect(); + let mut out = summary(self.doc(&a)?); + out["corners"] = json!(used); + out["detected"] = json!(given.is_none()); + out + } + "page_delete" => { + let pages = self.pages(&a, "pages")?; + self.apply(&a, Edit::DeletePages { pages })? + } + "page_move" => { + let pages = self.pages(&a, "pages")?; + let to = self.position(&a, "to")?; + self.apply(&a, Edit::MovePages { pages, to })? + } + "page_reverse" => { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..self.doc(&a)?.info.pages.len()).collect(), + }; + self.apply(&a, Edit::ReversePages { pages })? + } + "page_insert_blank" => self.insert_blank(&a)?, + "page_insert_file" => self.insert_file(&a)?, + "page_extract" => self.page_extract(&a)?, + "doc_combine" => self.doc_combine(&a)?, + "doc_create_multiple" => self.doc_create_multiple(&a)?, + "doc_split" => self.doc_split(&a)?, + "edit_undo" => { + let id = self.doc(&a)?.id; + let label = self.session.undo(id).map_err(failed)?.to_string(); + json!({ "undone": label, "document": summary(self.doc(&a)?) }) + } + "edit_redo" => { + let id = self.doc(&a)?.id; + let label = self.session.redo(id).map_err(failed)?.to_string(); + json!({ "redone": label, "document": summary(self.doc(&a)?) }) + } + "command_list" => self.command_list(&a)?, + "command_run" => return self.command_run(&a), + "command_batch" => self.command_batch(&a)?, + "doc_inspect" => self.doc_inspect(&a)?, + "render_preview" => return self.render_preview(&a).map(|c| vec![c]), + "page_number" => { + use pdfcraft_organize::LabelStyle as L; + let n = self.doc(&a)?.info.pages.len(); + let (from, to) = (a.int("from")?, a.int("to")?); + if from < 1 || to < from || to as usize > n { + return Err(ToolError::InvalidArgs(format!("from and to must satisfy 1 ≤ from ≤ to ≤ {n}"))); + } + let style = match a.opt_str("style")?.unwrap_or("decimal") { + "decimal" => L::Decimal, + "upper-roman" => L::UpperRoman, + "lower-roman" => L::LowerRoman, + "upper-alpha" => L::UpperAlpha, + "lower-alpha" => L::LowerAlpha, + "none" => L::None, + other => return Err(ToolError::InvalidArgs(format!("unknown style {other:?}"))), + }; + let prefix = a.opt_str("prefix")?.unwrap_or_default().to_string(); + let first = a.opt_int("start")?.unwrap_or(1).clamp(1, u32::MAX as i64) as u32; + let mut out = self.apply(&a, Edit::NumberPages { from: from as usize - 1, to: to as usize - 1, style, prefix, first })?; + out["labels"] = json!(self.doc(&a)?.info.pages.iter().map(|p| p.label.clone()).collect::>()); + out + } + "bookmark_list" => self.bookmark_listing(&a)?, + "bookmark_add" => { + let page = self.page(&a)?; + let parent = a.opt_path("parent")?.unwrap_or_default(); + let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); + let title = a.str("title")?.to_string(); + let (left, top) = (a.bookmark_coordinate("left")?, a.bookmark_coordinate("top")?); + let [left, top] = match self.doc(&a)?.info.pages.get(page) { + Some(p) => p.xyz_for_view(left, top), + None => [None, None], + }; + let (left, top) = (left.map(f64::from), top.map(f64::from)); + self.apply(&a, Edit::AddBookmark { parent, index, title, page, left, top })? + } + "bookmark_rename" => { + let (path, title) = (a.path("path")?, a.str("title")?.to_string()); + self.apply(&a, Edit::RenameBookmark { path, title })? + } + "bookmark_delete" => { + let path = a.path("path")?; + self.apply(&a, Edit::DeleteBookmark { path })? + } + "bookmark_move" => { + let from = a.path("path")?; + let to_parent = a.opt_path("parent")?.unwrap_or_default(); + let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); + self.apply(&a, Edit::MoveBookmark { from, to_parent, index })? + } + "bookmark_set_page" => { + let (path, page) = (a.path("path")?, self.page(&a)?); + self.apply(&a, Edit::SetBookmarkPage { path, page })? + } + "bookmark_set_style" => { + let path = a.path("path")?; + // Left out keeps the colour; [0, 0, 0] is the default black. + let color = match a.get("color") { + None => None, + Some(v) => { + let rgb: Option> = v.as_array().and_then(|c| c.iter().map(Value::as_f64).collect()); + match rgb.as_deref() { + Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]), + _ => return Err(ToolError::InvalidArgs("color must be [r, g, b], each from 0 to 1".into())), + } + } + }; + let (bold, italic) = (a.opt_bool("bold")?, a.opt_bool("italic")?); + if color.is_none() && bold.is_none() && italic.is_none() { + return Err(ToolError::InvalidArgs("give color, bold or italic".into())); + } + let mut edits = Vec::new(); + if let Some(color) = color { + edits.push(Edit::SetBookmarkColor { path: path.clone(), color: Some(color) }); + } + if bold.is_some() || italic.is_some() { + edits.push(Edit::SetBookmarkStyle { path, bold, italic }); + } + // A colour and a style given together are one step to undo, applied all or nothing. + match edits.len() { + 1 => self.apply(&a, edits.remove(0))?, + _ => self.apply(&a, Edit::Batch { label: "Style".into(), edits })?, + } + } + "bookmark_from_structure" => { + let mut out = self.apply(&a, Edit::BookmarksFromStructure)?; + let listing = self.bookmark_listing(&a)?; + out["bookmarks"] = listing["bookmarks"].clone(); + out["next"] = listing["next"].clone(); + out + } + "doc_protect" => self.doc_protect(&a)?, + "page_replace" => { + let pages = self.pages(&a, "pages")?; + let path = self.resolve(a.str("path")?, false)?; + let bytes = Arc::new(read_input(&path)?); + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let src_pages = match a.opt_ints("from_pages")? { + Some(p) => one_based(&p)?, + None => (0..pages.len()).collect(), + }; + self.apply(&a, Edit::ReplacePages { pages, name, bytes, src_pages })? + } + "page_duplicate" => { + let pages = self.pages(&a, "pages")?; + let at = self.opt_position(&a, "at")?; + self.apply(&a, Edit::DuplicatePages { pages, at })? + } + "page_set_box" => self.page_set_box(&a)?, + "page_transition_set" => self.page_transition_set(&a)?, + "doc_create" => self.doc_create(&a)?, + "xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?, + "doc_flatten" => { + let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true)); + if !comments && !fields { + return Err(ToolError::InvalidArgs("nothing to flatten".into())); + } + self.apply(&a, Edit::Flatten { comments, fields })? + } + "doc_reduce" => { + let id = self.doc(&a)?.id; + let before = self.doc(&a)?.bytes.len(); + let path = self.resolve(a.str("path")?, true)?; + let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; + // As in the app, a copy that isn't smaller is not written (#490). + let written = bytes.len() < before; + if written { + write_atomic(&path, &bytes)?; + } + json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) + } + "doc_optimize" => self.doc_optimize(&a)?, + "doc_initial_view" => self.doc_initial_view(&a)?, + "doc_revisions" => self.doc_revisions(&a)?, + "text_lines" => { + let page = self.page(&a)?; + let doc = self.doc_to_copy(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let lines: Vec = doc + .text_lines(page) + .iter() + .enumerate() + .map(|(i, l)| { + let (u, v) = (info.user_to_view(l.rect[0] as f32, l.rect[1] as f32), info.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); + json!({ + "line": i + 1, + "text": l.text, + "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], + "font": l.base_font, + "size": (l.size * 100.0).round() / 100.0, + }) + }) + .collect(); + json!({ "page": page + 1, "count": lines.len(), "lines": lines }) + } + "image_hit" => { + let page = self.page(&a)?; + let point = a + .get("point") + .and_then(Value::as_array) + .filter(|p| p.len() == 2) + .and_then(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) + .filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1e9)) + .ok_or_else(|| ToolError::InvalidArgs("point must contain two finite coordinates".into()))?; + let doc = self.doc(&a)?; + let p = &doc.info.pages[page]; + let user = p.view_to_user(point[0] as f32, point[1] as f32).map(f64::from); + let images: Vec<_> = doc.page_images(page).iter().enumerate().filter(|(_, im)| im.contains(user)).map(|(i, _)| i + 1).collect(); + json!({"page":page+1,"images":images}) + } + "page_images" => { + let page = self.page(&a)?; + let doc = self.doc(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let list: Vec = doc + .page_images(page) + .iter() + .enumerate() + .map(|(i, im)| { + let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32)); + let visible = im.visible_rect.map(|rect| { + let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32)); + [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))] + }); + json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) + }) + .collect(); + json!({ "page": page + 1, "count": list.len(), "images": list }) + } + "image_edit" | "image_save" => { + let page = self.page(&a)?; + let n = self.doc(&a)?.page_images(page).len(); + let k = a.int("image")?; + if k < 1 || k as usize > n { + return Err(ToolError::InvalidArgs(format!("image {k} is out of range: page {} has {n} images", page + 1))); + } + let index = k as usize - 1; + if name == "image_save" { + let (ext, bytes) = self.doc(&a)?.page_image_file(page, index).map_err(failed)?; + let mut path = self.resolve(a.str("path")?, true)?; + if path.is_dir() { + // Adding the extension to a folder's name would write beside it: "." is the + // root, so that would land outside it. + return Err(failed(format!("{}: is a folder, not a file", path.display()))); + } + if path.extension().is_none() { + path.set_extension(ext); + } + write_atomic(&path, &bytes)?; + json!({ "path": path.to_string_lossy(), "format": ext, "bytes": bytes.len() }) + } else { + use pdfcraft_engine::ImageEdit; + let change = match a.str("action")? { + "move" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("move needs rect: 4 numbers".into()))?; + // Top-left-origin points → user space. + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + "rotate_by" => ImageEdit::RotateBy( + a.get("degrees") + .and_then(Value::as_f64) + .ok_or_else(|| ToolError::InvalidArgs("rotate_by needs degrees: a number (clockwise)".into()))?, + ), + "rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32), + "crop" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = + <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?; + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + "flip_horizontal" => ImageEdit::Flip { horizontal: true }, + "flip_vertical" => ImageEdit::Flip { horizontal: false }, + "delete" => ImageEdit::Delete, + "replace" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + ImageEdit::Replace { + name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + bytes: Arc::new(bytes), + } + } + other => return Err(ToolError::InvalidArgs(format!("unknown action {other:?}"))), + }; + self.apply(&a, Edit::EditPageImage { page, index, change })? + } + } + "text_paragraphs" => { + let page = self.page(&a)?; + let doc = self.doc_to_copy(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let current = doc.text_blocks(page); + let id = doc.id; + let blocks: Vec = current + .iter() + .enumerate() + .map(|(i, b)| { + let (u, v) = (info.user_to_view(b.rect[0] as f32, b.rect[1] as f32), info.user_to_view(b.rect[2] as f32, b.rect[3] as f32)); + json!({ + "paragraph": i + 1, + "text": b.text, + "editor_text": b.editor_text, + "rows": b.rows.iter().map(|row| row.iter().map(|i| i + 1).collect::>()).collect::>(), + "lines": b.lines.iter().map(|l| l + 1).collect::>(), + "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], + "font": b.base_font, + "size": (b.size * 100.0).round() / 100.0, + }) + }) + .collect(); + self.record_listing(id, page, ¤t); + json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks }) + } + "text_font_files" => { + let roots = if let Some(paths) = a.get("directories") { + let paths = paths + .as_array() + .filter(|p| p.len() <= 16) + .ok_or_else(|| ToolError::InvalidArgs("directories must be at most 16 paths".into()))?; + paths + .iter() + .map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false)) + .collect::>>()? + } else if let Some(roots) = &self.roots { + // The installed fonts are outside the roots: look in the read root instead. + let read = roots + .read_root() + .ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?; + vec![self.resolve(&read.to_string_lossy(), false)?] + } else if let Some(root) = &self.root { + vec![root.clone()] + } else { + pdfcraft_engine::text_fonts::installed_directories() + }; + json!({"fonts": pdfcraft_engine::text_fonts::discover(&roots).into_iter().map(|f| json!({"path":f.path,"family":f.family,"font":f.face})).collect::>()}) + } + "text_font_import" => { + let page = self.page(&a)?; + let path = self.resolve(a.str("path")?, false)?; + let file = std::fs::File::open(&path).map_err(failed)?; + use std::io::Read; + let mut bytes = Vec::new(); + file.take((16 << 20) + 1).read_to_end(&mut bytes).map_err(failed)?; + if bytes.len() > 16 << 20 { + return Err(ToolError::InvalidArgs("font input exceeds 16 MiB".into())); + } + let before: Vec<_> = self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| f.resource).collect(); + let mut out = self.apply(&a, Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })?; + let added = self + .doc(&a)? + .text_editor_fonts(page) + .into_iter() + .find(|f| !before.contains(&f.resource) && !f.resource.starts_with("PCApproved")); + if let Some(f) = added { + out["resource"] = json!(f.resource); + out["font"] = json!(f.face); + } + out + } + "text_fonts" => { + let page = self.page(&a)?; + json!({"fonts": self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| json!({"resource":f.resource,"font":f.face,"full_face":f.resource.starts_with("PCFull"),"preview_only":f.resource.starts_with("PCApproved")})).collect::>()}) + } + "text_edit_layout" => { + let page = self.page(&a)?; + let doc = self.doc(&a)?; + let k = a.int("paragraph")?; + let blocks = doc.text_blocks(page); + let index = self.paragraph_index(doc.id, page, k, &blocks)?; + let source = blocks.get(index).ok_or_else(|| ToolError::InvalidArgs("paragraph is out of range".into()))?; + let text = a.opt_str("text")?.unwrap_or(&source.editor_text); + let style = pdfcraft_engine::BlockStyle { + size: a.opt_num("size")?, + width: a.opt_num("width")?, + resource: a.opt_str("font_resource")?.map(str::to_owned), + ranges: parse_style_ranges(a.get("ranges"))?, + ..Default::default() + }; + let layout = doc.text_edit_layout(page, index, text, &style).map_err(failed)?; + let info = &doc.info.pages[page]; + let view_rect = |r: [f64; 4]| { + let a = info.user_to_view(r[0] as f32, r[1] as f32); + let b = info.user_to_view(r[2] as f32, r[3] as f32); + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] + }; + let fonts: Vec<_> = doc.text_editor_fonts(page).into_iter().map(|f| json!({"resource": f.resource, "face": f.face})).collect(); + json!({ "page": page + 1, "paragraph": k, "source_rect": view_rect(layout.source_rect), + "draft_rect": layout.draft_rect.map(view_rect), "overflow": layout.overflow, "embedded_fonts": fonts }) + } + "text_edit" if a.get("paragraph").is_some() => { + let page = self.page(&a)?; + let k = a.int("paragraph")?; + let (id, blocks) = { + let doc = self.doc(&a)?; + (doc.id, doc.text_blocks(page)) + }; + let index = self.paragraph_index(id, page, k, &blocks)?; + let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?; + let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); + let mut style = pdfcraft_engine::BlockStyle { + ranges: parse_style_ranges(a.get("ranges"))?, + resource: a.opt_str("font_resource")?.map(str::to_owned), + bold: a.opt_bool("bold")?, + size: a.opt_num("size")?, + underline: a.opt_bool("underline")?, + line_spacing: a.opt_num("line_spacing")?, + char_spacing: a.opt_num("char_spacing")?, + scale: a.opt_num("scale")?, + width: a.opt_num("width")?, + ..Default::default() + }; + let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?); + if dx.is_some() || dy.is_some() { + style.offset = Some([dx.unwrap_or(0.0), dy.unwrap_or(0.0)]); + } + if let Some(f) = a.opt_str("font")? { + let family = match f { + "helvetica" => pdfcraft_engine::FontFamily::Helvetica, + "times" => pdfcraft_engine::FontFamily::Times, + "courier" => pdfcraft_engine::FontFamily::Courier, + other => return Err(ToolError::InvalidArgs(format!("unknown font {other:?} (helvetica, times, courier)"))), + }; + style.family = Some((family, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))); + } + if let Some(c) = a.opt_str("color")? { + style.color = Some(comments::parse_color(c)?); + } + if let Some(al) = a.opt_str("align")? { + style.align = Some(match al { + "left" => pdfcraft_engine::TextAlign::Left, + "center" => pdfcraft_engine::TextAlign::Center, + "right" => pdfcraft_engine::TextAlign::Right, + "justify" => pdfcraft_engine::TextAlign::Justify, + other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))), + }); + } + // Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy). + let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); + let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy]; + let wanted: String = text.split_whitespace().collect(); + let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; + let doc = self.doc(&a)?; + let after = doc.text_blocks(page); + // Without `text` the paragraph keeps the document's own words: not echoed when + // the document doesn't allow copying them. + let copy = doc.allows_copying(); + // Only a paragraph that now shows exactly the requested text is the edited one. A + // neighbour that overlaps the old box (overlaid text, a text layer) must not take + // its number, and neither must a paragraph the edit merged it into: rewriting that + // one by this number would drop the neighbour's text. + let edited = block_overlapping(&after, moved) + .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); + if let Some(b) = edited.and_then(|i| after.get(i)) { + out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font }); + if copy { + out["paragraph"]["text"] = json!(b.text); + } + } + let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after); + if !stale.is_empty() { + out["stale_paragraphs"] = json!(stale); + } + out + } + "text_edit" => { + let page = self.page(&a)?; + let n = self.doc(&a)?.text_lines(page).len(); + let line = a.int("line")?; + if line < 1 || line as usize > n { + return Err(ToolError::InvalidArgs(format!("line {line} is out of range: page {} has {n} lines", page + 1))); + } + let text = a.str("text")?.to_owned(); + let mut out = self.apply(&a, Edit::EditTextLine { page, line: line as usize - 1, text })?; + let after = self.doc(&a)?.text_lines(page); + if let Some(l) = after.get(line as usize - 1) { + out["line"] = json!({ "text": l.text, "font": l.base_font }); + } + out + } + "doc_audit_space" => { + let rows: Vec = self + .doc(&a)? + .audit_space() + .iter() + .map(|u| json!({ "category": u.category.label(), "bytes": u.bytes, "percent": (u.percent * 100.0).round() / 100.0 })) + .collect(); + json!({ "categories": rows }) + } + "doc_open_revision" => { + let id = self.doc(&a)?.id; + let n = usize::try_from(a.opt_int("revision")?.ok_or_else(|| ToolError::InvalidArgs("revision is required".into()))?).unwrap_or(0); + let new = self.session.open_revision(id, n).map_err(failed)?; + summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) + } + "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, + "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, + "doc_unprotect" => { + let mut out = self.apply(&a, Edit::RemoveProtection)?; + out["security"] = security(self.doc(&a)?); + out + } + "form_fields" => self.form_fields(&a)?, + "form_fill" => self.form_fill(&a)?, + "form_set_image" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? + } + "form_reset" => self.form_reset(&a)?, + "form_add_field" => self.form_add_field(&a)?, + "form_add_field_table" => self.form_add_field_table(&a)?, + "form_set_props" => self.form_set_props(&a)?, + "form_delete_field" => self.form_delete_field(&a)?, + "form_tab_order" => self.form_tab_order(&a)?, + "form_calculation_order" => self.form_calculation_order(&a)?, + "doc_export_data" => self.doc_export_data(&a)?, + "doc_import_data" => self.doc_import_data(&a)?, + "redact_mark" => self.redact_mark(&a)?, + "redact_apply" => self.redact_apply(&a)?, + "redact_clear" => self.redact_clear(&a)?, + "doc_hidden_info" => self.doc_hidden_info(&a)?, + "printers" => self.printers()?, + "printer_options" => self.printer_options(&a)?, + "link_list" => self.link_list(&a)?, + "link_open" => self.link_open(&a)?, + "link_add" => self.link_add(&a)?, + "link_edit" => self.link_edit(&a)?, + "link_delete" => self.link_delete(&a)?, + "links_from_urls" => self.links_from_urls(&a)?, + "links_remove" => self.links_remove(&a)?, + "object_list" => self.object_list(&a)?, + "object_move" => self.object_move(&a)?, + "content_list" => self.content_list(&a)?, + "page_add_text" => self.page_add_text(&a)?, + "page_add_image" => self.page_add_image(&a)?, + "content_update" => self.content_update(&a)?, + "content_delete" => self.content_delete(&a)?, + "content_copy" => self.content_copy(&a)?, + "doc_print" => self.doc_print(&a)?, + "attachment_add" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + let name = match a.opt_str("name")? { + Some(n) => n.to_string(), + None => path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + }; + let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); + let description = a.opt_str("description")?.map(str::to_string); + // Only the document-level keys: an annotation attachment of the same name is not the new one. + let before = document_attachment_keys(self.doc(&a)?); + let out = self.apply(&a, Edit::AddAttachment { name, bytes, description, mime })?; + let key = document_attachment_keys(self.doc(&a)?).into_iter().find(|n| !before.contains(n)).unwrap_or_default(); + json!({ "name": key, "document": out }) + } + "attachment_delete" => { + let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; + self.apply(&a, edit)? + } + "doc_remove_hidden" => self.doc_remove_hidden(&a)?, + "doc_audit_redactions" => self.doc_audit_redactions(&a)?, + "fill_sign_add" => self.fill_sign_add(&a)?, + "fill_sign_date_format" => self.fill_sign_date_format(&a)?, + "measure_distance" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Distance)?, + "measure_perimeter" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Perimeter)?, + "measure_area" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Area)?, + "measure_info" => self.measurement_info(&a)?, + "measure_list" => self.measurement_list(&a)?, + "measure_scale" => self.measurement_scale(&a)?, + "measure_snap" => self.measurement_snap(&a)?, + "measure_export" => self.measurement_export(&a)?, + "comment_list" => self.comment_list(&a)?, + "comment_add" => self.comment_add(&a)?, + "stamp_custom" => self.stamp_custom(&a)?, + "comment_reply" => self.comment_reply(&a)?, + "comment_set_status" => self.comment_set_status(&a)?, + "sign_list" => self.sign_list(&a)?, + "accessibility_check" => self.a11y_check(&a)?, + "accessibility_find_untagged" => self.a11y_find_untagged(&a)?, + "accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?, + "accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?, + "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, + "ocr_recognize" => self.ocr_recognize(&a)?, + "js_run" => self.js_run(&a)?, + "js_document_scripts" => self.js_document_scripts(&a)?, + "js_set_document_script" => self.js_set_document_script(&a)?, + "js_enabled" => self.js_enabled(&a)?, + "form_set_script" => self.form_set_script(&a)?, + "form_merge_data" => self.form_merge_data(&a)?, + "form_actions" => self.form_actions(&a)?, + "form_detect_fields" => self.form_detect_fields(&a)?, + "doc_compare" => self.doc_compare(&a)?, + "action_list" => self.action_list()?, + "pdfa_verify" => self.pdfa_verify(&a)?, + "print_readiness" => self.print_readiness(&a)?, + "doc_export_office" => self.doc_export_office(&a)?, + "pdfa_convert" => self.pdfa_convert(&a)?, + "standards_remove_id" => { + let standard = match a.str("standard")? { + "pdfa" => pdfcraft_engine::Standard::PdfA, + "pdfx" => pdfcraft_engine::Standard::PdfX, + other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))), + }; + self.apply(&a, Edit::RemoveStandardId { standard })? + } + "action_run" => self.action_run(&a)?, + "doc_compare_report" => self.doc_compare_report(&a)?, + "doc_compare_mark" => self.doc_compare_mark(&a)?, + "form_set_actions" => self.form_set_actions(&a)?, + "ocr_status" => self.ocr_status()?, + "ocr_recognize_files" => self.ocr_recognize_files(&a)?, + "accessibility_report" => self.a11y_report(&a)?, + "accessibility_review_record" => self.a11y_review_record(&a)?, + "accessibility_review_save" => self.a11y_review_save(&a)?, + "accessibility_review_load" => self.a11y_review_load(&a)?, + "accessibility_review_html" => self.a11y_review_html(&a)?, + "accessibility_fix" => self.a11y_fix(&a)?, + "accessibility_figures" => self.accessibility_figures(&a)?, + "accessibility_set_alt" => self.accessibility_set_alt(&a)?, + "accessibility_tags" => self.accessibility_tags(&a)?, + "accessibility_tag_content" => self.accessibility_tag_content(&a)?, + "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, + "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, + "accessibility_move_tag" => self.accessibility_move_tag(&a)?, + "sign_id_create" => self.sign_id_create(&a)?, + "sign_document" => self.sign_document(&a)?, + "sign_keychain_ids" => { + #[cfg(target_os = "macos")] + let ids: Vec = pdfcraft_engine::sign::keychain::identities(None) + .map_err(failed)? + .iter() + .map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) + .collect(); + #[cfg(not(target_os = "macos"))] + let ids: Vec = Vec::new(); + json!({ "count": ids.len(), "ids": ids }) + } + "sign_windows_ids" => { + #[cfg(target_os = "windows")] + let (ids, unusable): (Vec, Vec) = { + let listing = pdfcraft_engine::sign::windows::list().map_err(failed)?; + let ids = listing + .ids + .iter() + .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) + .collect(); + let unusable = listing + .unusable + .iter() + .map(|u| json!({ "subject": u.subject, "sha256": u.fingerprint, "reason": u.reason, "no_private_key": u.no_private_key })) + .collect(); + (ids, unusable) + }; + #[cfg(not(target_os = "windows"))] + let (ids, unusable): (Vec, Vec) = (Vec::new(), Vec::new()); + json!({ "count": ids.len(), "ids": ids, "unusable": unusable }) + } + "sign_trust" => self.sign_trust(&a)?, + "comment_mark" => self.comment_mark(&a)?, + "comment_lock" => self.comment_lock(&a)?, + "comments_hide" => self.comments_hide(&a)?, + "layer_set" => self.layer_set(&a)?, + "view_reading_colors" => self.view_reading_colors(&a)?, + "comments_summarize" => self.comments_summarize(&a)?, + "comment_edit" => self.comment_edit(&a)?, + "comment_delete" => self.comment_delete(&a)?, + other => return Err(ToolError::UnknownTool(other.into())), + }; + Ok(vec![Content::Json(out)]) + } +} diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs new file mode 100644 index 0000000000..26addfd80b --- /dev/null +++ b/crates/automation/src/documents.rs @@ -0,0 +1,606 @@ +use super::error::{Result, ToolError, failed}; +use super::json::{Args, nest_rows, security, summary}; +use super::paths::{child, read_input, write_atomic}; +use super::*; + +/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). +const WATERMARK_MAX_OFFSET: f64 = 20_000.0; + +/// Bookmarks in one page of the bookmark list, by default and at most. +const BOOKMARK_PAGE: usize = 100; + +const MAX_BOOKMARK_PAGE: usize = 1000; + +impl Automation { + // ---- documents --------------------------------------------------------------------------- + + /// One page of a document's bookmarks: `offset` (from 0) and `limit` (1 to `MAX_BOOKMARK_PAGE`). + pub(crate) fn bookmark_listing(&self, a: &Args) -> Result { + let id = a.int("doc")?; + let id = DocId(u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?); + let offset = a.opt_int("offset")?.unwrap_or(0); + let offset = usize::try_from(offset).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?; + let limit = a.opt_int("limit")?.unwrap_or(BOOKMARK_PAGE as i64); + let limit = usize::try_from(limit) + .ok() + .filter(|l| (1..=MAX_BOOKMARK_PAGE).contains(l)) + .ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_BOOKMARK_PAGE}")))?; + let page = self + .session + .bookmark_page(id, offset, limit) + .ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?; + let pages = &self.doc(a)?.info.pages; + Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated })) + } + + pub(crate) fn doc(&self, a: &Args) -> Result<&Document> { + let id = a.int("doc")?; + let id = u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?; + self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)"))) + } + + /// Most pages whose paragraph listing is remembered. + const MAX_LISTINGS: usize = 256; + + /// Remember the paragraphs `text_paragraphs` just listed for `page`, so their numbers can be + /// resolved against that listing. + pub(crate) fn record_listing(&mut self, doc: DocId, page: usize, blocks: &[TextBlock]) { + if self.listed.len() >= Self::MAX_LISTINGS && !self.listed.contains_key(&(doc, page)) { + self.evicted.extend(self.listed.keys().map(|(d, _)| *d)); + self.listed.clear(); + } + self.listed.insert((doc, page), blocks.iter().map(BlockKey::of).collect()); + } + + /// The index in `blocks` (the page's paragraphs now) of paragraph number `k` (1-based). + /// + /// After `text_paragraphs` a number names the paragraph that listing showed, wherever it is + /// now. Without a listing it is a position in `blocks`. A paragraph an edit merged into a + /// neighbour (or changed) is refused: the number would otherwise rewrite another paragraph + /// and lose its text (#2516). So is any number on a page of a document whose listings were + /// dropped at the cap, until the page is listed again. + pub(crate) fn paragraph_index(&self, doc: DocId, page: usize, k: i64, blocks: &[TextBlock]) -> Result { + let keys = self.listed.get(&(doc, page)); + if keys.is_none() && self.evicted.contains(&doc) { + return Err(ToolError::InvalidArgs(format!( + "page {} has no paragraph listing any more: too many pages were listed and the oldest listings were dropped. List the page again with text_paragraphs", + page + 1 + ))); + } + let n = keys.map_or(blocks.len(), Vec::len); + let i = usize::try_from(k) + .ok() + .and_then(|k| k.checked_sub(1)) + .filter(|i| *i < n) + .ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)))?; + let Some(keys) = keys else { return Ok(i) }; + keys.get(i).and_then(|key| key.find(blocks, i)).ok_or_else(|| { + ToolError::InvalidArgs(format!( + "paragraph {k} of page {} is no longer a paragraph of its own: an edit changed it or how the page is grouped. List the page again with text_paragraphs", + page + 1 + )) + }) + } + + /// Update the listing after paragraph `k` was edited into `edited`, one of the page's + /// paragraphs `after`. Returns the numbers of the listed paragraphs that are gone. + pub(crate) fn follow_edit(&mut self, doc: DocId, page: usize, k: i64, edited: Option<&TextBlock>, after: &[TextBlock]) -> Vec { + let Some(keys) = self.listed.get_mut(&(doc, page)) else { return Vec::new() }; + if let (Some(edited), Some(key)) = (edited, usize::try_from(k).ok().and_then(|k| k.checked_sub(1)).and_then(|i| keys.get_mut(i))) { + *key = BlockKey::of(edited); + } + keys.iter().enumerate().filter(|(i, key)| key.find(after, *i).is_none()).map(|(i, _)| i + 1).collect() + } + + /// The document, if its security allows copying text and images out of it (Table 22, bit 5). + pub(crate) fn doc_to_copy(&self, a: &Args) -> Result<&Document> { + let doc = self.doc(a)?; + doc.check_copying().map_err(failed)?; + Ok(doc) + } + + pub(crate) fn doc_open(&mut self, a: &Args) -> Result { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; + let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; + Ok(summary(doc)) + } + + pub(crate) fn doc_close(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let discard_changes = a.opt_bool("discard_changes")?.unwrap_or(false); + if doc.dirty && !discard_changes { + return Err(failed("the document has unsaved changes: save it with doc_save, or pass discard_changes: true")); + } + self.session.close(id); + self.renderers.remove(&id); + self.texts.remove(&id); + self.listed.retain(|(d, _), _| *d != id); + self.evicted.remove(&id); + Ok(json!({ "closed": id.0 })) + } + + pub(crate) fn doc_save(&mut self, a: &Args) -> Result { + self.doc_save_with(a, write_atomic) + } + + pub(crate) fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let target = match a.opt_str("path")? { + Some(p) => self.resolve(p, true)?, + None => { + // Saving in place writes where the file was opened from, which the read root + // allows and the write root may not. + let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?; + let own_path = PathBuf::from(&own); + self.check_roots(&own, &own_path, file_access::Access::Write)?; + own_path + } + }; + let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); + // Saving to a new file is a full rewrite unless asked otherwise, like Save As. + let full = a.opt_bool("full")?.unwrap_or(!same_file); + let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false); + if flatten_fill_sign { + self.apply(a, Edit::FlattenFillSign)?; + } + let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; + // A save that adds or removes encryption, or must not keep the old revision (Sanitize, + // redaction), is rewritten in full even when an incremental save was asked for. + let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); + self.publish_save(id, &target, bytes, incremental, write) + } + + /// Validate the prepared bytes before publishing them or marking the document saved. + pub(crate) fn publish_save( + &mut self, + id: DocId, + target: &Path, + bytes: Arc>, + incremental: bool, + write: impl FnOnce(&Path, &[u8]) -> Result<()>, + ) -> Result { + self.session.check_save(id, &bytes).map_err(failed)?; + write(target, &bytes)?; + let path = target.to_string_lossy().into_owned(); + self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?; + let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?; + Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) })) + } + + pub(crate) fn doc_revisions(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let ends = doc.revision_ends(); + let sigs = doc.signatures.clone(); + let list: Vec = ends + .iter() + .enumerate() + .map(|(i, end)| { + let start = if i == 0 { 0 } else { ends[i - 1] }; + // A signature signs the revision its byte range ends in. + let signed: Vec<&str> = + sigs.iter().filter(|s| s.signed && s.signed_len > start && s.signed_len <= *end).map(|s| s.field.as_str()).collect(); + json!({ "revision": i + 1, "end": end, "bytes": end - start, "signed_by": signed }) + }) + .collect(); + Ok(json!({ "revisions": list, "unsaved_changes": doc.dirty })) + } + + pub(crate) fn doc_initial_view(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; + let bad = |m: String| ToolError::InvalidArgs(m); + let mut v = self.doc(a)?.initial_view(); + let before = v.clone(); + if let Some(n) = a.opt_str("navigation")? { + v.navigation = match n { + "page" => N::PageOnly, + "bookmarks" => N::Bookmarks, + "pages" => N::Pages, + "attachments" => N::Attachments, + "layers" => N::Layers, + o => return Err(bad(format!("unknown navigation {o:?}"))), + }; + } + if let Some(l) = a.opt_str("layout")? { + v.layout = match l { + "default" => L::Default, + "single" => L::SinglePage, + "continuous" => L::SinglePageContinuous, + "two_up" => L::TwoUp, + "two_up_continuous" => L::TwoUpContinuous, + "two_up_cover" => L::TwoUpCoverPage, + "two_up_continuous_cover" => L::TwoUpContinuousCoverPage, + o => return Err(bad(format!("unknown layout {o:?}"))), + }; + } + match a.get("magnification") { + None => {} + Some(Value::Number(p)) => v.magnification = M::Percent(p.as_f64().unwrap_or(100.0).clamp(1.0, 6400.0)), + Some(Value::String(m)) => { + v.magnification = match m.as_str() { + "default" => M::Default, + "actual" => M::ActualSize, + "fit_page" => M::FitPage, + "fit_width" => M::FitWidth, + "fit_height" => M::FitHeight, + "fit_visible" => M::FitVisible, + o => return Err(bad(format!("unknown magnification {o:?}"))), + } + } + Some(_) => return Err(bad("magnification is a name or a percentage".into())), + } + if let Some(p) = a.opt_int("page")? { + v.page = (p.max(1) - 1) as usize; + } + for (k, f) in [ + ("fit_window", &mut v.fit_window), + ("center_window", &mut v.center_window), + ("full_screen", &mut v.full_screen), + ("display_title", &mut v.display_title), + ("hide_menubar", &mut v.hide_menubar), + ("hide_toolbar", &mut v.hide_toolbar), + ("hide_window_ui", &mut v.hide_window_ui), + ] { + if let Some(b) = a.opt_bool(k)? { + *f = b; + } + } + if let Some(l) = a.opt_str("language")? { + v.language = (!l.trim().is_empty()).then(|| l.trim().to_string()); + } + if let Some(b) = a.opt_str("binding")? { + v.right_to_left = match b { + "left" => false, + "right" => true, + o => return Err(bad(format!("binding is left or right, not {o:?}"))), + }; + } + let mut out = if v != before { self.apply(a, Edit::SetInitialView(Box::new(v.clone())))? } else { json!({}) }; + out["initial_view"] = json!({ + "navigation": format!("{:?}", v.navigation), + "layout": format!("{:?}", v.layout), + "magnification": format!("{:?}", v.magnification), + "page": v.page + 1, + "fit_window": v.fit_window, + "center_window": v.center_window, + "full_screen": v.full_screen, + "display_title": v.display_title, + "hide_menubar": v.hide_menubar, + "hide_toolbar": v.hide_toolbar, + "hide_window_ui": v.hide_window_ui, + "language": v.language, + "binding": if v.right_to_left { "right" } else { "left" }, + }); + Ok(out) + } + + pub(crate) fn doc_optimize(&mut self, a: &Args) -> Result { + use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset}; + let id = self.doc(a)?.id; + let before = self.doc(a)?.bytes.len(); + let path = self.resolve(a.str("path")?, true)?; + let mut settings = match a.opt_str("preset")? { + None => Preset::Standard.settings(), + Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(), + }; + if let Some(c) = a.opt_str("compatibility")? { + settings.compatibility = Compatibility::from_name(c) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?; + } + let images = |key: &str, base: ImageSettings| -> Result { + let Some(v) = a.get(key) else { return Ok(base) }; + let mut s = base; + if let Some(d) = v.get("downsample").and_then(Value::as_bool) { + s.downsample = d; + } + if let Some(p) = v.get("ppi").and_then(Value::as_f64) { + s.target_ppi = p.clamp(9.0, 2400.0); + } + if let Some(p) = v.get("above_ppi").and_then(Value::as_f64) { + s.above_ppi = p.clamp(9.0, 2400.0); + } + s.above_ppi = s.above_ppi.max(s.target_ppi); + if let Some(c) = v.get("compression").and_then(Value::as_str) { + let q = v.get("quality").and_then(Value::as_u64).unwrap_or(60).clamp(1, 100) as u8; + s.compression = match c { + "jpeg" => Compression::Jpeg(q), + "zip" | "flate" => Compression::Flate, + "retain" => Compression::Retain, + other => return Err(ToolError::InvalidArgs(format!("unknown compression {other:?} (jpeg, zip, retain)"))), + }; + } + Ok(s) + }; + settings.color = images("color", settings.color)?; + settings.gray = images("gray", settings.gray)?; + for (key, flag) in [ + ("merge_fragments", &mut settings.merge_fragments), + ("discard_thumbnails", &mut settings.discard_thumbnails), + ("discard_alternate_images", &mut settings.discard_alternate_images), + ("discard_tags", &mut settings.discard_tags), + ("discard_print_settings", &mut settings.discard_print_settings), + ("flate_unencoded", &mut settings.flate_unencoded), + ("object_streams", &mut settings.object_streams), + ("lzw_to_flate", &mut settings.lzw_to_flate), + ("remove_invalid_links", &mut settings.remove_invalid_links), + ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), + ] { + if let Some(b) = a.opt_bool(key)? { + *flag = b; + } + } + let discard: Vec = match a.get("discard") { + None => Vec::new(), + Some(v) => v + .as_array() + .ok_or_else(|| ToolError::InvalidArgs("discard must be an array".into()))? + .iter() + .map(|x| { + x.as_str() + .and_then(pdfcraft_engine::Hidden::from_id) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown discard category {x}"))) + }) + .collect::>()?, + }; + let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; + write_atomic(&path, &bytes)?; + fn valid_version(v: &[u8]) -> Option<&str> { + if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None } + } + let catalog_version = (settings.compatibility == Compatibility::Retain) + .then(|| { + let pw = self.doc(a).ok().and_then(|d| d.password.as_deref()); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?; + let root = doc.root()?; + doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned) + }) + .flatten(); + let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version); + // Viewers read the later of the two (object streams raise the header past a lower catalog + // entry). Both are "d.d", so text order is version order. + let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max(); + let o = &r.optimize; + Ok(json!({ + "path": path.to_string_lossy(), + "pdf_version": pdf_version, + "object_streams": settings.effective_object_streams(), + "bytes_before": before, + "bytes_after": bytes.len(), + "merged_images": o.merged_images, + "fragments_merged": o.fragments_merged, + "images": o.images, + "images_resampled": o.images_resampled, + "images_recompressed": o.images_recompressed, + "image_bytes_before": o.image_bytes_before, + "image_bytes_after": o.image_bytes_after, + "thumbnails": o.thumbnails, + "alternate_images": o.alternate_images, + "tags_removed": o.tags_removed, + "streams_compressed": o.streams_compressed, + "lzw_streams": o.lzw_streams, + "invalid_links": o.invalid_links, + "invalid_bookmarks": o.invalid_bookmarks, + "unreferenced_dests": o.unreferenced_dests, + "unused_xobjects": o.unused_xobjects, + "merged_objects": r.merged, + "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), + })) + } + + /// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based). + fn mark_file(&self, a: &Args) -> Result> { + let Some(p) = a.opt_str("file")? else { return Ok(None) }; + let path = self.resolve(p, false)?; + let bytes = read_input(&path)?; + let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1; + Ok(Some(pdfcraft_engine::MarkFile { + name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + bytes: Arc::new(bytes), + page, + })) + } + + pub(crate) fn apply(&mut self, a: &Args, edit: Edit) -> Result { + let id = self.doc(a)?.id; + self.session.apply(id, edit).map_err(failed)?; + let doc = self.doc(a)?; + let mut out = summary(doc); + // Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent. + if !doc.edit_notices.is_empty() { + out["warnings"] = json!(doc.edit_notices.iter().map(ToString::to_string).collect::>()); + } + Ok(out) + } + + pub(crate) fn marks(&mut self, tool: &str, a: &Args) -> Result { + use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark}; + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let color = + |key: &str, default: [f64; 3]| -> Result<[f64; 3]> { Ok(a.opt_str(key)?.map(comments::parse_color).transpose()?.unwrap_or(default)) }; + let replace = a.opt_bool("replace")?.unwrap_or(false); + let edit = match tool { + "doc_header_footer" => { + let mut hf = HeaderFooter::default(); + for (k, key) in ["header_left", "header_center", "header_right", "footer_left", "footer_center", "footer_right"].iter().enumerate() { + hf.text[k] = a.opt_str(key)?.unwrap_or_default().to_string(); + } + if let Some(s) = a.opt_num("font_size")? { + hf.font_size = s; + } + hf.font = match a.opt_str("font")? { + None | Some("Helvetica") => pdfcraft_engine::FontFamily::Helvetica, + Some("Times") => pdfcraft_engine::FontFamily::Times, + Some("Courier") => pdfcraft_engine::FontFamily::Courier, + Some(other) => { + return Err(ToolError::InvalidArgs(format!("font must be \"Helvetica\", \"Times\" or \"Courier\", not {other:?}"))); + } + }; + hf.color = color("color", hf.color)?; + if let Some(m) = a.get("margins").and_then(Value::as_array) { + let m: Vec = m.iter().filter_map(Value::as_f64).collect(); + hf.margins = <[f64; 4]>::try_from(m).map_err(|_| ToolError::InvalidArgs("margins must be 4 numbers".into()))?; + } + if let Some(s) = a.opt_int("start_number")? { + hf.start_number = s.clamp(1, u32::MAX as i64) as u32; + } + hf.shrink = a.opt_bool("shrink")?.unwrap_or(false); + Edit::AddHeaderFooter { pages, settings: hf, replace } + } + "doc_watermark" => { + let d = Watermark::default(); + let file = self.mark_file(a)?; + let text = match &file { + Some(_) => a.opt_str("text")?.unwrap_or_default().to_string(), + None => a.str("text")?.to_string(), + }; + let wm = Watermark { + text, + source: None, + scale: a.opt_num("scale")?.unwrap_or(d.scale).clamp(0.01, 1.0), + font_size: a.opt_num("font_size")?.unwrap_or(0.0), + color: color("color", d.color)?, + opacity: a.opt_num("opacity")?.unwrap_or(d.opacity), + rotation: a.opt_num("rotation")?.unwrap_or(d.rotation), + behind: a.opt_bool("behind")?.unwrap_or(false), + offset: [ + a.opt_num("offset_x")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), + a.opt_num("offset_y")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), + ], + }; + Edit::AddWatermark { pages, settings: wm, replace, file } + } + "doc_background" => Edit::AddBackground { + pages, + settings: Background { + color: color("color", [1.0; 3])?, + opacity: a.opt_num("opacity")?.unwrap_or(1.0), + scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0), + ..Background::default() + }, + replace, + file: self.mark_file(a)?, + }, + _ => { + let kind = match a.str("kind")? { + "header_footer" => MarkKind::HeaderFooter, + "watermark" => MarkKind::Watermark, + "background" => MarkKind::Background, + other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))), + }; + Edit::RemoveMarks { kind } + } + }; + self.apply(a, edit) + } + + pub(crate) fn export(&mut self, tool: &str, a: &Args) -> Result { + let doc = self.doc_to_copy(a)?; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); + let mut ex = pdfcraft_engine::export::Exporter::new(doc); + if tool == "doc_export_text" { + let path = self.resolve(a.str("path")?, true)?; + let text = ex.text_of(&pages).map_err(failed)?; + write_atomic(&path, text.as_bytes())?; + return Ok(json!({ "path": path.to_string_lossy(), "pages": pages.len(), "bytes": text.len() })); + } + let folder = self.resolve(a.str("folder")?, true)?; + std::fs::create_dir_all(&folder).map_err(|e| failed(format!("{}: {e}", folder.display())))?; + if tool == "doc_export_all_images" { + let min = u32::try_from(a.opt_int("min_size")?.unwrap_or(0).max(0)).unwrap_or(u32::MAX); + let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?; + let mut files = Vec::new(); + for (k, img) in out.images.iter().enumerate() { + let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len())); + write_atomic(&path, &img.data)?; + files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height })); + } + let skipped: Vec = out.skipped.iter().map(|(p, r, why)| json!({ "page": p + 1, "object": r.num, "reason": why })).collect(); + return Ok(json!({ "count": files.len(), "files": files, "skipped": skipped })); + } + let dpi = a.opt_num("dpi")?.unwrap_or(150.0); + let quality = a.opt_int("quality")?.unwrap_or(85).clamp(1, 100) as u8; + let format = match a.opt_str("format")?.unwrap_or("png") { + "png" => pdfcraft_engine::export::ImageFormat::Png, + "jpeg" | "jpg" => pdfcraft_engine::export::ImageFormat::Jpeg { quality }, + "tiff" | "tif" => pdfcraft_engine::export::ImageFormat::Tiff, + f => return Err(ToolError::InvalidArgs(format!("unknown format {f:?} (png, jpeg, tiff)"))), + }; + let mut files = Vec::new(); + let mut lowered = Vec::new(); + for p in pages { + let img = ex.image(p, dpi, format).map_err(failed)?; + let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension())); + write_atomic(&path, &img)?; + files.push(path.to_string_lossy().into_owned()); + // A page too large for the renderer at `dpi` is drawn at the most it allows. + let used = ex.dpi_used(p, dpi); + if used < dpi.clamp(18.0, 1200.0) - 0.5 { + lowered.push(json!({ "page": p + 1, "dpi": used.floor() })); + } + } + if lowered.is_empty() { + Ok(json!({ "count": files.len(), "files": files })) + } else { + Ok(json!({ "count": files.len(), "files": files, "lower_dpi": lowered })) + } + } + + pub(crate) fn doc_protect(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{Algorithm, Changes, Printing, Protection}; + let d = Protection::default(); + // Restrictions only exist behind a permissions password (ISO 32000-2 §7.6.4.4: /P is + // enforced against the owner password; without one everything stays allowed). Refuse a + // restriction that could not take effect instead of writing an unrestricted file (#134). + let restriction = ["printing", "changes", "copy", "accessibility"].into_iter().find(|k| a.get(k).is_some()); + if let (None, Some(key)) = (a.opt_str("permissions_password")?, restriction) { + return Err(ToolError::InvalidArgs(format!( + "`{key}` needs `permissions_password`: with open_password alone the document is encrypted but nothing is restricted" + ))); + } + let p = Protection { + open_password: a.opt_str("open_password")?.map(str::to_owned), + permissions_password: a.opt_str("permissions_password")?.map(str::to_owned), + printing: match a.opt_str("printing")? { + None => d.printing, + Some("none") => Printing::None, + Some("low") => Printing::Low, + Some("high") => Printing::High, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown printing {o:?}"))), + }, + changes: match a.opt_str("changes")? { + None => d.changes, + Some("none") => Changes::None, + Some("pages") => Changes::Pages, + Some("fill-sign") => Changes::FillSign, + Some("comment-fill-sign") => Changes::CommentFillSign, + Some("any-except-extract") => Changes::AnyExceptExtract, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown changes {o:?}"))), + }, + copy: a.opt_bool("copy")?.unwrap_or(d.copy), + accessibility: a.opt_bool("accessibility")?.unwrap_or(d.accessibility), + algorithm: match a.opt_str("compatibility")? { + None | Some("aes-256") => Algorithm::Aes256, + Some("aes-128") => Algorithm::Aes128, + Some("rc4-128") => Algorithm::Rc4_128, + Some("rc4-40") => Algorithm::Rc4_40, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown compatibility {o:?}"))), + }, + encrypt_metadata: a.opt_bool("encrypt_metadata")?.unwrap_or(d.encrypt_metadata), + }; + let mut out = self.apply(a, Edit::Protect(p))?; + out["security"] = security(self.doc(a)?); + Ok(out) + } +} diff --git a/crates/automation/src/error.rs b/crates/automation/src/error.rs new file mode 100644 index 0000000000..18e58939ed --- /dev/null +++ b/crates/automation/src/error.rs @@ -0,0 +1,33 @@ +/// Why a tool call failed. +#[derive(Clone, Debug, PartialEq)] +pub enum ToolError { + /// No tool has this name. + UnknownTool(String), + /// The arguments don't match the tool's schema. + InvalidArgs(String), + /// The tool ran and failed (the message is for the agent to read). + Failed(String), +} + +impl std::fmt::Display for ToolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ToolError::UnknownTool(t) => write!(f, "unknown tool {t:?}"), + ToolError::InvalidArgs(m) | ToolError::Failed(m) => f.write_str(m), + } + } +} + +impl std::error::Error for ToolError {} + +pub(crate) type Result = std::result::Result; + +pub(crate) fn failed(e: impl std::fmt::Display) -> ToolError { + ToolError::Failed(e.to_string()) +} + +impl From for ToolError { + fn from(e: pdfcraft_engine::EditError) -> Self { + failed(e) + } +} diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs new file mode 100644 index 0000000000..c8710aa509 --- /dev/null +++ b/crates/automation/src/json.rs @@ -0,0 +1,361 @@ +use super::error::{Result, ToolError}; +use super::*; + +/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at +/// most 14 400 across). +const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0; + +// ---- JSON helpers ---------------------------------------------------------------------------- + +pub(crate) fn parse_style_ranges(value: Option<&Value>) -> Result> { + let Some(value) = value else { return Ok(Vec::new()) }; + let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?; + values + .iter() + .map(|v| { + let a = Args(v); + let bounds = a + .get("range") + .and_then(Value::as_array) + .filter(|v| v.len() == 2) + .ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?; + let start = bounds + .first() + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; + let end = bounds + .get(1) + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; + let family = a + .opt_str("font")? + .map(|f| match f { + "helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica), + "times" => Ok(pdfcraft_engine::FontFamily::Times), + "courier" => Ok(pdfcraft_engine::FontFamily::Courier), + _ => Err(Args::wrong("font", "helvetica, times or courier")), + }) + .transpose()? + .map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) }) + .transpose()?; + Ok(pdfcraft_engine::RangeStyle { + range: start..end, + family, + resource: a.opt_str("font_resource")?.map(str::to_owned), + size: a.opt_num("size")?, + color: a.opt_str("color")?.map(comments::parse_color).transpose()?, + underline: a.opt_bool("underline")?, + char_spacing: a.opt_num("char_spacing")?, + scale: a.opt_num("scale")?, + }) + }) + .collect() +} + +/// Typed access to validated arguments. +pub(crate) struct Args<'a>(pub(crate) &'a Value); + +impl Args<'_> { + pub(crate) fn get(&self, key: &str) -> Option<&Value> { + self.0.get(key).filter(|v| !v.is_null()) + } + pub(crate) fn missing(key: &str) -> ToolError { + ToolError::InvalidArgs(format!("missing argument {key}")) + } + pub(crate) fn wrong(key: &str, what: &str) -> ToolError { + ToolError::InvalidArgs(format!("{key} must be {what}")) + } + pub(crate) fn str(&self, key: &str) -> Result<&str> { + self.opt_str(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_str(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_str().ok_or_else(|| Self::wrong(key, "a string"))).transpose() + } + pub(crate) fn int(&self, key: &str) -> Result { + self.opt_int(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_int(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_i64().ok_or_else(|| Self::wrong(key, "an integer"))).transpose() + } + pub(crate) fn opt_num(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| Self::wrong(key, "a number"))).transpose() + } + pub(crate) fn opt_bool(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_bool().ok_or_else(|| Self::wrong(key, "true or false"))).transpose() + } + pub(crate) fn ints(&self, key: &str) -> Result> { + self.opt_ints(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_ints(&self, key: &str) -> Result>> { + let Some(v) = self.get(key) else { return Ok(None) }; + let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of integers"))?; + arr.iter().map(|x| x.as_i64().ok_or_else(|| Self::wrong(key, "an array of integers"))).collect::>>().map(Some) + } + /// A 1-based bookmark path → 0-based indices. + pub(crate) fn path(&self, key: &str) -> Result> { + let p = self.opt_path(key)?.ok_or_else(|| Self::missing(key))?; + if p.is_empty() { + return Err(ToolError::InvalidArgs(format!("{key} must not be empty"))); + } + Ok(p) + } + pub(crate) fn opt_path(&self, key: &str) -> Result>> { + let Some(v) = self.opt_ints(key)? else { return Ok(None) }; + v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::>>().map(Some) + } + /// A bookmark's `left` or `top`: points on the displayed page, `None` when left out, an error + /// when it is not a number or lies absurdly far off the page. + pub(crate) fn bookmark_coordinate(&self, key: &str) -> Result> { + match self.opt_num(key)? { + None => Ok(None), + Some(v) if v.is_finite() && v.abs() <= MAX_BOOKMARK_POSITION => Ok(Some(v as f32)), + Some(_) => Err(ToolError::InvalidArgs(format!("{key} must be between -{MAX_BOOKMARK_POSITION} and {MAX_BOOKMARK_POSITION} points"))), + } + } + pub(crate) fn strs(&self, key: &str) -> Result> { + let v = self.get(key).ok_or_else(|| Self::missing(key))?; + let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?; + arr.iter().map(|x| x.as_str().ok_or_else(|| Self::wrong(key, "an array of strings"))).collect() + } +} + +/// The name-tree keys of a document's own attachments, leaving out the files attached to pages as comments. +pub(crate) fn document_attachment_keys(doc: &Document) -> Vec { + doc.info + .attachments + .iter() + .filter_map(|x| match &x.source { + pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), + pdfcraft_render::AttachmentSource::Annotation { .. } => None, + }) + .collect() +} + +/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the +/// next level down in `children`, and paths and pages are 1-based. +pub(crate) fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { + // The open rows, outermost first, with their depth and the children gathered so far. + let mut open: Vec<(usize, Value, Vec)> = Vec::new(); + let mut roots = Vec::new(); + for row in rows { + let depth = row.path.len().saturating_sub(1); + while open.last().is_some_and(|(d, _, _)| *d >= depth) { + close_row(&mut open, &mut roots); + } + let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); + let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); + // Only what differs from the defaults (black, regular), as the PDF itself stores it. + if let Some(color) = row.color { + fields["color"] = json!(color); + } + if row.bold { + fields["bold"] = json!(true); + } + if row.italic { + fields["italic"] = json!(true); + } + if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) { + fields["destination"] = destination; + } + open.push((depth, fields, Vec::new())); + } + while !open.is_empty() { + close_row(&mut open, &mut roots); + } + roots +} + +/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page +/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its +/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the +/// reader's position and zoom). `None` for a destination without a type. +pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option { + use pdfcraft_render::DestView; + let points = |left: Option, top: Option| { + let [x, y] = page.dest_fraction(left, top, 0); + let round = |v: f32| f64::from((v * 100.0).round() / 100.0); + (x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height))) + }; + Some(match view { + DestView::Top => return None, + DestView::Xyz { left, top, zoom } => { + let (x, y) = points(*left, *top); + json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) }) + } + DestView::Fit => json!({ "fit": "Fit" }), + DestView::FitH { top } => { + let (x, y) = points(None, *top); + json!({ "fit": "FitH", "left": x, "top": y }) + } + DestView::FitV { left } => { + let (x, y) = points(*left, None); + json!({ "fit": "FitV", "left": x, "top": y }) + } + DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }), + }) +} + +/// Closes the innermost open row: it joins the row above it as a child, or the top level. +fn close_row(open: &mut Vec<(usize, Value, Vec)>, roots: &mut Vec) { + if let Some((_, mut fields, children)) = open.pop() { + fields["children"] = Value::Array(children); + match open.last_mut() { + Some((_, _, siblings)) => siblings.push(fields), + None => roots.push(fields), + } + } +} + +pub(crate) fn one_based(pages: &[i64]) -> Result> { + pages + .iter() + .map(|p| if *p >= 1 { Ok(*p as usize - 1) } else { Err(ToolError::InvalidArgs(format!("page numbers start at 1 (got {p})"))) }) + .collect() +} + +/// The document's security as the next save writes it (never includes passwords). +pub(crate) fn security(d: &Document) -> Value { + match d.security_summary() { + None => json!({ "protected": false }), + Some(s) => { + let p = s.permissions; + json!({ + "protected": true, + "method": s.method, + "pending": s.pending, + "printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" }, + "modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(), + "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), + }) + } + } +} + +/// What every tool that changes a document returns. +pub(crate) fn summary(d: &Document) -> Value { + json!({ + "doc": d.id.0, + "name": d.name, + "path": d.path, + "pages": d.info.pages.len(), + "dirty": d.dirty, + "editable": d.read_only_reason.is_none(), + "read_only_reason": d.read_only_reason, + "encrypted": d.info.encrypted, + "undo": d.can_undo(), + "redo": d.can_redo(), + }) +} + +pub(crate) fn info(d: &Document) -> Value { + let i = &d.info; + let page1 = |p: usize| p + 1; + let mut security = security(d); + if let Some(s) = d.security_summary() { + security["opened_as_owner"] = json!(s.owner); + } + json!({ + "document": summary(d), + "pdf_version": i.pdf_version, + "file_size": i.file_size, + "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, + // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the + // ones the spec names, set and cleared with doc_set_info. + "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), + "creator": i.creator, "producer": i.producer, + "tagged": i.tagged, + "has_javascript": i.has_javascript, + // Where the document opens (/OpenAction, named destinations resolved): 1-based page and + // destination, null without one. + "open_destination": i.open_dest.as_ref().map(|(p, view)| json!({ "page": page1(*p), "destination": i.pages.get(*p).and_then(|pg| destination(view, pg)) })), + // XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic" + // (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed). + "xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), + "xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })), + // A script of the form ran too long and was abandoned: its scripts no longer run in this + // document (calculations, buttons) until it is opened again. + "xfa_scripts_off": d.xfa_scripts_off(), + // What was rewritten from, or could not be written to, the XFA data. + "xfa_warnings": d.xfa_warnings, + "security": security, + "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ + "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, + })).collect::>(), + "outline": outline(&i.outline), + "outline_more": i.outline_more, + // Rectangles use the tools' convention (top-left of the displayed page, like + // comment_list and link_list), not raw PDF user space, so they can be fed back to + // geometry-taking tools (#129). + "annotations": i.annotations.iter().map(|a| json!({ + "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, + "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, + "rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))), + })).collect::>(), + "fields": i.fields.iter().map(|f| json!({ + "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), + "tooltip": f.tooltip, "has_actions": f.has_actions, + })).collect::>(), + "links": i.links.iter().map(|l| json!({ + "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), + "target": match &l.target { + pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), + pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), + pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ + "layers": changes.iter().map(|(op, ocg)| json!({ + "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), + "state": match op { + pdfcraft_render::LayerOp::On => "on", + pdfcraft_render::LayerOp::Off => "off", + pdfcraft_render::LayerOp::Toggle => "toggle", + }, + })).collect::>(), + "preserve_rb": preserve_rb, + }), + pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ + "action": if *launch { "Launch" } else { "GoToR" }, + "remote_document": file, + "destination": match destination { + pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), + pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), + pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), + }, + "new_window": new_window, + }), + pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), + }, + })).collect::>(), + "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), + // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with + // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. + "transitions": i.transitions.iter().map(|t| json!({ + "page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration, + "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, + "display": t.display, + })).collect::>(), + "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), + // `substitute` names the face standing in for a program the document does not embed, and + // `substitution` says why. Both are null for an embedded font: nothing stands in. A null + // `substitute` with a non-null `substitution` means no face was available at all, so + // nothing is drawn for that font -- which is the answer to "why is my text missing?". + "fonts": i.fonts.iter().map(|f| json!({ + "name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding, + "substitute": f.substitute, "substitution": f.substitution, + })).collect::>(), + "warnings": i.warnings, + "repairs": d.repair_log(), + }) +} + +/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the +/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports +/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page). +/// Public so `pdfcraft-cli` reports the same rectangles as the tools. +pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { + i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) +} + +fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { + Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5bf44c9653..e1ae0141c4 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -45,73 +45,32 @@ use serde_json::{Value, json}; pub use pdfcraft_render::Pixels; pub use tools::{ToolDef, tools}; -/// One piece of a tool's result. -#[derive(Clone, Debug, PartialEq)] -pub enum Content { - /// Structured data (MCP: a text block with the JSON plus `structuredContent`). - Json(Value), - /// A PNG image. - Png { data: Vec, width: u32, height: u32 }, -} - -/// Why a tool call failed. -#[derive(Clone, Debug, PartialEq)] -pub enum ToolError { - /// No tool has this name. - UnknownTool(String), - /// The arguments don't match the tool's schema. - InvalidArgs(String), - /// The tool ran and failed (the message is for the agent to read). - Failed(String), -} - -impl std::fmt::Display for ToolError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ToolError::UnknownTool(t) => write!(f, "unknown tool {t:?}"), - ToolError::InvalidArgs(m) | ToolError::Failed(m) => f.write_str(m), - } - } -} - -impl std::error::Error for ToolError {} - -type Result = std::result::Result; - -fn failed(e: impl std::fmt::Display) -> ToolError { - ToolError::Failed(e.to_string()) -} - -impl From for ToolError { - fn from(e: pdfcraft_engine::EditError) -> Self { - failed(e) - } -} - -/// Default and maximum resolution for `page_render`. -const DEFAULT_DPI: f64 = 96.0; -const MAX_DPI: f64 = 600.0; -/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). -const WATERMARK_MAX_OFFSET: f64 = 20_000.0; - -/// Bookmarks in one page of the bookmark list, by default and at most. -const BOOKMARK_PAGE: usize = 100; -const MAX_BOOKMARK_PAGE: usize = 1000; -/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at -/// most 14 400 across). -const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0; - -/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a -/// match near the start returns without touching the rest of a long document (#971). -const FIND_FIRST_BATCH: usize = 8; -const FIND_MAX_BATCH: usize = 128; - use file_access::foreign_share; use text_cache::TextCache; -type PageTexts = Vec<(usize, std::result::Result, String>)>; -const FIND_MAX_WARNINGS: usize = 100; -const FIND_MAX_WARNING_BYTES: usize = 1024; +mod assemble; +mod dispatch; +mod documents; +mod error; +mod json; +mod pages; +mod paths; + +pub use dispatch::Content; +pub use error::ToolError; +use error::{Result, failed}; +pub(crate) use json::destination; +pub use json::view_rect; +use json::{Args, info, summary}; +use pages::{DEFAULT_DPI, MAX_DPI, encode_png}; +#[cfg(test)] +use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel}; +#[cfg(test)] +pub(crate) use paths::MAX_INPUT; +use paths::{child, lexical, unused, write_atomic}; +pub(crate) use paths::{read_input, read_input_bytes}; +#[cfg(test)] +use paths::{write_atomic_synced, write_atomic_with}; /// A headless PdfCraft session driven by tool calls. pub struct Automation { @@ -205,2652 +164,6 @@ impl Automation { } Ok(target) } - - /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). - pub fn call(&mut self, name: &str, args: &Value) -> Result> { - let empty = json!({}); - let args = if args.is_null() { &empty } else { args }; - if !args.is_object() { - return Err(ToolError::InvalidArgs("arguments must be a JSON object".into())); - } - let def = tools::find(name).ok_or_else(|| ToolError::UnknownTool(name.into()))?; - tools::check_args(def, args)?; - let a = Args(args); - let out = match name { - "doc_open" => self.doc_open(&a)?, - "doc_batch" => self.doc_batch(&a)?, - "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), - "doc_info" => { - let id = self.doc(&a)?.id; - self.session.refresh_fonts(id); - info(self.doc(&a)?) - } - "doc_close" => self.doc_close(&a)?, - "doc_save" => self.doc_save(&a)?, - "doc_set_info" => { - let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() }; - self.apply(&a, edit)? - } - "page_render" => return self.page_render(&a).map(|c| vec![c]), - "comment_image_preview" => return self.comment_image_preview(&a), - "text_extract" => self.text_extract(&a)?, - "text_word_count" => self.text_word_count(&a)?, - "text_find" => self.text_find(&a)?, - "page_rotate" => { - let degrees = a.int("degrees")?; - if degrees % 90 != 0 { - return Err(ToolError::InvalidArgs("degrees must be a multiple of 90".into())); - } - let doc = self.doc(&a)?; - let base = match a.opt_ints("pages")? { - Some(_) => self.pages(&a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - let parity = match a.opt_str("subset")?.unwrap_or("all") { - "all" => pdfcraft_engine::PageParity::Both, - "even" => pdfcraft_engine::PageParity::Even, - "odd" => pdfcraft_engine::PageParity::Odd, - s => return Err(ToolError::InvalidArgs(format!("unknown subset {s:?} (all, even, odd)"))), - }; - let orientation = match a.opt_str("orientation")?.unwrap_or("all") { - "all" => pdfcraft_engine::PageOrientation::Both, - "landscape" => pdfcraft_engine::PageOrientation::Landscape, - "portrait" => pdfcraft_engine::PageOrientation::Portrait, - o => return Err(ToolError::InvalidArgs(format!("unknown orientation {o:?} (all, landscape, portrait)"))), - }; - let pages = pdfcraft_engine::filter_pages(&self.doc(&a)?.info, &base, parity, orientation); - if pages.is_empty() { - return Err(failed("no pages match the filters")); - } - let n = pages.len(); - let mut out = self.apply(&a, Edit::RotatePages { pages, degrees })?; - out["rotated"] = json!(n); - out - } - "page_enhance_camera" => { - let page = self.page(&a)?; - let given = a.nums::<8>("corners")?; - let doc = self.doc(&a)?; - let (id, info) = (doc.id, doc.info.pages.get(page).cloned().ok_or_else(|| failed("no such page"))?); - let corners = match given { - Some(c) => [0, 1, 2, 3].map(|i| { - let [x, y] = info.view_to_user(c[2 * i] as f32, c[2 * i + 1] as f32); - [x as f64, y as f64] - }), - None => self.session.detect_camera_corners(id, page).ok_or_else(|| { - failed("the document's edges could not be found on this page: pass `corners` [x1, y1, x2, y2, x3, y3, x4, y4]") - })?, - }; - let level = match a.opt_int("level")? { - None => 0, - Some(l @ 0..=100) => l as u8, - Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), - }; - let look = match a.opt_str("mode")? { - None => pdfcraft_engine::camera_clean::Look::Colour, - Some(m) => pdfcraft_engine::camera_clean::Look::from_name(m) - .ok_or_else(|| ToolError::InvalidArgs(format!("unknown mode {m:?} (original, colour, greyscale, bw)")))?, - }; - self.session.enhance_camera_page_at(id, page, corners, look, level).map_err(failed)?; - let r = |v: f32| (v * 100.0).round() / 100.0; - let used: Vec = corners.iter().flat_map(|&[x, y]| info.user_to_view(x as f32, y as f32)).map(r).collect(); - let mut out = summary(self.doc(&a)?); - out["corners"] = json!(used); - out["detected"] = json!(given.is_none()); - out - } - "page_delete" => { - let pages = self.pages(&a, "pages")?; - self.apply(&a, Edit::DeletePages { pages })? - } - "page_move" => { - let pages = self.pages(&a, "pages")?; - let to = self.position(&a, "to")?; - self.apply(&a, Edit::MovePages { pages, to })? - } - "page_reverse" => { - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(&a, "pages")?, - None => (0..self.doc(&a)?.info.pages.len()).collect(), - }; - self.apply(&a, Edit::ReversePages { pages })? - } - "page_insert_blank" => self.insert_blank(&a)?, - "page_insert_file" => self.insert_file(&a)?, - "page_extract" => self.page_extract(&a)?, - "doc_combine" => self.doc_combine(&a)?, - "doc_create_multiple" => self.doc_create_multiple(&a)?, - "doc_split" => self.doc_split(&a)?, - "edit_undo" => { - let id = self.doc(&a)?.id; - let label = self.session.undo(id).map_err(failed)?.to_string(); - json!({ "undone": label, "document": summary(self.doc(&a)?) }) - } - "edit_redo" => { - let id = self.doc(&a)?.id; - let label = self.session.redo(id).map_err(failed)?.to_string(); - json!({ "redone": label, "document": summary(self.doc(&a)?) }) - } - "command_list" => self.command_list(&a)?, - "command_run" => return self.command_run(&a), - "command_batch" => self.command_batch(&a)?, - "doc_inspect" => self.doc_inspect(&a)?, - "render_preview" => return self.render_preview(&a).map(|c| vec![c]), - "page_number" => { - use pdfcraft_organize::LabelStyle as L; - let n = self.doc(&a)?.info.pages.len(); - let (from, to) = (a.int("from")?, a.int("to")?); - if from < 1 || to < from || to as usize > n { - return Err(ToolError::InvalidArgs(format!("from and to must satisfy 1 ≤ from ≤ to ≤ {n}"))); - } - let style = match a.opt_str("style")?.unwrap_or("decimal") { - "decimal" => L::Decimal, - "upper-roman" => L::UpperRoman, - "lower-roman" => L::LowerRoman, - "upper-alpha" => L::UpperAlpha, - "lower-alpha" => L::LowerAlpha, - "none" => L::None, - other => return Err(ToolError::InvalidArgs(format!("unknown style {other:?}"))), - }; - let prefix = a.opt_str("prefix")?.unwrap_or_default().to_string(); - let first = a.opt_int("start")?.unwrap_or(1).clamp(1, u32::MAX as i64) as u32; - let mut out = self.apply(&a, Edit::NumberPages { from: from as usize - 1, to: to as usize - 1, style, prefix, first })?; - out["labels"] = json!(self.doc(&a)?.info.pages.iter().map(|p| p.label.clone()).collect::>()); - out - } - "bookmark_list" => self.bookmark_listing(&a)?, - "bookmark_add" => { - let page = self.page(&a)?; - let parent = a.opt_path("parent")?.unwrap_or_default(); - let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); - let title = a.str("title")?.to_string(); - let (left, top) = (a.bookmark_coordinate("left")?, a.bookmark_coordinate("top")?); - let [left, top] = match self.doc(&a)?.info.pages.get(page) { - Some(p) => p.xyz_for_view(left, top), - None => [None, None], - }; - let (left, top) = (left.map(f64::from), top.map(f64::from)); - self.apply(&a, Edit::AddBookmark { parent, index, title, page, left, top })? - } - "bookmark_rename" => { - let (path, title) = (a.path("path")?, a.str("title")?.to_string()); - self.apply(&a, Edit::RenameBookmark { path, title })? - } - "bookmark_delete" => { - let path = a.path("path")?; - self.apply(&a, Edit::DeleteBookmark { path })? - } - "bookmark_move" => { - let from = a.path("path")?; - let to_parent = a.opt_path("parent")?.unwrap_or_default(); - let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); - self.apply(&a, Edit::MoveBookmark { from, to_parent, index })? - } - "bookmark_set_page" => { - let (path, page) = (a.path("path")?, self.page(&a)?); - self.apply(&a, Edit::SetBookmarkPage { path, page })? - } - "bookmark_set_style" => { - let path = a.path("path")?; - // Left out keeps the colour; [0, 0, 0] is the default black. - let color = match a.get("color") { - None => None, - Some(v) => { - let rgb: Option> = v.as_array().and_then(|c| c.iter().map(Value::as_f64).collect()); - match rgb.as_deref() { - Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]), - _ => return Err(ToolError::InvalidArgs("color must be [r, g, b], each from 0 to 1".into())), - } - } - }; - let (bold, italic) = (a.opt_bool("bold")?, a.opt_bool("italic")?); - if color.is_none() && bold.is_none() && italic.is_none() { - return Err(ToolError::InvalidArgs("give color, bold or italic".into())); - } - let mut edits = Vec::new(); - if let Some(color) = color { - edits.push(Edit::SetBookmarkColor { path: path.clone(), color: Some(color) }); - } - if bold.is_some() || italic.is_some() { - edits.push(Edit::SetBookmarkStyle { path, bold, italic }); - } - // A colour and a style given together are one step to undo, applied all or nothing. - match edits.len() { - 1 => self.apply(&a, edits.remove(0))?, - _ => self.apply(&a, Edit::Batch { label: "Style".into(), edits })?, - } - } - "bookmark_from_structure" => { - let mut out = self.apply(&a, Edit::BookmarksFromStructure)?; - let listing = self.bookmark_listing(&a)?; - out["bookmarks"] = listing["bookmarks"].clone(); - out["next"] = listing["next"].clone(); - out - } - "doc_protect" => self.doc_protect(&a)?, - "page_replace" => { - let pages = self.pages(&a, "pages")?; - let path = self.resolve(a.str("path")?, false)?; - let bytes = Arc::new(read_input(&path)?); - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let src_pages = match a.opt_ints("from_pages")? { - Some(p) => one_based(&p)?, - None => (0..pages.len()).collect(), - }; - self.apply(&a, Edit::ReplacePages { pages, name, bytes, src_pages })? - } - "page_duplicate" => { - let pages = self.pages(&a, "pages")?; - let at = self.opt_position(&a, "at")?; - self.apply(&a, Edit::DuplicatePages { pages, at })? - } - "page_set_box" => self.page_set_box(&a)?, - "page_transition_set" => self.page_transition_set(&a)?, - "doc_create" => self.doc_create(&a)?, - "xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?, - "doc_flatten" => { - let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true)); - if !comments && !fields { - return Err(ToolError::InvalidArgs("nothing to flatten".into())); - } - self.apply(&a, Edit::Flatten { comments, fields })? - } - "doc_reduce" => { - let id = self.doc(&a)?.id; - let before = self.doc(&a)?.bytes.len(); - let path = self.resolve(a.str("path")?, true)?; - let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; - // As in the app, a copy that isn't smaller is not written (#490). - let written = bytes.len() < before; - if written { - write_atomic(&path, &bytes)?; - } - json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) - } - "doc_optimize" => self.doc_optimize(&a)?, - "doc_initial_view" => self.doc_initial_view(&a)?, - "doc_revisions" => self.doc_revisions(&a)?, - "text_lines" => { - let page = self.page(&a)?; - let doc = self.doc_to_copy(&a)?; - let info = &doc.info.pages[page]; - let r = |x: f32| (x as f64 * 100.0).round() / 100.0; - let lines: Vec = doc - .text_lines(page) - .iter() - .enumerate() - .map(|(i, l)| { - let (u, v) = (info.user_to_view(l.rect[0] as f32, l.rect[1] as f32), info.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); - json!({ - "line": i + 1, - "text": l.text, - "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], - "font": l.base_font, - "size": (l.size * 100.0).round() / 100.0, - }) - }) - .collect(); - json!({ "page": page + 1, "count": lines.len(), "lines": lines }) - } - "image_hit" => { - let page = self.page(&a)?; - let point = a - .get("point") - .and_then(Value::as_array) - .filter(|p| p.len() == 2) - .and_then(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) - .filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1e9)) - .ok_or_else(|| ToolError::InvalidArgs("point must contain two finite coordinates".into()))?; - let doc = self.doc(&a)?; - let p = &doc.info.pages[page]; - let user = p.view_to_user(point[0] as f32, point[1] as f32).map(f64::from); - let images: Vec<_> = doc.page_images(page).iter().enumerate().filter(|(_, im)| im.contains(user)).map(|(i, _)| i + 1).collect(); - json!({"page":page+1,"images":images}) - } - "page_images" => { - let page = self.page(&a)?; - let doc = self.doc(&a)?; - let info = &doc.info.pages[page]; - let r = |x: f32| (x as f64 * 100.0).round() / 100.0; - let list: Vec = doc - .page_images(page) - .iter() - .enumerate() - .map(|(i, im)| { - let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32)); - let visible = im.visible_rect.map(|rect| { - let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32)); - [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))] - }); - json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) - }) - .collect(); - json!({ "page": page + 1, "count": list.len(), "images": list }) - } - "image_edit" | "image_save" => { - let page = self.page(&a)?; - let n = self.doc(&a)?.page_images(page).len(); - let k = a.int("image")?; - if k < 1 || k as usize > n { - return Err(ToolError::InvalidArgs(format!("image {k} is out of range: page {} has {n} images", page + 1))); - } - let index = k as usize - 1; - if name == "image_save" { - let (ext, bytes) = self.doc(&a)?.page_image_file(page, index).map_err(failed)?; - let mut path = self.resolve(a.str("path")?, true)?; - if path.is_dir() { - // Adding the extension to a folder's name would write beside it: "." is the - // root, so that would land outside it. - return Err(failed(format!("{}: is a folder, not a file", path.display()))); - } - if path.extension().is_none() { - path.set_extension(ext); - } - write_atomic(&path, &bytes)?; - json!({ "path": path.to_string_lossy(), "format": ext, "bytes": bytes.len() }) - } else { - use pdfcraft_engine::ImageEdit; - let change = match a.str("action")? { - "move" => { - let r: Vec = - a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("move needs rect: 4 numbers".into()))?; - // Top-left-origin points → user space. - let info = &self.doc(&a)?.info.pages[page]; - let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); - ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } - "rotate_by" => ImageEdit::RotateBy( - a.get("degrees") - .and_then(Value::as_f64) - .ok_or_else(|| ToolError::InvalidArgs("rotate_by needs degrees: a number (clockwise)".into()))?, - ), - "rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32), - "crop" => { - let r: Vec = - a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - let r = - <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?; - let info = &self.doc(&a)?.info.pages[page]; - let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); - ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } - "flip_horizontal" => ImageEdit::Flip { horizontal: true }, - "flip_vertical" => ImageEdit::Flip { horizontal: false }, - "delete" => ImageEdit::Delete, - "replace" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - ImageEdit::Replace { - name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), - bytes: Arc::new(bytes), - } - } - other => return Err(ToolError::InvalidArgs(format!("unknown action {other:?}"))), - }; - self.apply(&a, Edit::EditPageImage { page, index, change })? - } - } - "text_paragraphs" => { - let page = self.page(&a)?; - let doc = self.doc_to_copy(&a)?; - let info = &doc.info.pages[page]; - let r = |x: f32| (x as f64 * 100.0).round() / 100.0; - let current = doc.text_blocks(page); - let id = doc.id; - let blocks: Vec = current - .iter() - .enumerate() - .map(|(i, b)| { - let (u, v) = (info.user_to_view(b.rect[0] as f32, b.rect[1] as f32), info.user_to_view(b.rect[2] as f32, b.rect[3] as f32)); - json!({ - "paragraph": i + 1, - "text": b.text, - "editor_text": b.editor_text, - "rows": b.rows.iter().map(|row| row.iter().map(|i| i + 1).collect::>()).collect::>(), - "lines": b.lines.iter().map(|l| l + 1).collect::>(), - "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], - "font": b.base_font, - "size": (b.size * 100.0).round() / 100.0, - }) - }) - .collect(); - self.record_listing(id, page, ¤t); - json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks }) - } - "text_font_files" => { - let roots = if let Some(paths) = a.get("directories") { - let paths = paths - .as_array() - .filter(|p| p.len() <= 16) - .ok_or_else(|| ToolError::InvalidArgs("directories must be at most 16 paths".into()))?; - paths - .iter() - .map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false)) - .collect::>>()? - } else if let Some(roots) = &self.roots { - // The installed fonts are outside the roots: look in the read root instead. - let read = roots - .read_root() - .ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?; - vec![self.resolve(&read.to_string_lossy(), false)?] - } else if let Some(root) = &self.root { - vec![root.clone()] - } else { - pdfcraft_engine::text_fonts::installed_directories() - }; - json!({"fonts": pdfcraft_engine::text_fonts::discover(&roots).into_iter().map(|f| json!({"path":f.path,"family":f.family,"font":f.face})).collect::>()}) - } - "text_font_import" => { - let page = self.page(&a)?; - let path = self.resolve(a.str("path")?, false)?; - let file = std::fs::File::open(&path).map_err(failed)?; - use std::io::Read; - let mut bytes = Vec::new(); - file.take((16 << 20) + 1).read_to_end(&mut bytes).map_err(failed)?; - if bytes.len() > 16 << 20 { - return Err(ToolError::InvalidArgs("font input exceeds 16 MiB".into())); - } - let before: Vec<_> = self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| f.resource).collect(); - let mut out = self.apply(&a, Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })?; - let added = self - .doc(&a)? - .text_editor_fonts(page) - .into_iter() - .find(|f| !before.contains(&f.resource) && !f.resource.starts_with("PCApproved")); - if let Some(f) = added { - out["resource"] = json!(f.resource); - out["font"] = json!(f.face); - } - out - } - "text_fonts" => { - let page = self.page(&a)?; - json!({"fonts": self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| json!({"resource":f.resource,"font":f.face,"full_face":f.resource.starts_with("PCFull"),"preview_only":f.resource.starts_with("PCApproved")})).collect::>()}) - } - "text_edit_layout" => { - let page = self.page(&a)?; - let doc = self.doc(&a)?; - let k = a.int("paragraph")?; - let blocks = doc.text_blocks(page); - let index = self.paragraph_index(doc.id, page, k, &blocks)?; - let source = blocks.get(index).ok_or_else(|| ToolError::InvalidArgs("paragraph is out of range".into()))?; - let text = a.opt_str("text")?.unwrap_or(&source.editor_text); - let style = pdfcraft_engine::BlockStyle { - size: a.opt_num("size")?, - width: a.opt_num("width")?, - resource: a.opt_str("font_resource")?.map(str::to_owned), - ranges: parse_style_ranges(a.get("ranges"))?, - ..Default::default() - }; - let layout = doc.text_edit_layout(page, index, text, &style).map_err(failed)?; - let info = &doc.info.pages[page]; - let view_rect = |r: [f64; 4]| { - let a = info.user_to_view(r[0] as f32, r[1] as f32); - let b = info.user_to_view(r[2] as f32, r[3] as f32); - [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] - }; - let fonts: Vec<_> = doc.text_editor_fonts(page).into_iter().map(|f| json!({"resource": f.resource, "face": f.face})).collect(); - json!({ "page": page + 1, "paragraph": k, "source_rect": view_rect(layout.source_rect), - "draft_rect": layout.draft_rect.map(view_rect), "overflow": layout.overflow, "embedded_fonts": fonts }) - } - "text_edit" if a.get("paragraph").is_some() => { - let page = self.page(&a)?; - let k = a.int("paragraph")?; - let (id, blocks) = { - let doc = self.doc(&a)?; - (doc.id, doc.text_blocks(page)) - }; - let index = self.paragraph_index(id, page, k, &blocks)?; - let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?; - let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); - let mut style = pdfcraft_engine::BlockStyle { - ranges: parse_style_ranges(a.get("ranges"))?, - resource: a.opt_str("font_resource")?.map(str::to_owned), - bold: a.opt_bool("bold")?, - size: a.opt_num("size")?, - underline: a.opt_bool("underline")?, - line_spacing: a.opt_num("line_spacing")?, - char_spacing: a.opt_num("char_spacing")?, - scale: a.opt_num("scale")?, - width: a.opt_num("width")?, - ..Default::default() - }; - let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?); - if dx.is_some() || dy.is_some() { - style.offset = Some([dx.unwrap_or(0.0), dy.unwrap_or(0.0)]); - } - if let Some(f) = a.opt_str("font")? { - let family = match f { - "helvetica" => pdfcraft_engine::FontFamily::Helvetica, - "times" => pdfcraft_engine::FontFamily::Times, - "courier" => pdfcraft_engine::FontFamily::Courier, - other => return Err(ToolError::InvalidArgs(format!("unknown font {other:?} (helvetica, times, courier)"))), - }; - style.family = Some((family, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))); - } - if let Some(c) = a.opt_str("color")? { - style.color = Some(comments::parse_color(c)?); - } - if let Some(al) = a.opt_str("align")? { - style.align = Some(match al { - "left" => pdfcraft_engine::TextAlign::Left, - "center" => pdfcraft_engine::TextAlign::Center, - "right" => pdfcraft_engine::TextAlign::Right, - "justify" => pdfcraft_engine::TextAlign::Justify, - other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))), - }); - } - // Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy). - let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); - let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy]; - let wanted: String = text.split_whitespace().collect(); - let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; - let doc = self.doc(&a)?; - let after = doc.text_blocks(page); - // Without `text` the paragraph keeps the document's own words: not echoed when - // the document doesn't allow copying them. - let copy = doc.allows_copying(); - // Only a paragraph that now shows exactly the requested text is the edited one. A - // neighbour that overlaps the old box (overlaid text, a text layer) must not take - // its number, and neither must a paragraph the edit merged it into: rewriting that - // one by this number would drop the neighbour's text. - let edited = block_overlapping(&after, moved) - .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); - if let Some(b) = edited.and_then(|i| after.get(i)) { - out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font }); - if copy { - out["paragraph"]["text"] = json!(b.text); - } - } - let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after); - if !stale.is_empty() { - out["stale_paragraphs"] = json!(stale); - } - out - } - "text_edit" => { - let page = self.page(&a)?; - let n = self.doc(&a)?.text_lines(page).len(); - let line = a.int("line")?; - if line < 1 || line as usize > n { - return Err(ToolError::InvalidArgs(format!("line {line} is out of range: page {} has {n} lines", page + 1))); - } - let text = a.str("text")?.to_owned(); - let mut out = self.apply(&a, Edit::EditTextLine { page, line: line as usize - 1, text })?; - let after = self.doc(&a)?.text_lines(page); - if let Some(l) = after.get(line as usize - 1) { - out["line"] = json!({ "text": l.text, "font": l.base_font }); - } - out - } - "doc_audit_space" => { - let rows: Vec = self - .doc(&a)? - .audit_space() - .iter() - .map(|u| json!({ "category": u.category.label(), "bytes": u.bytes, "percent": (u.percent * 100.0).round() / 100.0 })) - .collect(); - json!({ "categories": rows }) - } - "doc_open_revision" => { - let id = self.doc(&a)?.id; - let n = usize::try_from(a.opt_int("revision")?.ok_or_else(|| ToolError::InvalidArgs("revision is required".into()))?).unwrap_or(0); - let new = self.session.open_revision(id, n).map_err(failed)?; - summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) - } - "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, - "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, - "doc_unprotect" => { - let mut out = self.apply(&a, Edit::RemoveProtection)?; - out["security"] = security(self.doc(&a)?); - out - } - "form_fields" => self.form_fields(&a)?, - "form_fill" => self.form_fill(&a)?, - "form_set_image" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? - } - "form_reset" => self.form_reset(&a)?, - "form_add_field" => self.form_add_field(&a)?, - "form_add_field_table" => self.form_add_field_table(&a)?, - "form_set_props" => self.form_set_props(&a)?, - "form_delete_field" => self.form_delete_field(&a)?, - "form_tab_order" => self.form_tab_order(&a)?, - "form_calculation_order" => self.form_calculation_order(&a)?, - "doc_export_data" => self.doc_export_data(&a)?, - "doc_import_data" => self.doc_import_data(&a)?, - "redact_mark" => self.redact_mark(&a)?, - "redact_apply" => self.redact_apply(&a)?, - "redact_clear" => self.redact_clear(&a)?, - "doc_hidden_info" => self.doc_hidden_info(&a)?, - "printers" => self.printers()?, - "printer_options" => self.printer_options(&a)?, - "link_list" => self.link_list(&a)?, - "link_open" => self.link_open(&a)?, - "link_add" => self.link_add(&a)?, - "link_edit" => self.link_edit(&a)?, - "link_delete" => self.link_delete(&a)?, - "links_from_urls" => self.links_from_urls(&a)?, - "links_remove" => self.links_remove(&a)?, - "object_list" => self.object_list(&a)?, - "object_move" => self.object_move(&a)?, - "content_list" => self.content_list(&a)?, - "page_add_text" => self.page_add_text(&a)?, - "page_add_image" => self.page_add_image(&a)?, - "content_update" => self.content_update(&a)?, - "content_delete" => self.content_delete(&a)?, - "content_copy" => self.content_copy(&a)?, - "doc_print" => self.doc_print(&a)?, - "attachment_add" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - let name = match a.opt_str("name")? { - Some(n) => n.to_string(), - None => path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), - }; - let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); - let description = a.opt_str("description")?.map(str::to_string); - // Only the document-level keys: an annotation attachment of the same name is not the new one. - let before = document_attachment_keys(self.doc(&a)?); - let out = self.apply(&a, Edit::AddAttachment { name, bytes, description, mime })?; - let key = document_attachment_keys(self.doc(&a)?).into_iter().find(|n| !before.contains(n)).unwrap_or_default(); - json!({ "name": key, "document": out }) - } - "attachment_delete" => { - let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; - self.apply(&a, edit)? - } - "doc_remove_hidden" => self.doc_remove_hidden(&a)?, - "doc_audit_redactions" => self.doc_audit_redactions(&a)?, - "fill_sign_add" => self.fill_sign_add(&a)?, - "fill_sign_date_format" => self.fill_sign_date_format(&a)?, - "measure_distance" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Distance)?, - "measure_perimeter" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Perimeter)?, - "measure_area" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Area)?, - "measure_info" => self.measurement_info(&a)?, - "measure_list" => self.measurement_list(&a)?, - "measure_scale" => self.measurement_scale(&a)?, - "measure_snap" => self.measurement_snap(&a)?, - "measure_export" => self.measurement_export(&a)?, - "comment_list" => self.comment_list(&a)?, - "comment_add" => self.comment_add(&a)?, - "stamp_custom" => self.stamp_custom(&a)?, - "comment_reply" => self.comment_reply(&a)?, - "comment_set_status" => self.comment_set_status(&a)?, - "sign_list" => self.sign_list(&a)?, - "accessibility_check" => self.a11y_check(&a)?, - "accessibility_find_untagged" => self.a11y_find_untagged(&a)?, - "accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?, - "accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?, - "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, - "ocr_recognize" => self.ocr_recognize(&a)?, - "js_run" => self.js_run(&a)?, - "js_document_scripts" => self.js_document_scripts(&a)?, - "js_set_document_script" => self.js_set_document_script(&a)?, - "js_enabled" => self.js_enabled(&a)?, - "form_set_script" => self.form_set_script(&a)?, - "form_merge_data" => self.form_merge_data(&a)?, - "form_actions" => self.form_actions(&a)?, - "form_detect_fields" => self.form_detect_fields(&a)?, - "doc_compare" => self.doc_compare(&a)?, - "action_list" => self.action_list()?, - "pdfa_verify" => self.pdfa_verify(&a)?, - "print_readiness" => self.print_readiness(&a)?, - "doc_export_office" => self.doc_export_office(&a)?, - "pdfa_convert" => self.pdfa_convert(&a)?, - "standards_remove_id" => { - let standard = match a.str("standard")? { - "pdfa" => pdfcraft_engine::Standard::PdfA, - "pdfx" => pdfcraft_engine::Standard::PdfX, - other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))), - }; - self.apply(&a, Edit::RemoveStandardId { standard })? - } - "action_run" => self.action_run(&a)?, - "doc_compare_report" => self.doc_compare_report(&a)?, - "doc_compare_mark" => self.doc_compare_mark(&a)?, - "form_set_actions" => self.form_set_actions(&a)?, - "ocr_status" => self.ocr_status()?, - "ocr_recognize_files" => self.ocr_recognize_files(&a)?, - "accessibility_report" => self.a11y_report(&a)?, - "accessibility_review_record" => self.a11y_review_record(&a)?, - "accessibility_review_save" => self.a11y_review_save(&a)?, - "accessibility_review_load" => self.a11y_review_load(&a)?, - "accessibility_review_html" => self.a11y_review_html(&a)?, - "accessibility_fix" => self.a11y_fix(&a)?, - "accessibility_figures" => self.accessibility_figures(&a)?, - "accessibility_set_alt" => self.accessibility_set_alt(&a)?, - "accessibility_tags" => self.accessibility_tags(&a)?, - "accessibility_tag_content" => self.accessibility_tag_content(&a)?, - "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, - "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, - "accessibility_move_tag" => self.accessibility_move_tag(&a)?, - "sign_id_create" => self.sign_id_create(&a)?, - "sign_document" => self.sign_document(&a)?, - "sign_keychain_ids" => { - #[cfg(target_os = "macos")] - let ids: Vec = pdfcraft_engine::sign::keychain::identities(None) - .map_err(failed)? - .iter() - .map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) - .collect(); - #[cfg(not(target_os = "macos"))] - let ids: Vec = Vec::new(); - json!({ "count": ids.len(), "ids": ids }) - } - "sign_windows_ids" => { - #[cfg(target_os = "windows")] - let (ids, unusable): (Vec, Vec) = { - let listing = pdfcraft_engine::sign::windows::list().map_err(failed)?; - let ids = listing - .ids - .iter() - .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) - .collect(); - let unusable = listing - .unusable - .iter() - .map(|u| json!({ "subject": u.subject, "sha256": u.fingerprint, "reason": u.reason, "no_private_key": u.no_private_key })) - .collect(); - (ids, unusable) - }; - #[cfg(not(target_os = "windows"))] - let (ids, unusable): (Vec, Vec) = (Vec::new(), Vec::new()); - json!({ "count": ids.len(), "ids": ids, "unusable": unusable }) - } - "sign_trust" => self.sign_trust(&a)?, - "comment_mark" => self.comment_mark(&a)?, - "comment_lock" => self.comment_lock(&a)?, - "comments_hide" => self.comments_hide(&a)?, - "layer_set" => self.layer_set(&a)?, - "view_reading_colors" => self.view_reading_colors(&a)?, - "comments_summarize" => self.comments_summarize(&a)?, - "comment_edit" => self.comment_edit(&a)?, - "comment_delete" => self.comment_delete(&a)?, - other => return Err(ToolError::UnknownTool(other.into())), - }; - Ok(vec![Content::Json(out)]) - } - - // ---- documents --------------------------------------------------------------------------- - - /// One page of a document's bookmarks: `offset` (from 0) and `limit` (1 to `MAX_BOOKMARK_PAGE`). - fn bookmark_listing(&self, a: &Args) -> Result { - let id = a.int("doc")?; - let id = DocId(u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?); - let offset = a.opt_int("offset")?.unwrap_or(0); - let offset = usize::try_from(offset).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?; - let limit = a.opt_int("limit")?.unwrap_or(BOOKMARK_PAGE as i64); - let limit = usize::try_from(limit) - .ok() - .filter(|l| (1..=MAX_BOOKMARK_PAGE).contains(l)) - .ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_BOOKMARK_PAGE}")))?; - let page = self - .session - .bookmark_page(id, offset, limit) - .ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?; - let pages = &self.doc(a)?.info.pages; - Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated })) - } - - fn doc(&self, a: &Args) -> Result<&Document> { - let id = a.int("doc")?; - let id = u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?; - self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)"))) - } - - /// Most pages whose paragraph listing is remembered. - const MAX_LISTINGS: usize = 256; - - /// Remember the paragraphs `text_paragraphs` just listed for `page`, so their numbers can be - /// resolved against that listing. - fn record_listing(&mut self, doc: DocId, page: usize, blocks: &[TextBlock]) { - if self.listed.len() >= Self::MAX_LISTINGS && !self.listed.contains_key(&(doc, page)) { - self.evicted.extend(self.listed.keys().map(|(d, _)| *d)); - self.listed.clear(); - } - self.listed.insert((doc, page), blocks.iter().map(BlockKey::of).collect()); - } - - /// The index in `blocks` (the page's paragraphs now) of paragraph number `k` (1-based). - /// - /// After `text_paragraphs` a number names the paragraph that listing showed, wherever it is - /// now. Without a listing it is a position in `blocks`. A paragraph an edit merged into a - /// neighbour (or changed) is refused: the number would otherwise rewrite another paragraph - /// and lose its text (#2516). So is any number on a page of a document whose listings were - /// dropped at the cap, until the page is listed again. - fn paragraph_index(&self, doc: DocId, page: usize, k: i64, blocks: &[TextBlock]) -> Result { - let keys = self.listed.get(&(doc, page)); - if keys.is_none() && self.evicted.contains(&doc) { - return Err(ToolError::InvalidArgs(format!( - "page {} has no paragraph listing any more: too many pages were listed and the oldest listings were dropped. List the page again with text_paragraphs", - page + 1 - ))); - } - let n = keys.map_or(blocks.len(), Vec::len); - let i = usize::try_from(k) - .ok() - .and_then(|k| k.checked_sub(1)) - .filter(|i| *i < n) - .ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)))?; - let Some(keys) = keys else { return Ok(i) }; - keys.get(i).and_then(|key| key.find(blocks, i)).ok_or_else(|| { - ToolError::InvalidArgs(format!( - "paragraph {k} of page {} is no longer a paragraph of its own: an edit changed it or how the page is grouped. List the page again with text_paragraphs", - page + 1 - )) - }) - } - - /// Update the listing after paragraph `k` was edited into `edited`, one of the page's - /// paragraphs `after`. Returns the numbers of the listed paragraphs that are gone. - fn follow_edit(&mut self, doc: DocId, page: usize, k: i64, edited: Option<&TextBlock>, after: &[TextBlock]) -> Vec { - let Some(keys) = self.listed.get_mut(&(doc, page)) else { return Vec::new() }; - if let (Some(edited), Some(key)) = (edited, usize::try_from(k).ok().and_then(|k| k.checked_sub(1)).and_then(|i| keys.get_mut(i))) { - *key = BlockKey::of(edited); - } - keys.iter().enumerate().filter(|(i, key)| key.find(after, *i).is_none()).map(|(i, _)| i + 1).collect() - } - - /// The document, if its security allows copying text and images out of it (Table 22, bit 5). - fn doc_to_copy(&self, a: &Args) -> Result<&Document> { - let doc = self.doc(a)?; - doc.check_copying().map_err(failed)?; - Ok(doc) - } - - fn doc_open(&mut self, a: &Args) -> Result { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; - let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; - Ok(summary(doc)) - } - - fn doc_close(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let id = doc.id; - let discard_changes = a.opt_bool("discard_changes")?.unwrap_or(false); - if doc.dirty && !discard_changes { - return Err(failed("the document has unsaved changes: save it with doc_save, or pass discard_changes: true")); - } - self.session.close(id); - self.renderers.remove(&id); - self.texts.remove(&id); - self.listed.retain(|(d, _), _| *d != id); - self.evicted.remove(&id); - Ok(json!({ "closed": id.0 })) - } - - fn doc_save(&mut self, a: &Args) -> Result { - self.doc_save_with(a, write_atomic) - } - - fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result { - let doc = self.doc(a)?; - let id = doc.id; - let target = match a.opt_str("path")? { - Some(p) => self.resolve(p, true)?, - None => { - // Saving in place writes where the file was opened from, which the read root - // allows and the write root may not. - let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?; - let own_path = PathBuf::from(&own); - self.check_roots(&own, &own_path, file_access::Access::Write)?; - own_path - } - }; - let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); - // Saving to a new file is a full rewrite unless asked otherwise, like Save As. - let full = a.opt_bool("full")?.unwrap_or(!same_file); - let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false); - if flatten_fill_sign { - self.apply(a, Edit::FlattenFillSign)?; - } - let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; - // A save that adds or removes encryption, or must not keep the old revision (Sanitize, - // redaction), is rewritten in full even when an incremental save was asked for. - let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); - self.publish_save(id, &target, bytes, incremental, write) - } - - /// Validate the prepared bytes before publishing them or marking the document saved. - fn publish_save( - &mut self, - id: DocId, - target: &Path, - bytes: Arc>, - incremental: bool, - write: impl FnOnce(&Path, &[u8]) -> Result<()>, - ) -> Result { - self.session.check_save(id, &bytes).map_err(failed)?; - write(target, &bytes)?; - let path = target.to_string_lossy().into_owned(); - self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?; - let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?; - Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) })) - } - - fn doc_revisions(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let ends = doc.revision_ends(); - let sigs = doc.signatures.clone(); - let list: Vec = ends - .iter() - .enumerate() - .map(|(i, end)| { - let start = if i == 0 { 0 } else { ends[i - 1] }; - // A signature signs the revision its byte range ends in. - let signed: Vec<&str> = - sigs.iter().filter(|s| s.signed && s.signed_len > start && s.signed_len <= *end).map(|s| s.field.as_str()).collect(); - json!({ "revision": i + 1, "end": end, "bytes": end - start, "signed_by": signed }) - }) - .collect(); - Ok(json!({ "revisions": list, "unsaved_changes": doc.dirty })) - } - - fn doc_initial_view(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; - let bad = |m: String| ToolError::InvalidArgs(m); - let mut v = self.doc(a)?.initial_view(); - let before = v.clone(); - if let Some(n) = a.opt_str("navigation")? { - v.navigation = match n { - "page" => N::PageOnly, - "bookmarks" => N::Bookmarks, - "pages" => N::Pages, - "attachments" => N::Attachments, - "layers" => N::Layers, - o => return Err(bad(format!("unknown navigation {o:?}"))), - }; - } - if let Some(l) = a.opt_str("layout")? { - v.layout = match l { - "default" => L::Default, - "single" => L::SinglePage, - "continuous" => L::SinglePageContinuous, - "two_up" => L::TwoUp, - "two_up_continuous" => L::TwoUpContinuous, - "two_up_cover" => L::TwoUpCoverPage, - "two_up_continuous_cover" => L::TwoUpContinuousCoverPage, - o => return Err(bad(format!("unknown layout {o:?}"))), - }; - } - match a.get("magnification") { - None => {} - Some(Value::Number(p)) => v.magnification = M::Percent(p.as_f64().unwrap_or(100.0).clamp(1.0, 6400.0)), - Some(Value::String(m)) => { - v.magnification = match m.as_str() { - "default" => M::Default, - "actual" => M::ActualSize, - "fit_page" => M::FitPage, - "fit_width" => M::FitWidth, - "fit_height" => M::FitHeight, - "fit_visible" => M::FitVisible, - o => return Err(bad(format!("unknown magnification {o:?}"))), - } - } - Some(_) => return Err(bad("magnification is a name or a percentage".into())), - } - if let Some(p) = a.opt_int("page")? { - v.page = (p.max(1) - 1) as usize; - } - for (k, f) in [ - ("fit_window", &mut v.fit_window), - ("center_window", &mut v.center_window), - ("full_screen", &mut v.full_screen), - ("display_title", &mut v.display_title), - ("hide_menubar", &mut v.hide_menubar), - ("hide_toolbar", &mut v.hide_toolbar), - ("hide_window_ui", &mut v.hide_window_ui), - ] { - if let Some(b) = a.opt_bool(k)? { - *f = b; - } - } - if let Some(l) = a.opt_str("language")? { - v.language = (!l.trim().is_empty()).then(|| l.trim().to_string()); - } - if let Some(b) = a.opt_str("binding")? { - v.right_to_left = match b { - "left" => false, - "right" => true, - o => return Err(bad(format!("binding is left or right, not {o:?}"))), - }; - } - let mut out = if v != before { self.apply(a, Edit::SetInitialView(Box::new(v.clone())))? } else { json!({}) }; - out["initial_view"] = json!({ - "navigation": format!("{:?}", v.navigation), - "layout": format!("{:?}", v.layout), - "magnification": format!("{:?}", v.magnification), - "page": v.page + 1, - "fit_window": v.fit_window, - "center_window": v.center_window, - "full_screen": v.full_screen, - "display_title": v.display_title, - "hide_menubar": v.hide_menubar, - "hide_toolbar": v.hide_toolbar, - "hide_window_ui": v.hide_window_ui, - "language": v.language, - "binding": if v.right_to_left { "right" } else { "left" }, - }); - Ok(out) - } - - fn doc_optimize(&mut self, a: &Args) -> Result { - use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset}; - let id = self.doc(a)?.id; - let before = self.doc(a)?.bytes.len(); - let path = self.resolve(a.str("path")?, true)?; - let mut settings = match a.opt_str("preset")? { - None => Preset::Standard.settings(), - Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(), - }; - if let Some(c) = a.opt_str("compatibility")? { - settings.compatibility = Compatibility::from_name(c) - .ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?; - } - let images = |key: &str, base: ImageSettings| -> Result { - let Some(v) = a.get(key) else { return Ok(base) }; - let mut s = base; - if let Some(d) = v.get("downsample").and_then(Value::as_bool) { - s.downsample = d; - } - if let Some(p) = v.get("ppi").and_then(Value::as_f64) { - s.target_ppi = p.clamp(9.0, 2400.0); - } - if let Some(p) = v.get("above_ppi").and_then(Value::as_f64) { - s.above_ppi = p.clamp(9.0, 2400.0); - } - s.above_ppi = s.above_ppi.max(s.target_ppi); - if let Some(c) = v.get("compression").and_then(Value::as_str) { - let q = v.get("quality").and_then(Value::as_u64).unwrap_or(60).clamp(1, 100) as u8; - s.compression = match c { - "jpeg" => Compression::Jpeg(q), - "zip" | "flate" => Compression::Flate, - "retain" => Compression::Retain, - other => return Err(ToolError::InvalidArgs(format!("unknown compression {other:?} (jpeg, zip, retain)"))), - }; - } - Ok(s) - }; - settings.color = images("color", settings.color)?; - settings.gray = images("gray", settings.gray)?; - for (key, flag) in [ - ("merge_fragments", &mut settings.merge_fragments), - ("discard_thumbnails", &mut settings.discard_thumbnails), - ("discard_alternate_images", &mut settings.discard_alternate_images), - ("discard_tags", &mut settings.discard_tags), - ("discard_print_settings", &mut settings.discard_print_settings), - ("flate_unencoded", &mut settings.flate_unencoded), - ("object_streams", &mut settings.object_streams), - ("lzw_to_flate", &mut settings.lzw_to_flate), - ("remove_invalid_links", &mut settings.remove_invalid_links), - ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), - ] { - if let Some(b) = a.opt_bool(key)? { - *flag = b; - } - } - let discard: Vec = match a.get("discard") { - None => Vec::new(), - Some(v) => v - .as_array() - .ok_or_else(|| ToolError::InvalidArgs("discard must be an array".into()))? - .iter() - .map(|x| { - x.as_str() - .and_then(pdfcraft_engine::Hidden::from_id) - .ok_or_else(|| ToolError::InvalidArgs(format!("unknown discard category {x}"))) - }) - .collect::>()?, - }; - let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; - write_atomic(&path, &bytes)?; - fn valid_version(v: &[u8]) -> Option<&str> { - if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None } - } - let catalog_version = (settings.compatibility == Compatibility::Retain) - .then(|| { - let pw = self.doc(a).ok().and_then(|d| d.password.as_deref()); - let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?; - let root = doc.root()?; - doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned) - }) - .flatten(); - let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version); - // Viewers read the later of the two (object streams raise the header past a lower catalog - // entry). Both are "d.d", so text order is version order. - let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max(); - let o = &r.optimize; - Ok(json!({ - "path": path.to_string_lossy(), - "pdf_version": pdf_version, - "object_streams": settings.effective_object_streams(), - "bytes_before": before, - "bytes_after": bytes.len(), - "merged_images": o.merged_images, - "fragments_merged": o.fragments_merged, - "images": o.images, - "images_resampled": o.images_resampled, - "images_recompressed": o.images_recompressed, - "image_bytes_before": o.image_bytes_before, - "image_bytes_after": o.image_bytes_after, - "thumbnails": o.thumbnails, - "alternate_images": o.alternate_images, - "tags_removed": o.tags_removed, - "streams_compressed": o.streams_compressed, - "lzw_streams": o.lzw_streams, - "invalid_links": o.invalid_links, - "invalid_bookmarks": o.invalid_bookmarks, - "unreferenced_dests": o.unreferenced_dests, - "unused_xobjects": o.unused_xobjects, - "merged_objects": r.merged, - "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), - })) - } - - /// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based). - fn mark_file(&self, a: &Args) -> Result> { - let Some(p) = a.opt_str("file")? else { return Ok(None) }; - let path = self.resolve(p, false)?; - let bytes = read_input(&path)?; - let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1; - Ok(Some(pdfcraft_engine::MarkFile { - name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), - bytes: Arc::new(bytes), - page, - })) - } - - fn apply(&mut self, a: &Args, edit: Edit) -> Result { - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - let doc = self.doc(a)?; - let mut out = summary(doc); - // Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent. - if !doc.edit_notices.is_empty() { - out["warnings"] = json!(doc.edit_notices.iter().map(ToString::to_string).collect::>()); - } - Ok(out) - } - - fn marks(&mut self, tool: &str, a: &Args) -> Result { - use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark}; - let n = self.doc(a)?.info.pages.len(); - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), - }; - let color = - |key: &str, default: [f64; 3]| -> Result<[f64; 3]> { Ok(a.opt_str(key)?.map(comments::parse_color).transpose()?.unwrap_or(default)) }; - let replace = a.opt_bool("replace")?.unwrap_or(false); - let edit = match tool { - "doc_header_footer" => { - let mut hf = HeaderFooter::default(); - for (k, key) in ["header_left", "header_center", "header_right", "footer_left", "footer_center", "footer_right"].iter().enumerate() { - hf.text[k] = a.opt_str(key)?.unwrap_or_default().to_string(); - } - if let Some(s) = a.opt_num("font_size")? { - hf.font_size = s; - } - hf.font = match a.opt_str("font")? { - None | Some("Helvetica") => pdfcraft_engine::FontFamily::Helvetica, - Some("Times") => pdfcraft_engine::FontFamily::Times, - Some("Courier") => pdfcraft_engine::FontFamily::Courier, - Some(other) => { - return Err(ToolError::InvalidArgs(format!("font must be \"Helvetica\", \"Times\" or \"Courier\", not {other:?}"))); - } - }; - hf.color = color("color", hf.color)?; - if let Some(m) = a.get("margins").and_then(Value::as_array) { - let m: Vec = m.iter().filter_map(Value::as_f64).collect(); - hf.margins = <[f64; 4]>::try_from(m).map_err(|_| ToolError::InvalidArgs("margins must be 4 numbers".into()))?; - } - if let Some(s) = a.opt_int("start_number")? { - hf.start_number = s.clamp(1, u32::MAX as i64) as u32; - } - hf.shrink = a.opt_bool("shrink")?.unwrap_or(false); - Edit::AddHeaderFooter { pages, settings: hf, replace } - } - "doc_watermark" => { - let d = Watermark::default(); - let file = self.mark_file(a)?; - let text = match &file { - Some(_) => a.opt_str("text")?.unwrap_or_default().to_string(), - None => a.str("text")?.to_string(), - }; - let wm = Watermark { - text, - source: None, - scale: a.opt_num("scale")?.unwrap_or(d.scale).clamp(0.01, 1.0), - font_size: a.opt_num("font_size")?.unwrap_or(0.0), - color: color("color", d.color)?, - opacity: a.opt_num("opacity")?.unwrap_or(d.opacity), - rotation: a.opt_num("rotation")?.unwrap_or(d.rotation), - behind: a.opt_bool("behind")?.unwrap_or(false), - offset: [ - a.opt_num("offset_x")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), - a.opt_num("offset_y")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), - ], - }; - Edit::AddWatermark { pages, settings: wm, replace, file } - } - "doc_background" => Edit::AddBackground { - pages, - settings: Background { - color: color("color", [1.0; 3])?, - opacity: a.opt_num("opacity")?.unwrap_or(1.0), - scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0), - ..Background::default() - }, - replace, - file: self.mark_file(a)?, - }, - _ => { - let kind = match a.str("kind")? { - "header_footer" => MarkKind::HeaderFooter, - "watermark" => MarkKind::Watermark, - "background" => MarkKind::Background, - other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))), - }; - Edit::RemoveMarks { kind } - } - }; - self.apply(a, edit) - } - - fn export(&mut self, tool: &str, a: &Args) -> Result { - let doc = self.doc_to_copy(a)?; - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); - let mut ex = pdfcraft_engine::export::Exporter::new(doc); - if tool == "doc_export_text" { - let path = self.resolve(a.str("path")?, true)?; - let text = ex.text_of(&pages).map_err(failed)?; - write_atomic(&path, text.as_bytes())?; - return Ok(json!({ "path": path.to_string_lossy(), "pages": pages.len(), "bytes": text.len() })); - } - let folder = self.resolve(a.str("folder")?, true)?; - std::fs::create_dir_all(&folder).map_err(|e| failed(format!("{}: {e}", folder.display())))?; - if tool == "doc_export_all_images" { - let min = u32::try_from(a.opt_int("min_size")?.unwrap_or(0).max(0)).unwrap_or(u32::MAX); - let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?; - let mut files = Vec::new(); - for (k, img) in out.images.iter().enumerate() { - let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len())); - write_atomic(&path, &img.data)?; - files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height })); - } - let skipped: Vec = out.skipped.iter().map(|(p, r, why)| json!({ "page": p + 1, "object": r.num, "reason": why })).collect(); - return Ok(json!({ "count": files.len(), "files": files, "skipped": skipped })); - } - let dpi = a.opt_num("dpi")?.unwrap_or(150.0); - let quality = a.opt_int("quality")?.unwrap_or(85).clamp(1, 100) as u8; - let format = match a.opt_str("format")?.unwrap_or("png") { - "png" => pdfcraft_engine::export::ImageFormat::Png, - "jpeg" | "jpg" => pdfcraft_engine::export::ImageFormat::Jpeg { quality }, - "tiff" | "tif" => pdfcraft_engine::export::ImageFormat::Tiff, - f => return Err(ToolError::InvalidArgs(format!("unknown format {f:?} (png, jpeg, tiff)"))), - }; - let mut files = Vec::new(); - let mut lowered = Vec::new(); - for p in pages { - let img = ex.image(p, dpi, format).map_err(failed)?; - let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension())); - write_atomic(&path, &img)?; - files.push(path.to_string_lossy().into_owned()); - // A page too large for the renderer at `dpi` is drawn at the most it allows. - let used = ex.dpi_used(p, dpi); - if used < dpi.clamp(18.0, 1200.0) - 0.5 { - lowered.push(json!({ "page": p + 1, "dpi": used.floor() })); - } - } - if lowered.is_empty() { - Ok(json!({ "count": files.len(), "files": files })) - } else { - Ok(json!({ "count": files.len(), "files": files, "lower_dpi": lowered })) - } - } - - fn doc_create(&mut self, a: &Args) -> Result { - let (name, bytes) = match a.str("from")? { - "blank" => { - let n = a.opt_int("pages")?.unwrap_or(1).clamp(1, 10_000) as usize; - let (w, h) = (a.opt_num("width")?.unwrap_or(612.0), a.opt_num("height")?.unwrap_or(792.0)); - ("Untitled.pdf".to_string(), self.session.create_blank(w, h, n).map_err(failed)?) - } - "images" => { - let resolution = a.opt_num("dpi")?.map_or(pdfcraft_engine::ImageResolution::Embedded, pdfcraft_engine::ImageResolution::Dpi); - let mut images = Vec::new(); - for p in a.strs("paths")? { - let path = self.resolve(p, false)?; - let bytes = read_input(&path)?; - images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes)); - } - let name = if images.len() == 1 { - format!("{}.pdf", images[0].0.rsplit_once('.').map_or(images[0].0.as_str(), |(s, _)| s)) - } else { - "Images.pdf".into() - }; - (name, self.session.create_from_images_with_resolution(&images, resolution).map_err(failed)?) - } - "text" => { - let (title, text) = match (a.opt_str("text")?, a.opt_str("path")?) { - (Some(t), _) => ("Text".to_string(), t.to_string()), - (None, Some(p)) => { - let path = self.resolve(p, false)?; - let t = read_input(&path)?; - (path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t)) - } - (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), - }; - // The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with - // its line) by `pdfcraft_create::from_text`, as for every other caller. - let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e { - pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m), - e => failed(e), - })?; - (format!("{title}.pdf"), bytes) - } - other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))), - }; - let name = a.opt_str("name")?.map(str::to_owned).unwrap_or(name); - let id = self.session.open_new(name, bytes).map_err(failed)?; - Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?)) - } - - fn page_transition_set(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{Transition, TransitionSpec}; - let n = self.doc(a)?.info.pages.len(); - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), - }; - let transition = match a.opt_str("style")? { - None | Some("none") => None, - Some(style) => { - let mut t = Transition::new(style); - if let Some(d) = a.opt_num("duration")? { - t.duration = d as f32; - } - if let Some(dm) = a.opt_str("dimension")? { - t.horizontal = match dm { - "horizontal" => true, - "vertical" => false, - other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))), - }; - } - if let Some(m) = a.opt_str("motion")? { - t.inward = match m { - "inward" => true, - "outward" => false, - other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))), - }; - } - match a.get("direction") { - None => {} - Some(Value::String(s)) if s == "none" => t.direction = None, - Some(v) => { - t.direction = Some( - v.as_u64() - .and_then(|d| u16::try_from(d).ok()) - .ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?, - ); - } - } - if let Some(s) = a.opt_num("scale")? { - t.scale = s as f32; - } - if let Some(b) = a.opt_bool("opaque")? { - t.opaque = b; - } - Some(t) - } - }; - let display = a.opt_num("display")?.map(|s| s as f32); - self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } }) - } - - fn page_set_box(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{BoxSpec, PageBox}; - let doc = self.doc(a)?; - let n = doc.info.pages.len(); - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), - }; - let which = match a.opt_str("box")? { - None => PageBox::Crop, - Some(b) => PageBox::from_name(b).ok_or_else(|| ToolError::InvalidArgs(format!("unknown box {b:?}")))?, - }; - let nums = |key: &str| -> Result> { - a.get(key) - .map(|v| { - let v: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - <[f64; 4]>::try_from(v).map_err(|_| ToolError::InvalidArgs(format!("{key} must be 4 numbers"))) - }) - .transpose() - }; - let spec = match (nums("margins")?, nums("rect")?) { - (Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass margins or rect, not both".into())), - (Some(m), None) => BoxSpec::Margins(m), - (None, Some(r)) => { - // Top-left-origin points on the displayed page → user space (one page at a time). - if pages.len() != 1 { - return Err(ToolError::InvalidArgs("rect applies to a single page; use margins for several".into())); - } - let p = &doc.info.pages[pages[0]]; - let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); - BoxSpec::Rect([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } - (None, None) => BoxSpec::Remove, - }; - let mut out = self.apply(a, Edit::SetPageBox { pages, which, spec })?; - out["page_sizes"] = json!(self.doc(a)?.info.pages.iter().map(|p| [p.width, p.height]).collect::>()); - Ok(out) - } - - fn doc_protect(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{Algorithm, Changes, Printing, Protection}; - let d = Protection::default(); - // Restrictions only exist behind a permissions password (ISO 32000-2 §7.6.4.4: /P is - // enforced against the owner password; without one everything stays allowed). Refuse a - // restriction that could not take effect instead of writing an unrestricted file (#134). - let restriction = ["printing", "changes", "copy", "accessibility"].into_iter().find(|k| a.get(k).is_some()); - if let (None, Some(key)) = (a.opt_str("permissions_password")?, restriction) { - return Err(ToolError::InvalidArgs(format!( - "`{key}` needs `permissions_password`: with open_password alone the document is encrypted but nothing is restricted" - ))); - } - let p = Protection { - open_password: a.opt_str("open_password")?.map(str::to_owned), - permissions_password: a.opt_str("permissions_password")?.map(str::to_owned), - printing: match a.opt_str("printing")? { - None => d.printing, - Some("none") => Printing::None, - Some("low") => Printing::Low, - Some("high") => Printing::High, - Some(o) => return Err(ToolError::InvalidArgs(format!("unknown printing {o:?}"))), - }, - changes: match a.opt_str("changes")? { - None => d.changes, - Some("none") => Changes::None, - Some("pages") => Changes::Pages, - Some("fill-sign") => Changes::FillSign, - Some("comment-fill-sign") => Changes::CommentFillSign, - Some("any-except-extract") => Changes::AnyExceptExtract, - Some(o) => return Err(ToolError::InvalidArgs(format!("unknown changes {o:?}"))), - }, - copy: a.opt_bool("copy")?.unwrap_or(d.copy), - accessibility: a.opt_bool("accessibility")?.unwrap_or(d.accessibility), - algorithm: match a.opt_str("compatibility")? { - None | Some("aes-256") => Algorithm::Aes256, - Some("aes-128") => Algorithm::Aes128, - Some("rc4-128") => Algorithm::Rc4_128, - Some("rc4-40") => Algorithm::Rc4_40, - Some(o) => return Err(ToolError::InvalidArgs(format!("unknown compatibility {o:?}"))), - }, - encrypt_metadata: a.opt_bool("encrypt_metadata")?.unwrap_or(d.encrypt_metadata), - }; - let mut out = self.apply(a, Edit::Protect(p))?; - out["security"] = security(self.doc(a)?); - Ok(out) - } - - fn insert_blank(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let at = self.position(a, "at")?; - // Default to the size of the neighbouring page, as Acrobat does; Letter for empty files. - let near = doc.info.pages.get(at.saturating_sub(1)).or(doc.info.pages.first()); - let (w, h) = near.map(|p| (f64::from(p.width), f64::from(p.height))).unwrap_or((612.0, 792.0)); - let width = a.opt_num("width")?.unwrap_or(w); - let height = a.opt_num("height")?.unwrap_or(h); - if !(1.0..=14400.0).contains(&width) || !(1.0..=14400.0).contains(&height) { - return Err(ToolError::InvalidArgs("width and height must be between 1 and 14400 points".into())); - } - self.apply(a, Edit::InsertBlankPage { at, width, height }) - } - - fn insert_file(&mut self, a: &Args) -> Result { - let at = self.position(a, "at")?; - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename }) - } - - /// Write freshly created bytes to `out` and/or open them as a new document. - fn deliver(&mut self, a: &Args, name: &str, bytes: Arc>) -> Result { - let mut result = json!({ "bytes": bytes.len() }); - let out = a.opt_str("out")?; - if let Some(out) = out { - let path = self.resolve(out, true)?; - write_atomic(&path, &bytes)?; - result["path"] = json!(path.to_string_lossy()); - } - if a.opt_bool("open")?.unwrap_or(out.is_none()) { - let id = self.session.open_new(name, bytes).map_err(failed)?; - result["document"] = summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?); - } - Ok(result) - } - - fn doc_create_multiple(&mut self, a: &Args) -> Result { - let paths = a.strs("paths")?; - if paths.is_empty() { - return Err(ToolError::InvalidArgs("paths must list at least one file".into())); - } - let separate = match a.opt_str("mode")? { - None | Some("combine") => false, - Some("separate") => true, - Some(m) => return Err(ToolError::InvalidArgs(format!("mode must be \"combine\" or \"separate\", not {m:?}"))), - }; - if separate { - return self.create_separate(a, &paths); - } - let ranges: Vec> = match a.get("pages") { - None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(), - Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), - }; - // Each file is read, converted and copied in turn, so a long list holds one file at a time. - let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result { - let path = self.resolve(p, false)?; - let bytes = read_input(&path)?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?; - let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - Ok((title, pdf, range)) - }); - let bytes = self.session.combine_each(sources)?.ok_or_else(|| ToolError::InvalidArgs("paths must list at least one file".into()))?; - self.deliver(a, "Combined", bytes) - } - - /// One PDF per file, written into `out_dir`; a file that fails doesn't stop the others. - fn create_separate(&mut self, a: &Args, paths: &[&str]) -> Result { - let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("mode \"separate\" needs out_dir".into()))?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut out = Vec::new(); - for &p in paths { - let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| { - let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?; - let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); - let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?; - Ok((kind, stem, pdf)) - }); - out.push(match converted { - Ok((pdfcraft_engine::SourceKind::Pdf, ..)) => json!({ "path": p, "skipped": "already a PDF" }), - Ok((_, stem, pdf)) => { - let target = unused(&dir, &stem); - write_atomic(&target, &pdf)?; - json!({ "path": p, "output": target.to_string_lossy(), "bytes": pdf.len() }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "files": out })) - } - - fn page_extract(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let (id, name) = (doc.id, format!("{} (extract)", doc.name)); - let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "page".into()); - let pages = self.pages(a, "pages")?; - let mut out = if a.opt_bool("separate")?.unwrap_or(false) { - // Each page as its own file. - let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut files = Vec::new(); - for &p in &pages { - let bytes = self.session.extract(id, &[p]).map_err(failed)?; - let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1)); - write_atomic(&path, &bytes)?; - files.push(path.to_string_lossy().into_owned()); - } - json!({ "files": files }) - } else { - let bytes = self.session.extract(id, &pages).map_err(failed)?; - self.deliver(a, &name, bytes)? - }; - if a.opt_bool("delete")?.unwrap_or(false) { - let deleted = self.apply(a, Edit::DeletePages { pages })?; - out["original"] = deleted; - } - Ok(out) - } - - fn doc_combine(&mut self, a: &Args) -> Result { - let paths = a.strs("paths")?; - if paths.len() < 2 { - return Err(ToolError::InvalidArgs("combine needs at least two files".into())); - } - let ranges: Vec> = match a.get("pages") { - None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() => v - .iter() - .enumerate() - .map(|(index, value)| match value { - Value::Null => Ok(None), - Value::String(range) => Ok(Some(range.clone())), - _ => Err(ToolError::InvalidArgs(format!("pages[{index}] must be a range string or null"))), - }) - .collect::>()?, - Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), - }; - let passwords: Vec> = match a.get("passwords") { - None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_string() || x.is_null()) => { - v.iter().map(|x| x.as_str().map(str::to_owned)).collect() - } - Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())), - }; - // Entries sharing a group number are one file split into parts placed apart. - let groups: Option>> = match a.get("groups") { - None | Some(Value::Null) => None, - Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_u64() || x.is_null()) => { - Some(v.iter().map(Value::as_u64).collect()) - } - Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())), - }; - let field_conflicts = match a.opt_str("field_conflicts")? { - None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename, - Some("merge") => pdfcraft_organize::FieldConflicts::Merge, - Some(other) => { - return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}"))); - } - }; - let mut sources: Vec = Vec::new(); - let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new(); - for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() { - let path = self.resolve(p, false)?; - let group = groups.as_ref().and_then(|g| g.get(i).copied().flatten()); - let first = group.and_then(|g| firsts.iter().find(|f| f.0 == g)); - let bytes = match first { - Some((g, first_path, at)) => { - if *first_path != path { - return Err(ToolError::InvalidArgs(format!("groups[{i}]: group {g} is {}, not {}", first_path.display(), path.display()))); - } - sources.get(*at).map(|s| s.1.clone()).ok_or_else(|| failed("group source missing"))? - } - None => { - if let Some(g) = group { - firsts.push((g, path.clone(), sources.len())); - } - let bytes = Arc::new(read_input(&path)?); - // Images and text files are made into PDFs first (#569), as in the Combine files tab. - let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - self.session.as_pdf(&file_name, &bytes).map_err(failed)? - } - }; - let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - sources.push((name, bytes, range)); - } - let passwords: Vec> = passwords.iter().map(Option::as_deref).collect(); - let combined = match groups { - None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts), - Some(groups) => { - // A path without a group is a file of its own: give it a key no group uses. - let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1)); - let mut keys = Vec::with_capacity(groups.len()); - for g in &groups { - keys.push(match g { - Some(g) => *g, - None => { - let own = next.ok_or_else(|| ToolError::InvalidArgs("group numbers are too large".into()))?; - next = own.checked_add(1); - own - } - }); - } - self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts) - } - } - .map_err(failed)?; - let mut result = self.deliver(a, "Combined", combined.bytes)?; - if !combined.renamed_fields.is_empty() { - result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::>()); - } - Ok(result) - } - - fn doc_split(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let id = doc.id; - let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "part".into()); - let bookmarks = a.opt_bool("bookmarks")?.unwrap_or(false); - let max_mb = a.opt_num("max_mb")?; - let chosen = [a.get("every").is_some(), a.get("before").is_some(), bookmarks, max_mb.is_some()].iter().filter(|x| **x).count(); - if chosen != 1 { - return Err(ToolError::InvalidArgs( - "pass exactly one of every (pages per file), before (page numbers), bookmarks: true, or max_mb".into(), - )); - } - let mut titles: Vec<(usize, String)> = Vec::new(); - let parts = if let Some(mb) = max_mb { - if !mb.is_finite() || mb <= 0.0 { - return Err(ToolError::InvalidArgs("max_mb must be positive".into())); - } - self.session.split_by_size(id, (mb * 1_048_576.0) as usize).map_err(failed)? - } else { - let by = match (a.opt_int("every")?, a.opt_ints("before")?) { - (Some(n), None) if n > 0 => pdfcraft_organize::SplitBy::PageCount(n as usize), - (None, Some(b)) => pdfcraft_organize::SplitBy::Before(one_based(&b)?), - _ if bookmarks => { - titles = self.session.bookmark_splits(id); - if titles.is_empty() { - return Err(failed("the document has no top-level bookmarks")); - } - pdfcraft_organize::SplitBy::Before(titles.iter().map(|t| t.0).collect()) - } - _ => return Err(ToolError::InvalidArgs("every must be a positive page count".into())), - }; - self.session.split(id, &by).map_err(failed)? - }; - let dir = self.resolve(a.str("out_dir")?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut files = Vec::new(); - let mut used = std::collections::HashSet::new(); - for (i, (first, last, bytes)) in parts.iter().enumerate() { - let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); - let mut base = match titles.iter().find(|t| t.0 + 1 == *first) { - Some((_, t)) => format!("{stem}-{}", safe(t)), - None => format!("{stem}-part{}", i + 1), - }; - // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix. - if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) { - base.truncate(end); - } - let file = format!("{base}.pdf"); - // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. - let mut unique = file; - let mut n = 1; - while !used.insert(unique.to_lowercase()) { - n += 1; - unique = format!("{base}-{n}.pdf"); - } - let path = child(&dir, &unique); - write_atomic(&path, bytes)?; - files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last })); - } - Ok(json!({ "files": files })) - } - - // ---- pages and text ---------------------------------------------------------------------- - - /// 1-based page list → validated 0-based indices. - fn pages(&self, a: &Args, key: &str) -> Result> { - let n = self.doc(a)?.info.pages.len(); - let pages = one_based(&a.ints(key)?)?; - if pages.is_empty() { - return Err(ToolError::InvalidArgs(format!("{key} must list at least one page"))); - } - if let Some(p) = pages.iter().find(|p| **p >= n) { - return Err(ToolError::InvalidArgs(format!("page {} is out of range: the document has {n} pages", p + 1))); - } - Ok(pages) - } - - /// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based. - fn position(&self, a: &Args, key: &str) -> Result { - self.opt_position(a, key)?.ok_or_else(|| Args::missing(key)) - } - - /// `position`, but `None` when the key is absent. - fn opt_position(&self, a: &Args, key: &str) -> Result> { - let Some(at) = a.opt_int(key)? else { return Ok(None) }; - let n = self.doc(a)?.info.pages.len(); - if at < 1 || at as usize > n + 1 { - return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1))); - } - Ok(Some(at as usize - 1)) - } - - fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { - let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; - // The document's own view settings: layer visibility, hidden comments, reading colours - // (tools that change one drop this cache so the next render reads it again). - let config = - self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }); - let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone())); - let entry = self.renderers.entry(id).or_insert_with(fresh); - if !Arc::ptr_eq(&entry.0, &doc.display) { - *entry = fresh(); - } - Ok(&mut entry.1) - } - - fn page_render(&mut self, a: &Args) -> Result { - let id = self.doc(a)?.id; - let page = self.page(a)?; - let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI); - if !(1.0..=MAX_DPI).contains(&dpi) { - return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}"))); - } - let scale = (dpi / 72.0) as f32; - let block = a.opt_int("hide_text_block")?; - let added = a.opt_int("hide_added_text")?; - if block.is_some() && added.is_some() { - return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into())); - } - let out = if let Some(block) = block { - if block < 1 { - return Err(ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into())); - } - // The number names the paragraph text_paragraphs listed, as it does for text_edit. - let doc = self.doc(a)?; - let index = self.paragraph_index(doc.id, page, block, &doc.text_blocks(page))?; - doc.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? - } else if let Some(added) = added { - let index = usize::try_from(added) - .ok() - .and_then(|n| n.checked_sub(1)) - .ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?; - self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? - } else { - self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }) - }; - if let Some(e) = out.error { - return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); - } - let data = encode_png(out.width, out.height, &out.rgba)?; - Ok(Content::Png { data, width: out.width, height: out.height }) - } - - /// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels - /// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas: - /// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted - /// work on every scroll. It uses the same renderer, and the same per-document cache, as - /// `page_render`. - pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> { - if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) { - return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0))); - } - let id = DocId(doc); - let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len(); - if page < 1 || page > n { - return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages"))); - } - let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }); - if let Some(e) = out.error { - return Err(failed(format!("page {page} could not be rendered: {e}"))); - } - Ok((out.width, out.height, out.rgba)) - } - - fn page(&self, a: &Args) -> Result { - let n = self.doc(a)?.info.pages.len(); - let p = a.int("page")?; - if p < 1 || p as usize > n { - return Err(ToolError::InvalidArgs(format!("page {p} is out of range: the document has {n} pages"))); - } - Ok(p as usize - 1) - } - - /// The text of `pages` (0-based), from the cache or extracted in parallel. - fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result>> { - self.page_text_results(id, pages, &mut extract_parallel)? - .into_iter() - .map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1)))) - .collect() - } - - /// Keep per-page failures until the caller chooses strict or partial-result semantics. - fn page_text_results( - &mut self, - id: DocId, - pages: &[usize], - extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, - ) -> Result { - let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; - let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len()); - let password: Option> = doc.password.as_deref().map(Arc::from); - self.texts.prepare(id, &bytes); - let mut results = HashMap::new(); - let mut missing = Vec::new(); - for &p in pages { - if p >= n { - results.insert(p, Err("page is out of range".to_owned())); - } else if let Some(text) = self.texts.get(id, p) { - results.insert(p, Ok(text)); - } else { - missing.push(p); - } - } - missing.sort_unstable(); - missing.dedup(); - if !missing.is_empty() { - for (p, text) in extract(&bytes, password, &missing) { - if let Ok(text) = &text { - self.texts.insert(id, p, &bytes, text.clone()); - } - results.insert(p, text); - } - } - // The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A - // worker that fails to return a page is an explicit failure, never an empty success. - Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) - } - - fn text_word_count(&self, a: &Args) -> Result { - use pdfcraft_engine::word_count::{self, Counts, Options}; - let doc = self.doc_to_copy(a)?; - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - let options = Options { - exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false), - exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false), - exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false), - }; - let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?; - let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces }); - Ok(json!({ - "total": counts(report.total), - "pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::>(), - "pages_without_text": report.pages_without_text, - "unclassified_structure": report.unclassified_structure, - })) - } - - fn text_extract(&mut self, a: &Args) -> Result { - let doc = self.doc_to_copy(a)?; - let id = doc.id; - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - // Column select (#740): only the text inside the rectangle, row by row. - let rect = a.nums::<4>("rect")?.map(|r| r.map(|v| v as f32)); - let texts = self.page_texts(id, &pages)?; - let text = |t: &PageText| match rect { - Some(r) => t.column_text(&t.glyphs_in(r)), - None => t.plain_text(), - }; - let out: Vec = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect(); - Ok(json!({ "pages": out })) - } - - /// Matches in document order, extracting page text in growing batches and stopping as soon as - /// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document - /// (#971). One match past the limit supplies `next`. Reached extraction failures are reported - /// as bounded warnings; `complete` requires both no continuation and no failed pages. - fn text_find(&mut self, a: &Args) -> Result { - self.text_find_with(a, &mut extract_parallel) - } - - fn text_find_with(&mut self, a: &Args, extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts) -> Result { - let doc = self.doc(a)?; - let (id, n) = (doc.id, doc.info.pages.len()); - let query = a.str("query")?; - let limit = usize::try_from(a.opt_int("limit")?.unwrap_or(500).max(1)).unwrap_or(usize::MAX); - let start = match a.opt_int("start_page")? { - None => 0, - Some(p) if p >= 1 && usize::try_from(p).is_ok_and(|p| p <= n) => usize::try_from(p - 1).unwrap_or(0), - Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))), - }; - let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); - let mut matches = Vec::new(); - let mut next = None; - let mut warnings = Vec::new(); - let mut warning_count = 0usize; - let (mut first, mut batch) = (start, FIND_FIRST_BATCH); - 'search: while first < n { - let end = first.saturating_add(batch).min(n); - let pages: Vec = (first..end).collect(); - let texts = self.page_text_results(id, &pages, extract)?; - for (p, text) in texts { - let text = match text { - Ok(text) => text, - Err(mut error) => { - warning_count += 1; - if warnings.len() < FIND_MAX_WARNINGS { - let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len())); - let truncated = end < error.len(); - error.truncate(end); - warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated })); - } - continue; - } - }; - let skip_here = if p == start { skip } else { 0 }; - // One extra match proves that the caller needs a continuation. Include skipped - // matches on the starting page when bounding the temporary per-page result. - let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); - for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) { - if matches.len() == limit { - next = Some(json!({ "start_page": p + 1, "skip": k })); - break 'search; - } - matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); - } - } - first = end; - batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); - } - let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches, - "warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings }); - if let (Some(next), Some(o)) = (next, out.as_object_mut()) { - o.insert("next".into(), next); - } - Ok(out) - } - - fn command_list(&self, a: &Args) -> Result { - let active = match a.opt_int("doc")? { - Some(_) => Some(self.doc(a)?.id), - None => None, - }; - let filter = a.opt_str("filter")?.unwrap_or("").to_lowercase(); - let enabled_only = a.opt_bool("enabled_only")?.unwrap_or(false); - let list: Vec = commands::COMMANDS - .iter() - .map(|c| { - json!({ - "id": c.id, - "label": commands::current_label(c, &self.session, active), - "menu": c.menu, - "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))), - "enabled": commands::is_enabled(c, &self.session, active), - "tool": tools::tool_for_command(c.id), - "params": tools::tool_for_command(c.id).and_then(tools::find).map(|t| &t.input_schema), - }) - }) - .collect(); - let list: Vec = list - .into_iter() - .filter(|c| { - (!enabled_only || c.get("enabled").and_then(Value::as_bool) == Some(true)) - && (filter.is_empty() - || ["id", "label", "menu"].iter().any(|k| c.get(k).is_some_and(|v| v.to_string().to_lowercase().contains(&filter)))) - }) - .collect(); - Ok(json!({ "commands": list })) - } - - // ---- paths ------------------------------------------------------------------------------- - - /// Resolve a user-supplied path, enforcing the root (if any). `for_write` allows a file that - /// does not exist yet (its nearest existing ancestor must be inside the root). - /// - /// Every path outside the root gets the same refusal, so a confined client can't learn what - /// exists out there (#136): `..` is resolved by name first, another network share or device - /// namespace is refused without touching it, and the deepest existing ancestor (links - /// followed) must be inside the root before anything about the rest is reported. - fn resolve(&self, path: &str, for_write: bool) -> Result { - let access = if for_write { file_access::Access::Write } else { file_access::Access::Read }; - self.resolve_for(path, access, for_write) - } - - /// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that - /// is only read may still be missing (a path checked up front, whose absence is reported per - /// file later). The automation roots, if any, get the last word. - fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result { - let target = self.resolve_in_root(path, may_be_missing)?; - self.check_roots(path, &target, access)?; - Ok(target) - } - - /// Hold `target`, which `path` named, to the automation read or write root. Always passes - /// without roots. - fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> { - match &self.roots { - Some(roots) => roots.check_path(path, target, access).map_err(failed), - None => Ok(()), - } - } - - fn resolve_in_root(&self, path: &str, for_write: bool) -> Result { - let p = Path::new(path); - let Some(root) = &self.root else { return Ok(p.to_path_buf()) }; - let outside = || failed(format!("{path} is outside the allowed directory {}", root.display())); - let joined = lexical(&root.join(p)).ok_or_else(outside)?; - if foreign_share(&joined, root) { - return Err(outside()); - } - let mut existing = joined.as_path(); - let mut rest = Vec::new(); - while !existing.exists() { - if existing.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) { - return Err(outside()); // a broken link: going on would tell whether its target exists - } - match (existing.file_name(), existing.parent()) { - (Some(name), Some(parent)) => { - rest.push(name); - existing = parent; - } - _ => return Err(outside()), // not even a drive or share that exists - } - } - let mut real = existing.canonicalize().map_err(|_| outside())?; - if !real.starts_with(root) { - return Err(outside()); - } - if !rest.is_empty() && !for_write { - // Missing, below a folder inside the root: say where it was looked for, so a path - // written for another working directory can be corrected (#1890), and otherwise - // why, as the system reports it. `joined` is under the root, so naming it reveals - // nothing outside. - real = joined.canonicalize().map_err(|e| { - let (looked, root) = (joined.display(), root.display()); - match e.kind() { - std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")), - _ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")), - } - })?; - if !real.starts_with(root) { - return Err(outside()); // it appeared, as a link out, since the check above - } - return Ok(real); - } - real.extend(rest.iter().rev()); - Ok(real) - } -} - -/// `path` with `.` and `..` resolved by name, before the filesystem is consulted (Windows does -/// the same, and so does joining onto a canonical root there). `None` if a `..` would climb -/// above the start of the path. -fn lexical(path: &Path) -> Option { - use std::path::Component; - let mut parts: Vec = Vec::new(); - for c in path.components() { - match c { - Component::CurDir => {} - Component::ParentDir => match parts.last() { - Some(Component::Normal(_)) => { - parts.pop(); - } - _ => return None, - }, - other => parts.push(other), - } - } - Some(parts.iter().collect()) -} - -// ---- JSON helpers ---------------------------------------------------------------------------- - -fn parse_style_ranges(value: Option<&Value>) -> Result> { - let Some(value) = value else { return Ok(Vec::new()) }; - let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?; - values - .iter() - .map(|v| { - let a = Args(v); - let bounds = a - .get("range") - .and_then(Value::as_array) - .filter(|v| v.len() == 2) - .ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?; - let start = bounds - .first() - .and_then(Value::as_u64) - .and_then(|n| usize::try_from(n).ok()) - .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; - let end = bounds - .get(1) - .and_then(Value::as_u64) - .and_then(|n| usize::try_from(n).ok()) - .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; - let family = a - .opt_str("font")? - .map(|f| match f { - "helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica), - "times" => Ok(pdfcraft_engine::FontFamily::Times), - "courier" => Ok(pdfcraft_engine::FontFamily::Courier), - _ => Err(Args::wrong("font", "helvetica, times or courier")), - }) - .transpose()? - .map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) }) - .transpose()?; - Ok(pdfcraft_engine::RangeStyle { - range: start..end, - family, - resource: a.opt_str("font_resource")?.map(str::to_owned), - size: a.opt_num("size")?, - color: a.opt_str("color")?.map(comments::parse_color).transpose()?, - underline: a.opt_bool("underline")?, - char_spacing: a.opt_num("char_spacing")?, - scale: a.opt_num("scale")?, - }) - }) - .collect() -} - -/// Typed access to validated arguments. -struct Args<'a>(&'a Value); - -impl Args<'_> { - fn get(&self, key: &str) -> Option<&Value> { - self.0.get(key).filter(|v| !v.is_null()) - } - fn missing(key: &str) -> ToolError { - ToolError::InvalidArgs(format!("missing argument {key}")) - } - fn wrong(key: &str, what: &str) -> ToolError { - ToolError::InvalidArgs(format!("{key} must be {what}")) - } - fn str(&self, key: &str) -> Result<&str> { - self.opt_str(key)?.ok_or_else(|| Self::missing(key)) - } - fn opt_str(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_str().ok_or_else(|| Self::wrong(key, "a string"))).transpose() - } - fn int(&self, key: &str) -> Result { - self.opt_int(key)?.ok_or_else(|| Self::missing(key)) - } - fn opt_int(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_i64().ok_or_else(|| Self::wrong(key, "an integer"))).transpose() - } - fn opt_num(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| Self::wrong(key, "a number"))).transpose() - } - fn opt_bool(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_bool().ok_or_else(|| Self::wrong(key, "true or false"))).transpose() - } - fn ints(&self, key: &str) -> Result> { - self.opt_ints(key)?.ok_or_else(|| Self::missing(key)) - } - fn opt_ints(&self, key: &str) -> Result>> { - let Some(v) = self.get(key) else { return Ok(None) }; - let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of integers"))?; - arr.iter().map(|x| x.as_i64().ok_or_else(|| Self::wrong(key, "an array of integers"))).collect::>>().map(Some) - } - /// A 1-based bookmark path → 0-based indices. - fn path(&self, key: &str) -> Result> { - let p = self.opt_path(key)?.ok_or_else(|| Self::missing(key))?; - if p.is_empty() { - return Err(ToolError::InvalidArgs(format!("{key} must not be empty"))); - } - Ok(p) - } - fn opt_path(&self, key: &str) -> Result>> { - let Some(v) = self.opt_ints(key)? else { return Ok(None) }; - v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::>>().map(Some) - } - /// A bookmark's `left` or `top`: points on the displayed page, `None` when left out, an error - /// when it is not a number or lies absurdly far off the page. - fn bookmark_coordinate(&self, key: &str) -> Result> { - match self.opt_num(key)? { - None => Ok(None), - Some(v) if v.is_finite() && v.abs() <= MAX_BOOKMARK_POSITION => Ok(Some(v as f32)), - Some(_) => Err(ToolError::InvalidArgs(format!("{key} must be between -{MAX_BOOKMARK_POSITION} and {MAX_BOOKMARK_POSITION} points"))), - } - } - fn strs(&self, key: &str) -> Result> { - let v = self.get(key).ok_or_else(|| Self::missing(key))?; - let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?; - arr.iter().map(|x| x.as_str().ok_or_else(|| Self::wrong(key, "an array of strings"))).collect() - } -} - -/// The name-tree keys of a document's own attachments, leaving out the files attached to pages as comments. -fn document_attachment_keys(doc: &Document) -> Vec { - doc.info - .attachments - .iter() - .filter_map(|x| match &x.source { - pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), - pdfcraft_render::AttachmentSource::Annotation { .. } => None, - }) - .collect() -} - -/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the -/// next level down in `children`, and paths and pages are 1-based. -fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { - // The open rows, outermost first, with their depth and the children gathered so far. - let mut open: Vec<(usize, Value, Vec)> = Vec::new(); - let mut roots = Vec::new(); - for row in rows { - let depth = row.path.len().saturating_sub(1); - while open.last().is_some_and(|(d, _, _)| *d >= depth) { - close_row(&mut open, &mut roots); - } - let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); - let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); - // Only what differs from the defaults (black, regular), as the PDF itself stores it. - if let Some(color) = row.color { - fields["color"] = json!(color); - } - if row.bold { - fields["bold"] = json!(true); - } - if row.italic { - fields["italic"] = json!(true); - } - if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) { - fields["destination"] = destination; - } - open.push((depth, fields, Vec::new())); - } - while !open.is_empty() { - close_row(&mut open, &mut roots); - } - roots -} - -/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page -/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its -/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the -/// reader's position and zoom). `None` for a destination without a type. -pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option { - use pdfcraft_render::DestView; - let points = |left: Option, top: Option| { - let [x, y] = page.dest_fraction(left, top, 0); - let round = |v: f32| f64::from((v * 100.0).round() / 100.0); - (x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height))) - }; - Some(match view { - DestView::Top => return None, - DestView::Xyz { left, top, zoom } => { - let (x, y) = points(*left, *top); - json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) }) - } - DestView::Fit => json!({ "fit": "Fit" }), - DestView::FitH { top } => { - let (x, y) = points(None, *top); - json!({ "fit": "FitH", "left": x, "top": y }) - } - DestView::FitV { left } => { - let (x, y) = points(*left, None); - json!({ "fit": "FitV", "left": x, "top": y }) - } - DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }), - }) -} - -/// Closes the innermost open row: it joins the row above it as a child, or the top level. -fn close_row(open: &mut Vec<(usize, Value, Vec)>, roots: &mut Vec) { - if let Some((_, mut fields, children)) = open.pop() { - fields["children"] = Value::Array(children); - match open.last_mut() { - Some((_, _, siblings)) => siblings.push(fields), - None => roots.push(fields), - } - } -} - -fn one_based(pages: &[i64]) -> Result> { - pages - .iter() - .map(|p| if *p >= 1 { Ok(*p as usize - 1) } else { Err(ToolError::InvalidArgs(format!("page numbers start at 1 (got {p})"))) }) - .collect() -} - -/// The document's security as the next save writes it (never includes passwords). -fn security(d: &Document) -> Value { - match d.security_summary() { - None => json!({ "protected": false }), - Some(s) => { - let p = s.permissions; - json!({ - "protected": true, - "method": s.method, - "pending": s.pending, - "printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" }, - "modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(), - "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), - }) - } - } -} - -/// What every tool that changes a document returns. -fn summary(d: &Document) -> Value { - json!({ - "doc": d.id.0, - "name": d.name, - "path": d.path, - "pages": d.info.pages.len(), - "dirty": d.dirty, - "editable": d.read_only_reason.is_none(), - "read_only_reason": d.read_only_reason, - "encrypted": d.info.encrypted, - "undo": d.can_undo(), - "redo": d.can_redo(), - }) -} - -fn info(d: &Document) -> Value { - let i = &d.info; - let page1 = |p: usize| p + 1; - let mut security = security(d); - if let Some(s) = d.security_summary() { - security["opened_as_owner"] = json!(s.owner); - } - json!({ - "document": summary(d), - "pdf_version": i.pdf_version, - "file_size": i.file_size, - "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, - // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the - // ones the spec names, set and cleared with doc_set_info. - "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), - "creator": i.creator, "producer": i.producer, - "tagged": i.tagged, - "has_javascript": i.has_javascript, - // Where the document opens (/OpenAction, named destinations resolved): 1-based page and - // destination, null without one. - "open_destination": i.open_dest.as_ref().map(|(p, view)| json!({ "page": page1(*p), "destination": i.pages.get(*p).and_then(|pg| destination(view, pg)) })), - // XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic" - // (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed). - "xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), - "xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })), - // A script of the form ran too long and was abandoned: its scripts no longer run in this - // document (calculations, buttons) until it is opened again. - "xfa_scripts_off": d.xfa_scripts_off(), - // What was rewritten from, or could not be written to, the XFA data. - "xfa_warnings": d.xfa_warnings, - "security": security, - "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ - "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, - })).collect::>(), - "outline": outline(&i.outline), - "outline_more": i.outline_more, - // Rectangles use the tools' convention (top-left of the displayed page, like - // comment_list and link_list), not raw PDF user space, so they can be fed back to - // geometry-taking tools (#129). - "annotations": i.annotations.iter().map(|a| json!({ - "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, - "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, - "rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))), - })).collect::>(), - "fields": i.fields.iter().map(|f| json!({ - "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), - "tooltip": f.tooltip, "has_actions": f.has_actions, - })).collect::>(), - "links": i.links.iter().map(|l| json!({ - "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), - "target": match &l.target { - pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), - pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), - pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ - "layers": changes.iter().map(|(op, ocg)| json!({ - "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), - "state": match op { - pdfcraft_render::LayerOp::On => "on", - pdfcraft_render::LayerOp::Off => "off", - pdfcraft_render::LayerOp::Toggle => "toggle", - }, - })).collect::>(), - "preserve_rb": preserve_rb, - }), - pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ - "action": if *launch { "Launch" } else { "GoToR" }, - "remote_document": file, - "destination": match destination { - pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), - pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), - pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), - }, - "new_window": new_window, - }), - pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), - }, - })).collect::>(), - "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), - // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with - // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. - "transitions": i.transitions.iter().map(|t| json!({ - "page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration, - "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, - "display": t.display, - })).collect::>(), - "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), - // `substitute` names the face standing in for a program the document does not embed, and - // `substitution` says why. Both are null for an embedded font: nothing stands in. A null - // `substitute` with a non-null `substitution` means no face was available at all, so - // nothing is drawn for that font -- which is the answer to "why is my text missing?". - "fonts": i.fonts.iter().map(|f| json!({ - "name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding, - "substitute": f.substitute, "substitution": f.substitution, - })).collect::>(), - "warnings": i.warnings, - "repairs": d.repair_log(), - }) -} - -/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the -/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports -/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page). -/// Public so `pdfcraft-cli` reports the same rectangles as the tools. -pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { - i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) -} - -fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { - Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) -} - -/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same). -fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> { - pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed) -} - -/// Extract the text of `pages` (0-based) with one renderer per worker thread. -fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts { - let extract = |r: &mut PageRenderer, p: usize| { - let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); - match out.error { - Some(e) => Err(e), - None => Ok(out.text.unwrap_or_default()), - } - }; - let config = RenderConfig { password, ..Default::default() }; - let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).min(8) }; - // Small jobs aren't worth a second parse of the document. - if workers == 1 || pages.len() < 8 { - let mut r = PageRenderer::new(bytes.clone(), config); - return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect(); - } - let next = std::sync::atomic::AtomicUsize::new(0); - let mut out: PageTexts = std::thread::scope(|s| { - let handles: Vec<_> = (0..workers) - .map(|_| { - s.spawn(|| { - let mut r = PageRenderer::new(bytes.clone(), config.clone()); - let mut done = Vec::new(); - loop { - let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let Some(p) = pages.get(i) else { break }; - done.push((*p, extract(&mut r, *p))); - } - done - }) - }) - .collect(); - handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect() - }); - out.sort_by_key(|(p, _)| *p); - out -} - -/// The file `name` inside `dir`, where `name` comes from a document or an argument: separators, -/// colons (a Windows drive or stream) and control characters become `_`, and a name of only dots -/// gets a leading `_`, so it is one plain file name and can't lead out of `dir`. -fn child(dir: &Path, name: &str) -> PathBuf { - let mut safe: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect(); - if safe.chars().all(|c| c == '.') { - safe.insert(0, '_'); - } - dir.join(safe) -} - -/// `.pdf` in `dir`, or ` (2).pdf` and so on when that name is taken. -fn unused(dir: &Path, stem: &str) -> PathBuf { - let first = child(dir, &format!("{stem}.pdf")); - if !first.exists() { - return first; - } - (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) -} - -/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`). -pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; - -/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap. -pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result> { - use std::io::Read as _; - let file = std::fs::File::open(path)?; - if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); - } - let mut bytes = Vec::new(); - file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?; - if bytes.len() as u64 > MAX_INPUT { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); - } - Ok(bytes) -} - -pub(crate) fn read_input(path: &Path) -> Result> { - read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display()))) -} - -/// Write and sync a temporary file in the same directory, then rename over the target. -/// This flushes staged contents; it does not promise parent-directory durability after a -/// power loss. Filesystems that refuse sync cause the save to fail before publication. -fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { - write_atomic_with(path, bytes, staging_suffixes()) -} - -/// [`write_atomic`], trying the staging names that `suffixes` give. -fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator) -> Result<()> { - write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all) -} - -fn write_atomic_synced( - path: &Path, - bytes: &[u8], - suffixes: impl IntoIterator, - sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>, -) -> Result<()> { - // A folder can't be replaced, and staging beside it could land outside the root: "." names - // the root itself, whose parent isn't ours to write in. - if path.is_dir() { - return Err(failed(format!("{}: is a folder, not a file", path.display()))); - } - let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); - path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; - std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - // The writer itself is the platform crate's, shared with the desktop app and the CLI - // (#2166): the staging file is written, flushed (delayed writeback failures surface before - // the destination is replaced or a document marked clean), closed and renamed; a failure at - // any step removes it and leaves the destination as it was. - pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync) - .map_err(|e| failed(format!("{}: {e}", path.display()))) } #[cfg(test)] diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs new file mode 100644 index 0000000000..d9ea9e5c4f --- /dev/null +++ b/crates/automation/src/pages.rs @@ -0,0 +1,363 @@ +use super::dispatch::Content; +use super::error::{Result, ToolError, failed}; +use super::json::{Args, one_based}; +use super::*; + +/// Default and maximum resolution for `page_render`. +pub(crate) const DEFAULT_DPI: f64 = 96.0; + +pub(crate) const MAX_DPI: f64 = 600.0; + +/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a +/// match near the start returns without touching the rest of a long document (#971). +pub(crate) const FIND_FIRST_BATCH: usize = 8; + +const FIND_MAX_BATCH: usize = 128; + +type PageTexts = Vec<(usize, std::result::Result, String>)>; + +pub(crate) const FIND_MAX_WARNINGS: usize = 100; + +pub(crate) const FIND_MAX_WARNING_BYTES: usize = 1024; + +impl Automation { + // ---- pages and text ---------------------------------------------------------------------- + + /// 1-based page list → validated 0-based indices. + pub(crate) fn pages(&self, a: &Args, key: &str) -> Result> { + let n = self.doc(a)?.info.pages.len(); + let pages = one_based(&a.ints(key)?)?; + if pages.is_empty() { + return Err(ToolError::InvalidArgs(format!("{key} must list at least one page"))); + } + if let Some(p) = pages.iter().find(|p| **p >= n) { + return Err(ToolError::InvalidArgs(format!("page {} is out of range: the document has {n} pages", p + 1))); + } + Ok(pages) + } + + /// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based. + pub(crate) fn position(&self, a: &Args, key: &str) -> Result { + self.opt_position(a, key)?.ok_or_else(|| Args::missing(key)) + } + + /// `position`, but `None` when the key is absent. + pub(crate) fn opt_position(&self, a: &Args, key: &str) -> Result> { + let Some(at) = a.opt_int(key)? else { return Ok(None) }; + let n = self.doc(a)?.info.pages.len(); + if at < 1 || at as usize > n + 1 { + return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1))); + } + Ok(Some(at as usize - 1)) + } + + pub(crate) fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { + let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; + // The document's own view settings: layer visibility, hidden comments, reading colours + // (tools that change one drop this cache so the next render reads it again). + let config = + self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }); + let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone())); + let entry = self.renderers.entry(id).or_insert_with(fresh); + if !Arc::ptr_eq(&entry.0, &doc.display) { + *entry = fresh(); + } + Ok(&mut entry.1) + } + + pub(crate) fn page_render(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let page = self.page(a)?; + let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI); + if !(1.0..=MAX_DPI).contains(&dpi) { + return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}"))); + } + let scale = (dpi / 72.0) as f32; + let block = a.opt_int("hide_text_block")?; + let added = a.opt_int("hide_added_text")?; + if block.is_some() && added.is_some() { + return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into())); + } + let out = if let Some(block) = block { + if block < 1 { + return Err(ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into())); + } + // The number names the paragraph text_paragraphs listed, as it does for text_edit. + let doc = self.doc(a)?; + let index = self.paragraph_index(doc.id, page, block, &doc.text_blocks(page))?; + doc.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? + } else if let Some(added) = added { + let index = usize::try_from(added) + .ok() + .and_then(|n| n.checked_sub(1)) + .ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?; + self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? + } else { + self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }) + }; + if let Some(e) = out.error { + return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); + } + let data = encode_png(out.width, out.height, &out.rgba)?; + Ok(Content::Png { data, width: out.width, height: out.height }) + } + + /// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels + /// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas: + /// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted + /// work on every scroll. It uses the same renderer, and the same per-document cache, as + /// `page_render`. + pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> { + if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) { + return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0))); + } + let id = DocId(doc); + let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len(); + if page < 1 || page > n { + return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages"))); + } + let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }); + if let Some(e) = out.error { + return Err(failed(format!("page {page} could not be rendered: {e}"))); + } + Ok((out.width, out.height, out.rgba)) + } + + pub(crate) fn page(&self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let p = a.int("page")?; + if p < 1 || p as usize > n { + return Err(ToolError::InvalidArgs(format!("page {p} is out of range: the document has {n} pages"))); + } + Ok(p as usize - 1) + } + + /// The text of `pages` (0-based), from the cache or extracted in parallel. + pub(crate) fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result>> { + self.page_text_results(id, pages, &mut extract_parallel)? + .into_iter() + .map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1)))) + .collect() + } + + /// Keep per-page failures until the caller chooses strict or partial-result semantics. + pub(crate) fn page_text_results( + &mut self, + id: DocId, + pages: &[usize], + extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, + ) -> Result { + let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; + let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len()); + let password: Option> = doc.password.as_deref().map(Arc::from); + self.texts.prepare(id, &bytes); + let mut results = HashMap::new(); + let mut missing = Vec::new(); + for &p in pages { + if p >= n { + results.insert(p, Err("page is out of range".to_owned())); + } else if let Some(text) = self.texts.get(id, p) { + results.insert(p, Ok(text)); + } else { + missing.push(p); + } + } + missing.sort_unstable(); + missing.dedup(); + if !missing.is_empty() { + for (p, text) in extract(&bytes, password, &missing) { + if let Ok(text) = &text { + self.texts.insert(id, p, &bytes, text.clone()); + } + results.insert(p, text); + } + } + // The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A + // worker that fails to return a page is an explicit failure, never an empty success. + Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) + } + + pub(crate) fn text_word_count(&self, a: &Args) -> Result { + use pdfcraft_engine::word_count::{self, Counts, Options}; + let doc = self.doc_to_copy(a)?; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let options = Options { + exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false), + exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false), + exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false), + }; + let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?; + let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces }); + Ok(json!({ + "total": counts(report.total), + "pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::>(), + "pages_without_text": report.pages_without_text, + "unclassified_structure": report.unclassified_structure, + })) + } + + pub(crate) fn text_extract(&mut self, a: &Args) -> Result { + let doc = self.doc_to_copy(a)?; + let id = doc.id; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + // Column select (#740): only the text inside the rectangle, row by row. + let rect = a.nums::<4>("rect")?.map(|r| r.map(|v| v as f32)); + let texts = self.page_texts(id, &pages)?; + let text = |t: &PageText| match rect { + Some(r) => t.column_text(&t.glyphs_in(r)), + None => t.plain_text(), + }; + let out: Vec = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect(); + Ok(json!({ "pages": out })) + } + + /// Matches in document order, extracting page text in growing batches and stopping as soon as + /// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document + /// (#971). One match past the limit supplies `next`. Reached extraction failures are reported + /// as bounded warnings; `complete` requires both no continuation and no failed pages. + pub(crate) fn text_find(&mut self, a: &Args) -> Result { + self.text_find_with(a, &mut extract_parallel) + } + + pub(crate) fn text_find_with( + &mut self, + a: &Args, + extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, + ) -> Result { + let doc = self.doc(a)?; + let (id, n) = (doc.id, doc.info.pages.len()); + let query = a.str("query")?; + let limit = usize::try_from(a.opt_int("limit")?.unwrap_or(500).max(1)).unwrap_or(usize::MAX); + let start = match a.opt_int("start_page")? { + None => 0, + Some(p) if p >= 1 && usize::try_from(p).is_ok_and(|p| p <= n) => usize::try_from(p - 1).unwrap_or(0), + Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))), + }; + let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); + let mut matches = Vec::new(); + let mut next = None; + let mut warnings = Vec::new(); + let mut warning_count = 0usize; + let (mut first, mut batch) = (start, FIND_FIRST_BATCH); + 'search: while first < n { + let end = first.saturating_add(batch).min(n); + let pages: Vec = (first..end).collect(); + let texts = self.page_text_results(id, &pages, extract)?; + for (p, text) in texts { + let text = match text { + Ok(text) => text, + Err(mut error) => { + warning_count += 1; + if warnings.len() < FIND_MAX_WARNINGS { + let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len())); + let truncated = end < error.len(); + error.truncate(end); + warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated })); + } + continue; + } + }; + let skip_here = if p == start { skip } else { 0 }; + // One extra match proves that the caller needs a continuation. Include skipped + // matches on the starting page when bounding the temporary per-page result. + let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); + for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) { + if matches.len() == limit { + next = Some(json!({ "start_page": p + 1, "skip": k })); + break 'search; + } + matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); + } + } + first = end; + batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); + } + let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches, + "warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings }); + if let (Some(next), Some(o)) = (next, out.as_object_mut()) { + o.insert("next".into(), next); + } + Ok(out) + } + + pub(crate) fn command_list(&self, a: &Args) -> Result { + let active = match a.opt_int("doc")? { + Some(_) => Some(self.doc(a)?.id), + None => None, + }; + let filter = a.opt_str("filter")?.unwrap_or("").to_lowercase(); + let enabled_only = a.opt_bool("enabled_only")?.unwrap_or(false); + let list: Vec = commands::COMMANDS + .iter() + .map(|c| { + json!({ + "id": c.id, + "label": commands::current_label(c, &self.session, active), + "menu": c.menu, + "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))), + "enabled": commands::is_enabled(c, &self.session, active), + "tool": tools::tool_for_command(c.id), + "params": tools::tool_for_command(c.id).and_then(tools::find).map(|t| &t.input_schema), + }) + }) + .collect(); + let list: Vec = list + .into_iter() + .filter(|c| { + (!enabled_only || c.get("enabled").and_then(Value::as_bool) == Some(true)) + && (filter.is_empty() + || ["id", "label", "menu"].iter().any(|k| c.get(k).is_some_and(|v| v.to_string().to_lowercase().contains(&filter)))) + }) + .collect(); + Ok(json!({ "commands": list })) + } +} + +/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same). +pub(crate) fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> { + pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed) +} + +/// Extract the text of `pages` (0-based) with one renderer per worker thread. +pub(crate) fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts { + let extract = |r: &mut PageRenderer, p: usize| { + let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); + match out.error { + Some(e) => Err(e), + None => Ok(out.text.unwrap_or_default()), + } + }; + let config = RenderConfig { password, ..Default::default() }; + let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).min(8) }; + // Small jobs aren't worth a second parse of the document. + if workers == 1 || pages.len() < 8 { + let mut r = PageRenderer::new(bytes.clone(), config); + return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect(); + } + let next = std::sync::atomic::AtomicUsize::new(0); + let mut out: PageTexts = std::thread::scope(|s| { + let handles: Vec<_> = (0..workers) + .map(|_| { + s.spawn(|| { + let mut r = PageRenderer::new(bytes.clone(), config.clone()); + let mut done = Vec::new(); + loop { + let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let Some(p) = pages.get(i) else { break }; + done.push((*p, extract(&mut r, *p))); + } + done + }) + }) + .collect(); + handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect() + }); + out.sort_by_key(|(p, _)| *p); + out +} diff --git a/crates/automation/src/paths.rs b/crates/automation/src/paths.rs new file mode 100644 index 0000000000..fb514df69f --- /dev/null +++ b/crates/automation/src/paths.rs @@ -0,0 +1,180 @@ +use super::error::{Result, failed}; +use super::*; + +impl Automation { + // ---- paths ------------------------------------------------------------------------------- + + /// Resolve a user-supplied path, enforcing the root (if any). `for_write` allows a file that + /// does not exist yet (its nearest existing ancestor must be inside the root). + /// + /// Every path outside the root gets the same refusal, so a confined client can't learn what + /// exists out there (#136): `..` is resolved by name first, another network share or device + /// namespace is refused without touching it, and the deepest existing ancestor (links + /// followed) must be inside the root before anything about the rest is reported. + pub(crate) fn resolve(&self, path: &str, for_write: bool) -> Result { + let access = if for_write { file_access::Access::Write } else { file_access::Access::Read }; + self.resolve_for(path, access, for_write) + } + + /// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that + /// is only read may still be missing (a path checked up front, whose absence is reported per + /// file later). The automation roots, if any, get the last word. + pub(crate) fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result { + let target = self.resolve_in_root(path, may_be_missing)?; + self.check_roots(path, &target, access)?; + Ok(target) + } + + /// Hold `target`, which `path` named, to the automation read or write root. Always passes + /// without roots. + pub(crate) fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> { + match &self.roots { + Some(roots) => roots.check_path(path, target, access).map_err(failed), + None => Ok(()), + } + } + + fn resolve_in_root(&self, path: &str, for_write: bool) -> Result { + let p = Path::new(path); + let Some(root) = &self.root else { return Ok(p.to_path_buf()) }; + let outside = || failed(format!("{path} is outside the allowed directory {}", root.display())); + let joined = lexical(&root.join(p)).ok_or_else(outside)?; + if foreign_share(&joined, root) { + return Err(outside()); + } + let mut existing = joined.as_path(); + let mut rest = Vec::new(); + while !existing.exists() { + if existing.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) { + return Err(outside()); // a broken link: going on would tell whether its target exists + } + match (existing.file_name(), existing.parent()) { + (Some(name), Some(parent)) => { + rest.push(name); + existing = parent; + } + _ => return Err(outside()), // not even a drive or share that exists + } + } + let mut real = existing.canonicalize().map_err(|_| outside())?; + if !real.starts_with(root) { + return Err(outside()); + } + if !rest.is_empty() && !for_write { + // Missing, below a folder inside the root: say where it was looked for, so a path + // written for another working directory can be corrected (#1890), and otherwise + // why, as the system reports it. `joined` is under the root, so naming it reveals + // nothing outside. + real = joined.canonicalize().map_err(|e| { + let (looked, root) = (joined.display(), root.display()); + match e.kind() { + std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")), + _ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")), + } + })?; + if !real.starts_with(root) { + return Err(outside()); // it appeared, as a link out, since the check above + } + return Ok(real); + } + real.extend(rest.iter().rev()); + Ok(real) + } +} + +/// `path` with `.` and `..` resolved by name, before the filesystem is consulted (Windows does +/// the same, and so does joining onto a canonical root there). `None` if a `..` would climb +/// above the start of the path. +pub(crate) fn lexical(path: &Path) -> Option { + use std::path::Component; + let mut parts: Vec = Vec::new(); + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir => match parts.last() { + Some(Component::Normal(_)) => { + parts.pop(); + } + _ => return None, + }, + other => parts.push(other), + } + } + Some(parts.iter().collect()) +} + +/// The file `name` inside `dir`, where `name` comes from a document or an argument: separators, +/// colons (a Windows drive or stream) and control characters become `_`, and a name of only dots +/// gets a leading `_`, so it is one plain file name and can't lead out of `dir`. +pub(crate) fn child(dir: &Path, name: &str) -> PathBuf { + let mut safe: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect(); + if safe.chars().all(|c| c == '.') { + safe.insert(0, '_'); + } + dir.join(safe) +} + +/// `.pdf` in `dir`, or ` (2).pdf` and so on when that name is taken. +pub(crate) fn unused(dir: &Path, stem: &str) -> PathBuf { + let first = child(dir, &format!("{stem}.pdf")); + if !first.exists() { + return first; + } + (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) +} + +/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`). +pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; + +/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap. +pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result> { + use std::io::Read as _; + let file = std::fs::File::open(path)?; + if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + let mut bytes = Vec::new(); + file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_INPUT { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + Ok(bytes) +} + +pub(crate) fn read_input(path: &Path) -> Result> { + read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display()))) +} + +/// Write and sync a temporary file in the same directory, then rename over the target. +/// This flushes staged contents; it does not promise parent-directory durability after a +/// power loss. Filesystems that refuse sync cause the save to fail before publication. +pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + write_atomic_with(path, bytes, staging_suffixes()) +} + +/// [`write_atomic`], trying the staging names that `suffixes` give. +pub(crate) fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator) -> Result<()> { + write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all) +} + +pub(crate) fn write_atomic_synced( + path: &Path, + bytes: &[u8], + suffixes: impl IntoIterator, + sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>, +) -> Result<()> { + // A folder can't be replaced, and staging beside it could land outside the root: "." names + // the root itself, whose parent isn't ours to write in. + if path.is_dir() { + return Err(failed(format!("{}: is a folder, not a file", path.display()))); + } + let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; + std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + // The writer itself is the platform crate's, shared with the desktop app and the CLI + // (#2166): the staging file is written, flushed (delayed writeback failures surface before + // the destination is replaced or a document marked clean), closed and renamed; a failure at + // any step removes it and leaves the destination as it was. + pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync) + .map_err(|e| failed(format!("{}: {e}", path.display()))) +} diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 4a5651b54d..fd4efa79da 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -207,8 +207,7 @@ fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() { #[test] fn names_from_documents_become_one_plain_file_name() { let dir = std::env::temp_dir().join("out"); - for name in ["../../x.png", "/abs/x.png", r"..\..\x.png", "C:x.png", r"\\host\share\x.png", "a:stream", "..", ".", "", "tab\there", "ok.png"] - { + for name in ["../../x.png", "/abs/x.png", r"..\..\x.png", "C:x.png", r"\\host\share\x.png", "a:stream", "..", ".", "", "tab\there", "ok.png"] { let p = child(&dir, name); assert_eq!(p.parent(), Some(dir.as_path()), "{name:?} -> {}", p.display()); assert_eq!(p.components().count(), dir.components().count() + 1, "{name:?} -> {}", p.display()); From a891dfebb2bcf0c6abf3b3c68122b7cd104d3116 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:13:58 +0000 Subject: [PATCH 091/246] refactor(automation): split a11y.rs into a11y/ New modules: a11y/ocr, a11y/documents, a11y/form_tools (JS and form-action tools). --- crates/automation/src/a11y.rs | 527 +---------------------- crates/automation/src/a11y/documents.rs | 219 ++++++++++ crates/automation/src/a11y/form_tools.rs | 163 +++++++ crates/automation/src/a11y/ocr.rs | 146 +++++++ 4 files changed, 532 insertions(+), 523 deletions(-) create mode 100644 crates/automation/src/a11y/documents.rs create mode 100644 crates/automation/src/a11y/form_tools.rs create mode 100644 crates/automation/src/a11y/ocr.rs diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 20243dcdae..1f0c9cb8a5 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -8,6 +8,10 @@ use std::collections::BTreeSet; use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic}; +mod documents; +mod form_tools; +mod ocr; + fn status_id(s: Status) -> &'static str { match s { Status::Passed => "passed", @@ -574,511 +578,6 @@ impl Automation { } } -impl Automation { - /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused - /// unless the models read it) and `skip_text_pages`, each left at its default when absent. - pub(crate) fn ocr_settings(&self, a: &Args) -> Result { - let mut settings = pdfcraft_engine::ocr::OcrSettings::default(); - if let Some(d) = a.opt_num("dpi")? { - settings.dpi = d.clamp(72.0, 600.0) as f32; - } - if let Some(l) = a.opt_str("language")? { - if !pdfcraft_engine::ocr::LANGUAGES.iter().any(|x| x.0 == l) { - return Err(ToolError::InvalidArgs(format!("unsupported language {l:?}"))); - } - settings.language = l.into(); - } - if let Some(s) = a.opt_bool("skip_text_pages")? { - settings.skip_text_pages = s; - } - Ok(settings) - } - - /// Recognizes text in each of `paths` and writes the result under the same file name in - /// `folder`, reporting per file its output, word count and skipped pages, or its error. A - /// path outside the root refuses the whole call before any work starts. - pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result { - let settings = self.ocr_settings(a)?; - let folder = self.resolve(a.str("folder")?, true)?; - // A path that leaves the root refuses the whole call before any work starts, like the - // other tools (the write check passes missing files, which stay per-file errors below). - let paths = a.strs("paths")?; - for p in &paths { - self.resolve_for(p, crate::file_access::Access::Read, true)?; - } - // Two inputs that would write the same output file overwrite each other while both report - // success, so the whole batch is refused here: before the folder is created, anything is - // read, or the OCR engines load. - let targets: Vec = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect(); - let shared = shared_file_names(&targets); - if !shared.is_empty() { - return Err(ToolError::InvalidArgs(format!( - "more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.", - shared.join(", ") - ))); - } - std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; - let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?; - let mut out = Vec::new(); - for p in paths { - let name = input_file_name(p); - let result = - self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then( - |bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true), - ); - out.push(match result { - Ok(r) => { - let target = child(&folder, &name); - write_atomic(&target, &r.bytes)?; - let skipped: Vec = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect(); - json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "files": out })) - } - - /// Recognizes text on the open document's `pages` (every page when absent) and adds it, - /// reporting the total word count and, per page, why it was skipped or what it found. - pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { - let settings = self.ocr_settings(a)?; - let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let doc = self.doc(a)?; - // Recognizing needs only permission to change the document; the words it found are - // returned only when copying is allowed too. - let (id, copy) = (doc.id, doc.allows_copying()); - let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; - let list: Vec = found - .iter() - .map(|p| match &p.skipped { - Some(why) => json!({ "page": p.page + 1, "skipped": why }), - None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), - None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }), - }) - .collect(); - Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) - } - - /// Whether text recognition's models are installed, the directories searched for them (in - /// order), and the languages it reads, each as a code and a name. - pub(crate) fn ocr_status(&self) -> Result { - use pdfcraft_engine::ocr; - let dirs: Vec = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect(); - let langs: Vec = ocr::LANGUAGES.iter().map(|(c, n)| json!({ "code": c, "name": n })).collect(); - Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs })) - } -} - -/// The file name an input is written under: its own file name, or `document.pdf` when it has none. -fn input_file_name(path: &str) -> String { - std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()) -} - -/// The output file names that two or more of `targets` share, each spelling listed once, in input -/// order. Names are compared without regard to case on Windows and macOS and exactly elsewhere; on -/// Windows a trailing dot or space is not part of the name (`scan.pdf.` is `scan.pdf`). Unicode -/// normalization forms (NFC and NFD spellings of one name) are not compared, so such inputs are -/// not caught. The folder is the same for every target, so only the names are compared. -fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec { - let mut groups: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); - for target in targets { - let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let key = if cfg!(windows) { - name.trim_end_matches(['.', ' ']).to_lowercase() - } else if cfg!(target_os = "macos") { - // Case only; NFC and NFD spellings of one name stay distinct here (a known limit). - name.to_lowercase() - } else { - name.clone() - }; - let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new())); - *count += 1; - if !spellings.contains(&name) { - spellings.push(name); - } - } - groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect() -} - -/// A script's request to the viewer as the JSON that running a script reports; a side panel -/// goes by its fixed id. -fn request_json(r: &pdfcraft_engine::js::Request) -> Value { - use pdfcraft_engine::js::Request as R; - match r { - R::Reset(n) => json!({ "reset": n }), - R::Print => json!({ "print": true }), - R::GoToPage(p) => json!({ "page": p + 1 }), - R::LaunchUrl(u) => json!({ "url": u }), - R::Submit(u) => json!({ "submit": u }), - R::Focus(f) => json!({ "focus": f }), - R::Beep => json!({ "beep": true }), - R::SaveAs => json!({ "save_as": true }), - R::ShowPanel(p) => json!({ "panel": p.id() }), - } -} - -impl Automation { - pub(crate) fn js_run(&mut self, a: &Args) -> Result { - let id = self.doc(a)?.id; - let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?; - let reqs: Vec = o.requests.iter().map(request_json).collect(); - Ok( - json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }), - ) - } - - pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { - let list: Vec = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect(); - Ok(json!({ "scripts": list })) - } - - pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result { - let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.js_document_scripts(a) - } - - pub(crate) fn form_set_script(&mut self, a: &Args) -> Result { - let edit = pdfcraft_engine::Edit::SetFieldScript { - name: a.str("field")?.into(), - event: a.str("event")?.into(), - script: a.opt_str("script")?.map(str::to_string), - }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - let out = self.session.take_js_output(id); - Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors })) - } - - pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result { - let mut files = Vec::new(); - for p in a.strs("paths")? { - let path = self.resolve(p, false)?; - files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?)); - } - let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?; - let target = self.resolve(a.str("path")?, true)?; - write_atomic(&target, csv.as_bytes())?; - let columns = csv.lines().next().map_or(0, |h| h.split(',').count()); - Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns })) - } - - pub(crate) fn doc_export_office(&self, a: &Args) -> Result { - let path = self.resolve(a.str("path")?, true)?; - let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); - let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) - .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?; - let export = self.doc_to_copy(a)?.export_office_checked(format); - write_atomic(&path, &export.bytes)?; - // Text left out to keep a hostile page bounded is said, not silent (#3120). - let truncated: Vec = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect(); - let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(), - "truncated": !truncated.is_empty(), "truncated_pages": truncated }); - if !export.truncated_pages.is_empty() { - use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; - out["warnings"] = json!([format!( - "forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them" - )]); - } - Ok(out) - } - - fn pdfa_level(&self, a: &Args) -> Result { - let l = a.opt_str("level")?.unwrap_or("2b"); - pdfcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)"))) - } - - pub(crate) fn pdfa_verify(&self, a: &Args) -> Result { - let level = self.pdfa_level(a)?; - let doc = self.doc(a)?; - let issues: Vec = doc - .pdfa_verify(level) - .iter() - .map(|i| json!({ "clause": i.clause, "message": i.message, "page": i.page.map(|p| p + 1), "fixable": i.fixable })) - .collect(); - let d = doc.standards(); - Ok(json!({ - "level": level.label(), - "compliant": issues.is_empty(), - "issues": issues, - "declared": { "pdfa": d.pdfa.map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())), "pdfua": d.pdfua, "output_intents": d.output_intents }, - })) - } - - pub(crate) fn print_readiness(&self, a: &Args) -> Result { - let report = self.doc(a)?.print_readiness(); - let findings: Vec = - report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect(); - let pages: Vec = report - .pages - .iter() - .map(|p| { - json!({ - "page": p.page, - "media_mm": p.media_mm, - "crop_mm": p.crop_mm, - "trim_mm": p.trim_mm, - "bleed_mm": p.bleed_mm, - "trim_set": p.trim_set, - "bleed_set": p.bleed_set, - "bleed_margin_mm": p.bleed_margin_mm, - }) - }) - .collect(); - let images: Vec = report - .images - .iter() - .map(|image| { - json!({ - "page": image.page, - "name": image.name, - "ppi": image.ppi, - "pixels": image.pixels, - "size_mm": image.size_mm, - }) - }) - .collect(); - let pdfa: Vec = report - .pdfa_issues - .iter() - .map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) })) - .collect(); - Ok(json!({ - "text": report.text(), - "findings": findings, - "pages": pages, - "images": images, - "output_intents": report.output_intents, - "fonts_not_embedded": report.fonts_not_embedded, - "pdfa_issues": pdfa, - "resolution_unknown": report.resolution_unknown, - })) - } - - pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result { - let level = self.pdfa_level(a)?; - let id = self.doc(a)?.id; - self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?; - self.pdfa_verify(a) - } - - pub(crate) fn action_list(&self) -> Result { - use pdfcraft_engine::actions::{Step, builtin}; - let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() }); - let actions: Vec = builtin() - .iter() - .map(|a| json!({ "name": a.name, "description": a.description, "steps": a.steps.iter().map(step_json).collect::>() })) - .collect(); - let steps: Vec = Step::all().iter().map(|s| json!({ "step": s.id(), "label": s.label(), "takes_arg": s.arg().is_some() })).collect(); - Ok(json!({ "actions": actions, "steps": steps })) - } - - pub(crate) fn action_run(&mut self, a: &Args) -> Result { - use pdfcraft_engine::actions::{Action, Step, builtin, run_on}; - let action = if let Some(name) = a.opt_str("action")? { - builtin() - .into_iter() - .find(|x| x.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| ToolError::InvalidArgs(format!("no built-in action {name:?}")))? - } else { - let items = a.get("steps").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("give action or steps".into()))?; - let steps = items - .iter() - .map(|it| { - let id = it["step"].as_str().unwrap_or(""); - Step::from_id(id, it["arg"].as_str().unwrap_or("")).ok_or_else(|| ToolError::InvalidArgs(format!("unknown step {id:?}"))) - }) - .collect::>>()?; - Action { name: "Custom".into(), description: String::new(), steps, builtin: false } - }; - let folder = self.resolve(a.str("folder")?, true)?; - std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; - let mut out = Vec::new(); - for p in a.strs("paths")? { - let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); - let result = self - .resolve(p, false) - .map_err(|e| e.to_string()) - .and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())) - .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); - out.push(match result { - Ok(r) => { - let target = child(&folder, &name); - let target = if target.exists() { - let stem = - std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); - unused(&folder, &stem) - } else { - target - }; - if target.exists() { - return Err(failed(format!("{}: no unused output name available", target.display()))); - } - write_atomic(&target, &r.bytes)?; - json!({ "path": p, "output": target.to_string_lossy(), "log": r.log }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "action": action.name, "files": out })) - } - - fn compare_ids(&self, a: &Args) -> Result<(pdfcraft_engine::DocId, pdfcraft_engine::DocId)> { - let new = self.doc(a)?.id; - let other = a.int("other")?; - let old = u64::try_from(other) - .ok() - .and_then(|o| self.session.get(pdfcraft_engine::DocId(o))) - .map(|d| d.id) - .ok_or_else(|| ToolError::InvalidArgs(format!("no open document {other}")))?; - Ok((old, new)) - } - - pub(crate) fn doc_compare(&self, a: &Args) -> Result { - use pdfcraft_engine::compare::Kind; - let (old, new) = self.compare_ids(a)?; - let c = self.session.compare(old, new).map_err(failed)?; - let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize; - let r2 = |r: &[f64; 4]| r.map(|v| (v * 100.0).round() / 100.0); - let side = - |s: &pdfcraft_engine::compare::Side| json!({ "text": s.text, "page": s.page + 1, "rects": s.rects.iter().map(r2).collect::>() }); - let list: Vec = c - .changes - .iter() - .take(limit) - .map(|ch| json!({ "kind": ch.kind.label().to_lowercase(), "old": side(&ch.old), "new": side(&ch.new) })) - .collect(); - let visual: Vec = if a.opt_bool("visual")?.unwrap_or(false) { - self.session.compare_visual(old, new, 72.0).map_err(failed)?.iter().map(|(p, r)| json!({ "page": p + 1, "rect": r2(r) })).collect() - } else { - Vec::new() - }; - Ok(json!({ - "visual": visual, - "identical": c.identical(), - "replaced": c.count(Kind::Replaced), - "inserted": c.count(Kind::Inserted), - "deleted": c.count(Kind::Deleted), - "old_words": c.old_words, - "new_words": c.new_words, - "changes": list, - })) - } - - pub(crate) fn doc_compare_report(&self, a: &Args) -> Result { - let (old, new) = self.compare_ids(a)?; - let bytes = self.session.compare_report(old, new).map_err(failed)?; - let path = self.resolve(a.str("path")?, true)?; - write_atomic(&path, &bytes)?; - Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len() })) - } - - pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result { - let (old, new) = self.compare_ids(a)?; - let n = self.session.mark_differences(old, new).map_err(failed)?; - Ok(json!({ "comments": n })) - } - - pub(crate) fn form_detect_fields(&mut self, a: &Args) -> Result { - let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let id = self.doc(a)?.id; - let found = self.session.detect_fields(id, &pages); - let list: Vec = found - .iter() - .map(|(p, c)| { - let (kind, comb) = match c.kind { - pdfcraft_engine::detect::Kind::Text => ("text", None), - pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None), - pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)), - }; - let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }); - if let Some(cells) = comb { - v["comb"] = json!(cells); - } - v - }) - .collect(); - if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() { - self.session.auto_detect_fields(id, &pages).map_err(failed)?; - } - Ok(json!({ "fields": list })) - } - - pub(crate) fn form_actions(&self, a: &Args) -> Result { - use pdfcraft_engine::FieldAction as A; - let list: Vec = self - .doc(a)? - .field_actions(a.str("field")?) - .iter() - .map(|(t, act)| { - let mut v = match act { - A::JavaScript(j) => json!({ "javascript": j }), - A::Uri(u) => json!({ "url": u }), - A::Reset(f) => json!({ "reset": f }), - A::Named(n) => json!({ "menu": n }), - A::GoTo(p) => json!({ "page": p + 1 }), - A::ShowHide { fields, hide: true } => json!({ "hide": fields }), - A::ShowHide { fields, hide: false } => json!({ "show": fields }), - A::Submit(u) => json!({ "submit": u }), - A::Other(s) => json!({ "other": s }), - }; - v["trigger"] = json!(t.id()); - v - }) - .collect(); - Ok(json!({ "field": a.str("field")?, "actions": list })) - } - - pub(crate) fn form_set_actions(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{FieldAction as A, FieldTrigger as T}; - let items = a.get("actions").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("actions must be an array".into()))?; - let bad = |m: String| ToolError::InvalidArgs(m); - let names = |v: &Value| -> Vec { - v.as_array().map(|x| x.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default() - }; - let mut acts = Vec::new(); - for it in items { - let tid = it["trigger"].as_str().unwrap_or(""); - let t = T::from_id(tid).ok_or_else(|| bad(format!("unknown trigger {tid:?}")))?; - let act = if let Some(j) = it["javascript"].as_str() { - A::JavaScript(j.into()) - } else if let Some(u) = it["url"].as_str() { - A::Uri(u.into()) - } else if it.get("reset").is_some() { - A::Reset(names(&it["reset"])) - } else if let Some(m) = it["menu"].as_str() { - A::Named(m.into()) - } else if let Some(p) = it["page"].as_u64() { - A::GoTo((p.max(1) - 1) as usize) - } else if it.get("show").is_some() { - A::ShowHide { fields: names(&it["show"]), hide: false } - } else if it.get("hide").is_some() { - A::ShowHide { fields: names(&it["hide"]), hide: true } - } else if let Some(u) = it["submit"].as_str() { - A::Submit(u.into()) - } else { - return Err(bad(format!("{tid}: give one action (javascript, url, reset, menu, page, show, hide or submit)"))); - }; - acts.push((t, act)); - } - let name = a.str("field")?.to_string(); - let edit = - pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.form_actions(a) - } - - pub(crate) fn js_enabled(&mut self, a: &Args) -> Result { - if let Some(on) = a.opt_bool("enabled")? { - self.session.set_javascript(on); - } - Ok(json!({ "enabled": self.session.javascript() })) - } -} - #[cfg(test)] mod review_html_tests { use super::*; @@ -1098,21 +597,3 @@ mod review_html_tests { assert!(!html.contains("