diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 757e1f49..53c16467 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -260,6 +260,8 @@ impl Automation { "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)?, + "doc_remove_hidden" => self.doc_remove_hidden(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, "comment_list" => self.comment_list(&a)?, "comment_add" => self.comment_add(&a)?, diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index a22c4671..9c7f137e 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -1,7 +1,7 @@ //! Redaction tools: mark areas, text, patterns or whole pages for redaction, apply the marks //! (removing what they cover for good) or clear them. -use printcraft_engine::{Edit, NewAnnotation, RedactPattern, Shape, Style, find_pattern, rect_quad}; +use printcraft_engine::{Edit, Hidden, NewAnnotation, RedactPattern, Shape, Style, find_pattern, rect_quad}; use serde_json::{Value, json}; use crate::comments::{DEFAULT_AUTHOR, parse_color}; @@ -103,4 +103,33 @@ impl Automation { } self.apply(a, Edit::ClearRedactions) } + + pub(crate) fn doc_hidden_info(&self, a: &Args) -> Result { + let doc = self.doc(a)?; + let items: Vec = doc.hidden_info().into_iter().map(|(h, n)| json!({ "category": h.id(), "label": h.label(), "count": n })).collect(); + let total: usize = doc.hidden_info().iter().map(|c| c.1).sum(); + Ok(json!({ "total": total, "categories": items })) + } + + pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result { + let edit = match a.get("categories") { + None => Edit::Sanitize, + Some(_) => { + let which = a + .strs("categories")? + .into_iter() + .map(|c| Hidden::from_id(c).ok_or_else(|| ToolError::InvalidArgs(format!("unknown category {c:?} (see doc_hidden_info)")))) + .collect::>>()?; + if which.is_empty() { + return Err(ToolError::InvalidArgs("categories is empty".into())); + } + Edit::RemoveHidden { which } + } + }; + let before: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum(); + let mut out = self.apply(a, edit)?; + let after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum(); + out["removed"] = json!(before.saturating_sub(after)); + Ok(out) + } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index bbfc2491..f0b71c39 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -287,6 +287,26 @@ pub fn tools() -> Vec { t("redact_clear", "Remove redaction marks", "Remove every redaction mark without applying it. Undoable.") .destructive() .with(schema(json!({ "doc": doc() }), &["doc"])), + t( + "doc_hidden_info", + "Find hidden information", + "What Remove Hidden Information would remove, by category (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data) with counts.", + ) + .ro() + .with(schema(json!({ "doc": doc() }), &["doc"])), + t( + "doc_remove_hidden", + "Remove hidden information / Sanitize", + "Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.", + ) + .destructive() + .with(schema( + json!({ + "doc": doc(), + "categories": { "type": "array", "items": { "type": "string", "enum": ["metadata", "attachments", "comments", "form-fields", "hidden-text", "hidden-layers", "bookmarks", "links-actions-scripts", "private-data"] } }, + }), + &["doc"], + )), t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 05182ff6..561037fb 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -640,3 +640,25 @@ fn redacting_through_tools() { assert!(matches!(a.call("redact_mark", &json!({ "doc": doc, "find": "nowhere to be found" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); } + +#[test] +fn removing_hidden_information_through_tools() { + let dir = workdir("hidden"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "internal note" })); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Secret plan" })); + let info = ok(&mut a, "doc_hidden_info", json!({ "doc": doc })); + let count = |v: &Value, c: &str| v["categories"].as_array().unwrap().iter().find(|x| x["category"] == c).unwrap()["count"].as_u64().unwrap(); + assert!(count(&info, "comments") >= 1 && count(&info, "metadata") >= 1, "{info}"); + let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc, "categories": ["comments"] })); + assert_eq!(r["undo"], "Remove hidden information"); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0); + assert!(count(&ok(&mut a, "doc_hidden_info", json!({ "doc": doc })), "metadata") >= 1, "only comments went"); + let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc })); + assert_eq!(r["undo"], "Sanitize document"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "clean.pdf" })); + let bytes = std::fs::read(dir.join("clean.pdf")).unwrap(); + assert!(!bytes.windows(11).any(|w| w == b"Secret plan"), "the old revision is gone"); + assert!(matches!(a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["nonsense"] })), Err(ToolError::InvalidArgs(_)))); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index f661bd8b..2f832433 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -259,7 +259,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ title: "Protect", items: &[ item("Protect with password", "lock", "protect.password", Ready), - item("Remove hidden information", "eye-off", "protect.sanitize", Planned("M8")), + item("Remove hidden information", "eye-off", "protect.remove_hidden", Ready), ], }, ToolSection { @@ -286,7 +286,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Redact pages", "file-x", "redact.pages", Ready), item("Find text and redact", "file-search", "redact.search", Ready), item("Set properties", "settings-2", "redact.properties", Ready), - item("Sanitize document", "sparkles", "redact.sanitize", Planned("M8")), + item("Sanitize document", "sparkles", "redact.sanitize", Ready), ], }], }, diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 87e13fa7..26b67e56 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -183,6 +183,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("redact.properties", "Redaction properties…", None, None, Document, "settings-2"), c("redact.apply", "Apply redactions…", None, None, HasRedactions, "check"), c("redact.clear", "Clear redaction marks", None, None, HasRedactions, "eraser"), + c("protect.remove_hidden", "Remove hidden information…", None, None, Modification, "eye-off"), + c("redact.sanitize", "Sanitize document…", None, None, Modification, "sparkles"), c("form.field.properties", "Field properties…", None, None, HasFields, "settings-2"), c("form.add.text", "Add a text field", None, None, Modification, "text-cursor-input"), c("form.add.checkbox", "Add a checkbox", None, None, Modification, "check-circle-2"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f91fe19d..6da3716d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -30,6 +30,7 @@ pub use printcraft_forms::{ pub use printcraft_annot::appearance as annot_text; pub use printcraft_annot::{FillMark, Markup, NewAnnotation, NoteIcon, Props as CommentProps, ReviewState, Rgb, Shape, Style, rect_quad}; pub use printcraft_redact::patterns::{PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern}; +pub use printcraft_redact::sanitize::{HIDDEN, Hidden}; pub type SplitPart = (usize, usize, Arc>); @@ -197,6 +198,11 @@ impl Document { self.editor.as_ref().and_then(|e| printcraft_annot::props(&e.cos, page, index)) } + /// Remove Hidden Information: what each category would remove. + pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { + self.editor.as_ref().map(|e| printcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() + } + /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { self.editor.as_ref().and_then(|e| printcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() @@ -549,6 +555,12 @@ pub enum Edit { }, /// Remove redaction marks without applying them. ClearRedactions, + /// Remove Hidden Information: the chosen categories. + RemoveHidden { + which: Vec, + }, + /// Sanitize Document: every category, then a full rewrite on save. + Sanitize, /// Flatten comments and/or form fields on every page into page content. Flatten { comments: bool, @@ -609,6 +621,8 @@ impl Edit { Edit::RemoveMarks { kind: MarkKind::Background } => "Remove background".into(), Edit::ApplyRedactions { .. } => "Apply redactions".into(), Edit::ClearRedactions => "Remove redaction marks".into(), + Edit::RemoveHidden { .. } => "Remove hidden information".into(), + Edit::Sanitize => "Sanitize document".into(), Edit::Flatten { comments: true, fields: false } => "Flatten comments".into(), Edit::Flatten { comments: false, fields: true } => "Flatten form fields".into(), Edit::Flatten { .. } => "Flatten".into(), @@ -711,6 +725,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::AddField { .. } | Edit::ApplyRedactions { .. } | Edit::ClearRedactions + | Edit::RemoveHidden { .. } + | Edit::Sanitize | Edit::SetFieldProps { .. } | Edit::DeleteField { .. } | Edit::Flatten { .. } => { @@ -866,6 +882,12 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::ClearRedactions => { printcraft_redact::clear_marks(doc, None)?; } + Edit::RemoveHidden { which } => { + printcraft_redact::sanitize::remove_hidden(doc, which)?; + } + Edit::Sanitize => { + printcraft_redact::sanitize::sanitize(doc)?; + } Edit::Flatten { comments, fields } => { let n = printcraft_model::pages(doc).len(); printcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; diff --git a/crates/redact/README.md b/crates/redact/README.md index 13803fb4..e32cbb24 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -33,5 +33,21 @@ A verification pass re-reads each redacted page and fails the operation if any g image is still under an area. Content that can't be decoded makes the operation fail rather than leave it unredacted. -Not yet: search-and-redact patterns, struct-tree/alt-text cleanup, metadata and hidden-information +## Remove hidden information / Sanitize + +```rust +let found = sanitize::scan(&doc); // [(Hidden, count)] per category +sanitize::remove_hidden(&mut doc, &[Hidden::Comments])?; // chosen categories +sanitize::sanitize(&mut doc)?; // all of them +``` + +Categories: metadata (`/Info`, XMP), file attachments, comments, form fields (flattened so the +values stay visible), hidden text (render modes 3 and 7, or wholly off the page), hidden layers +(content of off optional-content groups, then the groups), bookmarks, links/actions/JavaScript, +and `/PieceInfo` private data. Both make the next save a full rewrite. + +`patterns` finds phone numbers, e-mail addresses, credit card numbers (Luhn-checked), US Social +Security numbers and dates for Search & Redact. + +Not yet: pattern locales, struct-tree/alt-text cleanup, metadata and hidden-information sanitising (M8.4), re-encoding of DCT images (they are removed instead). diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 93c7821b..81b50c14 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -41,6 +41,7 @@ struct Gs { size: f64, char_spacing: f64, word_spacing: f64, + render_mode: i64, scale: f64, leading: f64, rise: f64, @@ -61,6 +62,13 @@ pub(crate) struct Scope<'a> { pub rects: &'a [[f64; 4]], pub mode: Mode, pub report: &'a mut Report, + /// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box). + pub hidden_text: Option<[f64; 4]>, + /// Sanitize: optional content groups (and membership dictionaries) that are off; content + /// marked with them is removed. + pub hidden_layers: Vec, + /// Content removed from hidden layers (blocks and XObjects). + pub layer_blocks: usize, fonts: HashMap, Rc>, used_names: Vec>, depth: usize, @@ -68,7 +76,46 @@ pub(crate) struct Scope<'a> { impl<'a> Scope<'a> { pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report) -> Self { - Scope { rects, mode, report, fonts: HashMap::new(), used_names: Vec::new(), depth: 0 } + Scope { + rects, + mode, + report, + hidden_text: None, + hidden_layers: Vec::new(), + layer_blocks: 0, + fonts: HashMap::new(), + used_names: Vec::new(), + depth: 0, + } + } + + /// Sanitizing visits every form XObject (not only those under a region). + fn everywhere(&self) -> bool { + self.hidden_text.is_some() || !self.hidden_layers.is_empty() + } + + /// Is this optional-content reference (an OCG or OCMD) hidden? + fn layer_hidden(&self, doc: &Document, o: &Object) -> bool { + if self.hidden_layers.is_empty() { + return false; + } + if let Some(r) = o.as_ref() + && self.hidden_layers.contains(&r) + { + return true; + } + // A membership dictionary (default policy AnyOn): hidden when all its groups are. + let d = doc.resolve(o); + let Some(d) = d.as_dict() else { return false }; + if d.name(b"Type") != Some(b"OCMD") { + return false; + } + let groups: Vec = match d.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) { + Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(), + Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(), + None => Vec::new(), + }; + !groups.is_empty() && groups.iter().all(|g| self.hidden_layers.contains(g)) } fn hits(&self, b: [f64; 4]) -> bool { @@ -148,7 +195,8 @@ struct Glyph { pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, ctm: Matrix) -> Output { let mut out = Output::default(); let fallback = Rc::new(Metrics::fallback()); - let mut gs = Gs { ctm, font: fallback.clone(), size: 0.0, char_spacing: 0.0, word_spacing: 0.0, scale: 1.0, leading: 0.0, rise: 0.0 }; + let mut gs = + Gs { ctm, font: fallback.clone(), size: 0.0, char_spacing: 0.0, word_spacing: 0.0, render_mode: 0, scale: 1.0, leading: 0.0, rise: 0.0 }; let mut stack: Vec = Vec::new(); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let fonts_res = res_dict(doc, resources, b"Font"); @@ -157,11 +205,21 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< let mut path: Vec = Vec::new(); let mut path_box: Option<[f64; 4]> = None; let mut clip = false; + let mut forced_change = false; for data in streams { - let parsed = parse(data); + let mut parsed = parse(data); + if !scope.hidden_layers.is_empty() { + let props = res_dict(doc, resources, b"Properties"); + let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); + parsed.ops = kept; + if removed > 0 { + scope.layer_blocks += removed; + forced_change = true; + } + } let mut ops: Vec = Vec::with_capacity(parsed.ops.len()); - let mut changed = false; + let mut changed = std::mem::take(&mut forced_change); for op in parsed.ops { let o = op.op.as_slice(); // Path construction. @@ -271,6 +329,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, b"TL" => gs.leading = op.num(0).unwrap_or(0.0), b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), + b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, b"Td" | b"TD" => { if let Some([x, y]) = op.nums::<2>() { if o == b"TD" { @@ -403,7 +462,15 @@ fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec { + let degenerate = b[2] - b[0] < 0.01 && b[3] - b[1] < 0.01; + matches!(gs.render_mode, 3 | 7) || !(overlaps(page, b, 0.0) || degenerate) + } + None => scope.glyph_hit(b, origin), + }; + glyphs.push(Glyph { bytes: pos..pos + len, advance, hit }); *tm = Matrix::translate(advance * gs.scale, 0.0).then(tm); pos += len; } @@ -452,6 +519,13 @@ fn xobject( let obj = doc.get(r); let Object::Stream(s) = &*obj else { return None }; match s.dict.name(b"Subtype") { + _ if s.dict.get(b"OC").is_some_and(|oc| scope.layer_hidden(doc, oc)) => { + if scope.mode == Mode::Verify { + return None; + } + scope.layer_blocks += 1; + Some(None) + } Some(b"Image") => { let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]); if !scope.hits(b) || scope.mode == Mode::Verify { @@ -485,6 +559,7 @@ fn xobject( (v.len() == 4).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) }); if let Some(bb) = bbox + && !scope.everywhere() && !scope.hits(fctm.bbox(bb)) { return None; @@ -544,3 +619,45 @@ fn xobject( _ => None, } } + +/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks +/// included). Returns the remaining operators and how many blocks went. +fn strip_hidden_layers(doc: &Document, scope: &Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { + let mut out = Vec::with_capacity(ops.len()); + // For each open marked-content block: does it hide its contents? + let mut stack: Vec = Vec::new(); + let mut removed = 0; + for op in ops { + let hidden_now = stack.last().copied().unwrap_or(false); + match op.op.as_slice() { + b"BDC" | b"BMC" => { + let this = op.is("BDC") + && op.name(0) == Some(b"OC") + && match op.operands.get(1) { + Some(Object::Name(n)) => props.get(n).is_some_and(|o| scope.layer_hidden(doc, o)), + Some(o) => scope.layer_hidden(doc, o), + None => false, + }; + if this && !hidden_now { + removed += 1; + } + stack.push(hidden_now || this); + if !(hidden_now || this) { + out.push(op); + } + } + b"EMC" => { + let was = stack.pop().unwrap_or(false); + if !was { + out.push(op); + } + } + _ => { + if !hidden_now { + out.push(op); + } + } + } + } + (out, removed) +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 49c7d6cf..74707a04 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -18,6 +18,7 @@ mod fonts; mod image; mod interp; pub mod patterns; +pub mod sanitize; #[cfg(test)] mod tests; @@ -85,7 +86,7 @@ fn color(doc: &Document, d: &Dict, key: &[u8]) -> Option { } } -fn annots_of(doc: &Document, page: &Dict) -> Vec { +pub(crate) fn annots_of(doc: &Document, page: &Dict) -> Vec { page.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() } @@ -134,7 +135,7 @@ pub fn marks(doc: &Document) -> Vec { } /// A page's content streams: (the references or inline objects as listed, their decoded data). -fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec, Vec>), RedactError> { +pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec, Vec>), RedactError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs new file mode 100644 index 00000000..0b61df40 --- /dev/null +++ b/crates/redact/src/sanitize.rs @@ -0,0 +1,399 @@ +//! Remove Hidden Information and Sanitize Document (architecture §11.1, execution plan M8.4). +//! +//! [`scan`] counts what each category would remove; [`remove_hidden`] removes the chosen +//! categories; [`sanitize`] removes all of them. Every removal makes the next save a full +//! rewrite, so the earlier revision (which still holds the data) leaves the file. + +use printcraft_content::Matrix; +use printcraft_cos::{Dict, Document, ObjRef, Object}; + +use crate::interp::{Mode, Scope, process}; +use crate::{RedactError, Report, annots_of, page_streams}; + +/// The categories of Acrobat's Remove Hidden Information panel that PrintCraft handles. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Hidden { + /// Document information (`/Info`) and XMP metadata streams. + Metadata, + /// Embedded files and file attachment annotations. + Attachments, + /// Comments and markup annotations (with their pop-ups). + Comments, + /// Interactive form fields (flattened: their appearance stays as page content). + FormFields, + /// Text drawn invisibly (render modes 3 and 7) or wholly off the page. + HiddenText, + /// Optional content (layers) that is off: its content, then the layers themselves. + HiddenLayers, + Bookmarks, + /// Links, actions (open action, additional actions) and document JavaScript. + LinksActionsScripts, + /// Private data of other applications (`/PieceInfo`). + PrivateData, +} + +pub const HIDDEN: [Hidden; 9] = [ + Hidden::Metadata, + Hidden::Attachments, + Hidden::Comments, + Hidden::FormFields, + Hidden::HiddenText, + Hidden::HiddenLayers, + Hidden::Bookmarks, + Hidden::LinksActionsScripts, + Hidden::PrivateData, +]; + +impl Hidden { + pub fn label(self) -> &'static str { + match self { + Hidden::Metadata => "Metadata", + Hidden::Attachments => "File attachments", + Hidden::Comments => "Comments and markups", + Hidden::FormFields => "Form fields", + Hidden::HiddenText => "Hidden text", + Hidden::HiddenLayers => "Hidden layers", + Hidden::Bookmarks => "Bookmarks", + Hidden::LinksActionsScripts => "Links, actions and JavaScripts", + Hidden::PrivateData => "Private data of other applications", + } + } + + pub fn id(self) -> &'static str { + match self { + Hidden::Metadata => "metadata", + Hidden::Attachments => "attachments", + Hidden::Comments => "comments", + Hidden::FormFields => "form-fields", + Hidden::HiddenText => "hidden-text", + Hidden::HiddenLayers => "hidden-layers", + Hidden::Bookmarks => "bookmarks", + Hidden::LinksActionsScripts => "links-actions-scripts", + Hidden::PrivateData => "private-data", + } + } + + pub fn from_id(id: &str) -> Option { + HIDDEN.into_iter().find(|h| h.id() == id) + } +} + +fn catalog(doc: &Document) -> Option<(ObjRef, Dict)> { + let r = doc.root()?; + Some((r, doc.get(r).as_dict()?.clone())) +} + +fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Option { + d.get(key).and_then(|o| doc.resolve(o).as_dict().cloned()) +} + +/// Entries of a name tree (`/Names` leaves, `/Kids` recursion), bounded against cycles. +fn name_tree_len(doc: &Document, node: &Dict, depth: usize) -> usize { + if depth > 32 { + return 0; + } + let leaves = node.get(b"Names").and_then(|n| doc.resolve(n).as_array().map(|a| a.len() / 2)).unwrap_or(0); + let kids = node.get(b"Kids").and_then(|k| doc.resolve(k).as_array().cloned()).unwrap_or_default(); + leaves + kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).map(|k| name_tree_len(doc, &k, depth + 1)).sum::() +} + +fn outline_len(doc: &Document, first: Option<&Object>, depth: usize) -> usize { + let mut n = 0; + let mut cur = first.cloned(); + let mut guard = 0; + while let Some(o) = cur { + guard += 1; + if guard > 100_000 || depth > 64 { + break; + } + let Some(d) = doc.resolve(&o).as_dict().cloned() else { break }; + n += 1 + outline_len(doc, d.get(b"First"), depth + 1); + cur = d.get(b"Next").cloned(); + } + n +} + +fn is_comment(subtype: &[u8]) -> bool { + !matches!(subtype, b"Link" | b"Widget" | b"Popup" | b"FileAttachment" | b"Redact") +} + +/// The layers that are off in the default configuration. +fn hidden_layers(doc: &Document) -> Vec { + let Some((_, cat)) = catalog(doc) else { return Vec::new() }; + let Some(oc) = sub(doc, &cat, b"OCProperties") else { return Vec::new() }; + let Some(d) = sub(doc, &oc, b"D") else { return Vec::new() }; + let all: Vec = + oc.get(b"OCGs").and_then(|g| doc.resolve(g).as_array().map(|a| a.iter().filter_map(Object::as_ref).collect())).unwrap_or_default(); + let list = |k: &[u8]| -> Vec { + d.get(k).and_then(|g| doc.resolve(g).as_array().map(|a| a.iter().filter_map(Object::as_ref).collect())).unwrap_or_default() + }; + let (on, off) = (list(b"ON"), list(b"OFF")); + if d.name(b"BaseState") == Some(b"OFF") { + all.into_iter().filter(|g| !on.contains(g)).collect() + } else { + off.into_iter().filter(|g| all.contains(g)).collect() + } +} + +/// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them). +fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result { + let off = if layers { hidden_layers(doc) } else { Vec::new() }; + if !text && off.is_empty() { + return Ok(0); + } + let mut total = 0; + let n = printcraft_model::pages(doc).len(); + for pi in 0..n { + let page = printcraft_model::pages(doc).swap_remove(pi); + let Ok((list, data)) = page_streams(doc, &page.dict, pi) else { + if write { + return Err(RedactError::Unreadable(pi + 1)); + } + continue; + }; + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut report = Report::default(); + let mode = if write { Mode::Apply } else { Mode::Verify }; + let mut scope = Scope::new(&[], mode, &mut report); + if text { + let c = page.crop(doc); + scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]); + } + scope.hidden_layers = off.clone(); + let out = process(doc, &mut scope, &data, &resources, Matrix::IDENTITY); + let blocks = scope.layer_blocks; + total += out.residue + blocks + report.glyphs; + if !write { + continue; + } + let mut new_list = list.clone(); + let mut changed = false; + for (i, new) in out.streams.into_iter().enumerate() { + let Some(bytes) = new else { continue }; + let mut dict = match &*doc.resolve(&list[i]) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + new_list[i] = Object::Ref(doc.add(Object::Stream(printcraft_cos::Stream::flate(dict, &bytes)))); + changed = true; + } + if changed || !out.xobjects.is_empty() { + let mut res = resources; + if !out.xobjects.is_empty() { + let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + 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)); + })?; + } + } + Ok(total) +} + +/// How many items each category would remove (categories with nothing are included as 0). +pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + let cat = catalog(doc).map(|c| c.1).unwrap_or_default(); + let pages = printcraft_model::pages(doc); + let annots: Vec = pages.iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect(); + let count_sub = |s: &[u8]| annots.iter().filter(|a| a.name(b"Subtype") == Some(s)).count(); + let names = sub(doc, &cat, b"Names").unwrap_or_default(); + let mut doc2 = doc.clone(); + HIDDEN + .into_iter() + .map(|h| { + let n = match h { + Hidden::Metadata => { + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().map(|d| d.len())).unwrap_or(0); + let xmp = usize::from(cat.contains(b"Metadata")) + pages.iter().filter(|p| p.dict.contains(b"Metadata")).count(); + info + xmp + } + Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"), + Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), + Hidden::FormFields => printcraft_forms::fields(doc).len(), + Hidden::HiddenText => content_pass(&mut doc2, true, false, false).unwrap_or(0), + Hidden::HiddenLayers => { + let off = hidden_layers(doc).len(); + if off == 0 { 0 } else { off + content_pass(&mut doc2, false, true, false).unwrap_or(0) } + } + Hidden::Bookmarks => sub(doc, &cat, b"Outlines").map_or(0, |o| outline_len(doc, o.get(b"First"), 0)), + Hidden::LinksActionsScripts => { + count_sub(b"Link") + + usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some())) + + usize::from(cat.contains(b"AA")) + + pages.iter().filter(|p| p.dict.contains(b"AA")).count() + + annots.iter().filter(|a| a.contains(b"AA") || (a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A"))).count() + + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t, 0)) + } + Hidden::PrivateData => usize::from(cat.contains(b"PieceInfo")) + pages.iter().filter(|p| p.dict.contains(b"PieceInfo")).count(), + }; + (h, n) + }) + .collect() +} + +/// Remove the chosen categories. Returns what was removed per category. +pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result, RedactError> { + let counts = scan(doc); + let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); + let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?; + let mut done = Vec::new(); + // Content first (it needs the layers and the form still in place). + let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers)); + if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) { + content_pass(doc, text, layers, true)?; + } + if which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0 { + // Fields without appearances get one first, so their values stay visible. + for f in printcraft_forms::fields(doc) { + if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { + printcraft_forms::redraw_field(doc, &f.name)?; + } + } + let n = printcraft_model::pages(doc).len(); + printcraft_edit::flatten(doc, &(0..n).collect::>(), false, true)?; + doc.update_dict(root, |d| { + d.remove(b"AcroForm"); + })?; + } + // Annotations: comments, attachments, links, actions. + let drop_annot = |a: &Dict| -> bool { + let s = a.name(b"Subtype").unwrap_or(b""); + (which.contains(&Hidden::Comments) && is_comment(s)) + || (which.contains(&Hidden::Attachments) && s == b"FileAttachment") + || (which.contains(&Hidden::LinksActionsScripts) && s == b"Link") + }; + for p in printcraft_model::pages(doc) { + let list = annots_of(doc, &p.dict); + let mut removed: Vec = Vec::new(); + for a in &list { + if let Some(r) = a.as_ref() + && doc.get(r).as_dict().is_some_and(drop_annot) + { + removed.push(r); + } + } + let mut kept = Vec::new(); + for a in list { + let Some(r) = a.as_ref() else { + kept.push(a); + continue; + }; + let d = doc.get(r).as_dict().cloned().unwrap_or_default(); + let parent_gone = d.get(b"Parent").and_then(Object::as_ref).is_some_and(|x| removed.contains(&x)); + if removed.contains(&r) || (d.name(b"Subtype") == Some(b"Popup") && parent_gone) { + continue; + } + if which.contains(&Hidden::LinksActionsScripts) && (d.contains(b"AA") || (d.name(b"Subtype") == Some(b"Widget") && d.contains(b"A"))) { + doc.update_dict(r, |d| { + d.remove(b"AA"); + d.remove(b"A"); + })?; + } + kept.push(a); + } + let private = which.contains(&Hidden::PrivateData); + let metadata = which.contains(&Hidden::Metadata); + let actions = which.contains(&Hidden::LinksActionsScripts); + doc.update_dict(p.obj, |d| { + if kept.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + if private { + d.remove(b"PieceInfo"); + } + if metadata { + d.remove(b"Metadata"); + } + if actions { + d.remove(b"AA"); + } + })?; + } + let off = hidden_layers(doc); + let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default(); + let mut names = sub(doc, &cat, b"Names"); + for &h in which { + match h { + Hidden::Metadata => { + cat.remove(b"Metadata"); + doc.trailer_mut().remove(b"Info"); + } + Hidden::Attachments => { + if let Some(n) = names.as_mut() { + n.remove(b"EmbeddedFiles"); + } + cat.remove(b"AF"); + } + Hidden::Bookmarks => { + cat.remove(b"Outlines"); + if cat.name(b"PageMode") == Some(b"UseOutlines") { + cat.remove(b"PageMode"); + } + } + Hidden::LinksActionsScripts => { + if cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some()) { + cat.remove(b"OpenAction"); + } + cat.remove(b"AA"); + if let Some(n) = names.as_mut() { + n.remove(b"JavaScript"); + } + } + Hidden::PrivateData => { + cat.remove(b"PieceInfo"); + } + Hidden::HiddenLayers if !off.is_empty() => { + // The off layers leave the configuration (their content is gone already). + if let Some(mut oc) = sub(doc, &cat, b"OCProperties") { + let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r)); + if let Some(Object::Array(a)) = oc.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) { + oc.set(b"OCGs".to_vec(), Object::Array(a.into_iter().filter(keep).collect())); + } + if let Some(mut d) = sub(doc, &oc, b"D") { + for k in [&b"ON"[..], b"OFF", b"Order", b"Locked"] { + if let Some(Object::Array(a)) = d.get(k).map(|g| (*doc.resolve(g)).clone()) { + d.set(k.to_vec(), Object::Array(a.into_iter().filter(keep).collect())); + } + } + oc.set(b"D".to_vec(), Object::Dict(d)); + } + cat.set(b"OCProperties".to_vec(), Object::Dict(oc)); + } + } + _ => {} + } + let n = count(h); + if n > 0 { + done.push((h, n)); + } + } + if let Some(n) = names { + cat.set(b"Names".to_vec(), Object::Dict(n)); + } + doc.set(root, Object::Dict(cat)); + if done.is_empty() { + return Err(RedactError::NothingToApply); + } + doc.require_full_save(); + Ok(done) +} + +/// Sanitize Document: remove every category of hidden information. +pub fn sanitize(doc: &mut Document) -> Result, RedactError> { + match remove_hidden(doc, &HIDDEN) { + // Nothing hidden is fine for Sanitize: the full rewrite still drops old revisions. + Err(RedactError::NothingToApply) => { + doc.require_full_save(); + Ok(Vec::new()) + } + r => r, + } +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index d3773312..1b788f9b 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -264,3 +264,87 @@ fn unreadable_content_fails_closed() { mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); } + +/// A document with something in every hidden-information category. +fn hidden_fixture() -> Document { + let content = b"BT /F1 10 Tf 10 100 Td (Visible) Tj 3 Tr (Hidden) Tj 0 Tr ET BT /F1 10 Tf 500 500 Td (Offpage) Tj ET /OC /L1 BDC BT /F1 10 Tf 10 50 Td (Layer) Tj ET EMC"; + let objs: Vec> = vec![ + // 1 catalog + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R /Names << /EmbeddedFiles << /Names [(a.txt) 8 0 R] >> /JavaScript << /Names [(init) 9 0 R] >> >> /OpenAction 9 0 R /Outlines 10 0 R /PageMode /UseOutlines /PieceInfo << /App << /Private 1 >> >> /OCProperties << /OCGs [13 0 R 14 0 R] /D << /OFF [13 0 R] /Order [13 0 R 14 0 R] >> >> /AcroForm << /Fields [17 0 R] >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + // 3 page + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /Properties << /L1 13 0 R >> >> /Annots [15 0 R 16 0 R 17 0 R 18 0 R 19 0 R] /PieceInfo << /App << /Private 2 >> >> /AA << /O 9 0 R >> >>".to_vec(), + stream("", content), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + stream("/Type /Metadata /Subtype /XML", b""), + b"<< /Type /Filespec /F (a.txt) /EF << /F 20 0 R >> >>".to_vec(), + b"<< /S /JavaScript /JS (app.alert(1)) >>".to_vec(), + b"<< /Type /Outlines /First 11 0 R /Last 12 0 R /Count 2 >>".to_vec(), + b"<< /Title (One) /Parent 10 0 R /Next 12 0 R >>".to_vec(), + b"<< /Title (Two) /Parent 10 0 R /Prev 11 0 R >>".to_vec(), + b"<< /Type /OCG /Name (Secret layer) >>".to_vec(), + b"<< /Type /OCG /Name (Shown layer) >>".to_vec(), + // 15 comment + 16 its pop-up, 17 widget, 18 link, 19 attachment + b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] /Popup 16 0 R >>".to_vec(), + b"<< /Type /Annot /Subtype /Popup /Rect [60 10 160 60] /Parent 15 0 R >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [10 200 150 220] /A 9 0 R >>".to_vec(), + b"<< /Type /Annot /Subtype /Link /Rect [10 230 50 240] /A << /S /URI /URI (https://example.org) >> >>".to_vec(), + b"<< /Type /Annot /Subtype /FileAttachment /Rect [200 10 210 20] /FS 8 0 R >>".to_vec(), + stream("", b"attached"), + ]; + let mut doc = pdf(objs); + let info = doc.add(Object::Dict({ + let mut d = Dict::new(); + d.set(b"Title".to_vec(), printcraft_cos::PdfString::text("Secret plan")); + d.set(b"Author".to_vec(), printcraft_cos::PdfString::text("Ada")); + d + })); + doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(info)); + doc +} + +#[test] +fn hidden_information_is_counted_and_removed() { + use crate::sanitize::{Hidden, remove_hidden, sanitize, scan}; + let mut doc = hidden_fixture(); + let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::Metadata], 3, "two Info entries and the XMP stream"); + assert_eq!(counts[&Hidden::Attachments], 2, "the embedded file and the attachment annotation"); + assert_eq!(counts[&Hidden::Comments], 1); + assert_eq!(counts[&Hidden::FormFields], 1); + assert_eq!(counts[&Hidden::HiddenText], 6 + 7, "Hidden (render mode 3) and Offpage (off the page)"); + assert_eq!(counts[&Hidden::HiddenLayers], 2, "one off layer and its block"); + assert_eq!(counts[&Hidden::Bookmarks], 2); + assert_eq!(counts[&Hidden::LinksActionsScripts], 5, "link, open action, page actions, widget action, document script"); + assert_eq!(counts[&Hidden::PrivateData], 2); + + // Only hidden text first. + let done = remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); + assert_eq!(done, [(Hidden::HiddenText, 13)]); + let c = content(&doc, 0); + assert!(c.contains("(Visible)") && !c.contains("Hidden") && !c.contains("Offpage") && c.contains("(Layer)"), "{c}"); + assert!(doc.full_save_required()); + + // Then everything. + let done = sanitize(&mut doc).unwrap(); + assert!(done.iter().all(|(h, _)| *h != Hidden::HiddenText)); + let c = content(&doc, 0); + assert!(!c.contains("Layer") && c.contains("(Visible)"), "{c}"); + let doc = reopen(&doc); + let after: Vec<(Hidden, usize)> = scan(&doc).into_iter().filter(|(_, n)| *n > 0).collect(); + // A full save writes a fresh /Info with the modification date only. + assert!(after.iter().all(|(h, _)| *h == Hidden::Metadata), "{after:?}"); + let cat = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + for k in [&b"Outlines"[..], b"OpenAction", b"PieceInfo", b"AcroForm", b"Metadata"] { + assert!(!cat.contains(k), "{}", String::from_utf8_lossy(k)); + } + assert!(printcraft_forms::fields(&doc).is_empty()); + let p = printcraft_model::pages(&doc).swap_remove(0); + assert!(annots_of(&doc, &p.dict).is_empty()); + let shown = doc.object_numbers().into_iter().any(|n| match &*doc.get(ObjRef::new(n, doc.generation(n))) { + Object::Stream(s) => s.decoded().is_ok_and(|d| d.windows(5).any(|w| w == b"(Ada)")), + _ => false, + }); + assert!(shown, "the field's value stays visible as page content"); +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index e0fb609e..ff9c470f 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -244,6 +244,8 @@ impl PrintCraftApp { self.dialog = Some(Dialog::RedactApply); } } + "protect.remove_hidden" => self.open_remove_hidden(), + "redact.sanitize" => self.dialog = Some(Dialog::Sanitize), "redact.clear" => { self.apply_edit(Edit::ClearRedactions); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9a5920b5..30397719 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -311,6 +311,22 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { close = ok || cancel; return; } + Dialog::RemoveHidden => { + let (ok, cancel) = crate::redact_ui::hidden_body(ui, &mut app.hidden_draft, &t); + if ok { + redact_now = Some(dialog); + } + close = ok || cancel; + return; + } + Dialog::Sanitize => { + let (ok, cancel) = crate::redact_ui::sanitize_body(ui, &t); + if ok { + redact_now = Some(dialog); + } + close = ok || cancel; + return; + } Dialog::FieldProps => { let Some(d) = app.field_props.as_mut() else { close = true; @@ -575,6 +591,18 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let n = app.redact_search(); app.redact_search.found = Some(n); } + Some(Dialog::RemoveHidden) => { + let which: Vec = app.hidden_draft.found.iter().filter(|f| f.2 && f.1 > 0).map(|f| f.0).collect(); + let n: usize = app.hidden_draft.found.iter().filter(|f| f.2).map(|f| f.1).sum(); + if app.apply_edit(Edit::RemoveHidden { which }) { + app.notify(format!("Removed {n} hidden item{}. Save to remove them from the file.", if n == 1 { "" } else { "s" })); + } + } + Some(Dialog::Sanitize) => { + if app.apply_edit(Edit::Sanitize) { + app.notify("Document sanitized. Save to finish: saving rewrites the whole file."); + } + } Some(Dialog::RedactApply) => { let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); if app.apply_edit(Edit::ApplyRedactions { pages: None }) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2bf77c03..ad59b602 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,7 +33,7 @@ mod palette; mod panels; pub mod prepare; mod redact_ui; -pub use redact_ui::{PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft}; +pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft}; mod protect; mod recovery; pub mod theme; @@ -121,6 +121,9 @@ pub enum Dialog { RedactSearch, RedactProps, RedactApply, + /// Remove Hidden Information and Sanitize Document. + RemoveHidden, + Sanitize, /// Documents from a session that ended unexpectedly. Recovery, } @@ -228,6 +231,7 @@ pub struct PrintCraftApp { pub redact_prefs: RedactPrefs, pub redact_pages_draft: RedactPagesDraft, pub redact_search: RedactSearchDraft, + pub hidden_draft: HiddenDraft, /// The Replace Pages dialog's state. pub replace_draft: Option, /// The last web link the app asked the system to open (tests and automation). @@ -304,6 +308,7 @@ impl PrintCraftApp { redact_prefs: RedactPrefs::default(), redact_pages_draft: RedactPagesDraft::default(), redact_search: RedactSearchDraft::default(), + hidden_draft: HiddenDraft::default(), replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, } diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 4c02412f..0f703468 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -308,3 +308,71 @@ fn buttons(ui: &mut egui::Ui, ok: &str, primary: bool) -> (bool, bool) { }); (a, c) } + +/// Remove Hidden Information: each category with what was found, checked by default when +/// something was. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct HiddenDraft { + pub found: Vec<(printcraft_engine::Hidden, usize, bool)>, +} + +impl PrintCraftApp { + pub fn open_remove_hidden(&mut self) { + let Some((_, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + self.hidden_draft = HiddenDraft { found: doc.hidden_info().into_iter().map(|(h, n)| (h, n, n > 0)).collect() }; + self.dialog = Some(crate::Dialog::RemoveHidden); + } +} + +/// Returns (remove, cancel). +pub(crate) fn hidden_body(ui: &mut egui::Ui, d: &mut HiddenDraft, t: &Tokens) -> (bool, bool) { + ui.set_width(440.0); + ui.label(egui::RichText::new("Remove hidden information").font(crate::theme::semibold(18.0))); + ui.add_space(4.0); + ui.label(egui::RichText::new("Select the items to remove from this document.").color(t.text_muted)); + ui.add_space(8.0); + let total: usize = d.found.iter().map(|f| f.1).sum(); + for (h, n, on) in d.found.iter_mut() { + ui.add_enabled_ui(*n > 0, |ui| { + ui.horizontal(|ui| { + ui.checkbox(on, h.label()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(if *n == 0 { "None found".to_string() } else { n.to_string() }).color(t.text_muted)); + }); + }); + }); + } + ui.add_space(6.0); + ui.label( + egui::RichText::new("Form fields are flattened: their values stay visible. Saving rewrites the whole file.").small().color(t.text_faint), + ); + ui.add_space(12.0); + let any = d.found.iter().any(|f| f.2 && f.1 > 0); + let (mut a, mut c) = (false, false); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(any && total > 0, |ui| widgets::pill_button(ui, "Remove", true)).inner.clicked() { + a = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + c = true; + } + }); + (a, c) +} + +/// Sanitize Document confirmation. Returns (sanitize, cancel). +pub(crate) fn sanitize_body(ui: &mut egui::Ui, t: &Tokens) -> (bool, bool) { + ui.set_width(440.0); + ui.label(egui::RichText::new("Sanitize document").font(crate::theme::semibold(18.0))); + ui.add_space(8.0); + ui.label("Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data."); + ui.add_space(4.0); + ui.label( + egui::RichText::new("Save the document afterwards; saving rewrites the whole file so nothing removed stays in it.") + .small() + .color(t.text_muted), + ); + ui.add_space(12.0); + buttons(ui, "Sanitize", true) +} diff --git a/crates/ui-egui/tests/redact.rs b/crates/ui-egui/tests/redact.rs index 5e75b37f..9c162eb1 100644 --- a/crates/ui-egui/tests/redact.rs +++ b/crates/ui-egui/tests/redact.rs @@ -78,3 +78,30 @@ fn marking_applying_and_clearing() { assert_eq!((doc.redaction_marks(), doc.can_undo()), (0, Some("Apply redactions"))); assert!(doc.form.is_empty(), "the whole page was redacted, fields included"); } + +#[test] +fn removing_hidden_information_and_sanitizing() { + let mut h = harness(); + h.state_mut().execute("protect.remove_hidden"); + h.run_steps(2); + h.get_by_label("Remove hidden information"); + { + let d = &h.state().hidden_draft; + let fields = d.found.iter().find(|f| f.0 == printcraft_engine::Hidden::FormFields).unwrap(); + assert_eq!((fields.1, fields.2), (5, true), "five fields, checked"); + } + h.get_by_label("Remove").click(); + h.run_steps(3); + { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert!(doc.form.is_empty()); + assert_eq!(doc.can_undo(), Some("Remove hidden information")); + } + h.state_mut().execute("redact.sanitize"); + h.run_steps(2); + h.get_by_label("Sanitize").click(); + h.run_steps(3); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Sanitize document")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 234017b5..ec643ab5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5659,11 +5659,11 @@ title = "Remove hidden information (selectable categories)" area = "G" tier = "P1" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["protect.remove_hidden"] +tools = ["doc_hidden_info", "doc_remove_hidden"] +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools"] +notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts, private data; overlapping objects and cropped content pending" [[feature]] id = "protect.sanitize" @@ -5671,11 +5671,11 @@ title = "Sanitize document (remove all hidden information)" area = "G" tier = "P1" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.sanitize"] +tools = ["doc_remove_hidden"] +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools"] +notes = "every category, then a full rewrite on save" [[feature]] id = "protect.remove-hidden-on-close"