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:
parent
f8175fb97e
commit
d2926538bd
|
|
@ -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)?,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"])),
|
||||
|
|
|
|||
|
|
@ -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(_))));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
}],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue