M8.4: Remove Hidden Information and Sanitize Document: scan with per-category counts (metadata, attachments, comments, form fields, hidden text in render modes 3/7 or off the page, hidden layers' content and groups, bookmarks, links/actions/JavaScript, private data); remove chosen categories (fields flattened so values stay visible) or all; full rewrite on save. Dialogs with checkboxes and counts, Sanitize confirmation; doc_hidden_info/doc_remove_hidden tools (59 tools)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 00:30:18 -04:00
parent f8175fb97e
commit d2926538bd
18 changed files with 866 additions and 22 deletions

View File

@ -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)?,

View File

@ -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<Value> {
let doc = self.doc(a)?;
let items: Vec<Value> = 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<Value> {
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::<Result<Vec<_>>>()?;
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)
}
}

View File

@ -287,6 +287,26 @@ pub fn tools() -> Vec<ToolDef> {
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"])),

View File

@ -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(_))));
}

View File

@ -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),
],
}],
},

View File

@ -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"),

View File

@ -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<Vec<u8>>);
@ -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<Hidden>,
},
/// Sanitize Document: every category, then a full rewrite on save.
Sanitize,
/// Flatten comments and/or form fields on every page into page content.
Flatten {
comments: bool,
@ -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::<Vec<_>>(), *comments, *fields)?;

View File

@ -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).

View File

@ -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<ObjRef>,
/// Content removed from hidden layers (blocks and XObjects).
pub layer_blocks: usize,
fonts: HashMap<Vec<u8>, Rc<Metrics>>,
used_names: Vec<Vec<u8>>,
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<ObjRef> = 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<u8>], 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<Gs> = 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<Op> = 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<Op> = 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<O
let b = trm.bbox([0.0, f.descent, w0, f.ascent]);
let origin = trm.apply(0.0, 0.0);
let advance = w0 * size + spacing;
glyphs.push(Glyph { bytes: pos..pos + len, advance, hit: scope.glyph_hit(b, origin) });
let hit = match scope.hidden_text {
// Invisible (Tr 3) and clip-only (Tr 7) text, or text wholly off the page.
Some(page) => {
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<Op>, props: &Dict) -> (Vec<Op>, usize) {
let mut out = Vec::with_capacity(ops.len());
// For each open marked-content block: does it hide its contents?
let mut stack: Vec<bool> = 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)
}

View File

@ -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<Rgb> {
}
}
fn annots_of(doc: &Document, page: &Dict) -> Vec<Object> {
pub(crate) fn annots_of(doc: &Document, page: &Dict) -> Vec<Object> {
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<Mark> {
}
/// 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<Object>, Vec<Vec<u8>>), RedactError> {
pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec<Object>, Vec<Vec<u8>>), RedactError> {
let list: Vec<Object> = match page.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {

View File

@ -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<Self> {
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<Dict> {
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::<usize>()
}
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<ObjRef> {
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<ObjRef> =
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<ObjRef> {
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<usize, RedactError> {
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<Dict> = 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<Vec<(Hidden, usize)>, 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::<Vec<_>>(), 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<ObjRef> = 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<Vec<(Hidden, usize)>, 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,
}
}

View File

@ -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<u8>> = 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"<x:xmpmeta/>"),
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<Hidden, usize> = 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");
}

View File

@ -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);
}

View File

@ -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<printcraft_engine::Hidden> = 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 }) {

View File

@ -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<files::ReplaceDraft>,
/// 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 },
}

View File

@ -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)
}

View File

@ -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"));
}

View File

@ -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"