mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge pull request #3285 from mtlcl410/fix/struct-refs-parent-tree-2532
M8: redaction's structure-tree walks start from the parent and ID trees too (#2447, #3302)
This commit is contained in:
@@ -51,6 +51,13 @@ pub enum RedactError {
|
||||
"redaction appearance has invalid geometry or exceeds the 8 MiB budget; reduce the marked area, turn off repeated text or apply fewer pages at once (nothing was redacted)"
|
||||
)]
|
||||
OverlayLimit,
|
||||
/// The structure tree couldn't be cleaned completely (#3302): an element written in place that
|
||||
/// would need changing, an element whose inherited page can't be found, or a tree too large to
|
||||
/// walk. Tags that kept the removed text or annotation would undo the redaction.
|
||||
#[error(
|
||||
"the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused"
|
||||
)]
|
||||
Tags,
|
||||
#[error(transparent)]
|
||||
Edit(#[from] pdfcraft_edit::EditError),
|
||||
#[error(transparent)]
|
||||
@@ -305,6 +312,12 @@ fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfc
|
||||
|
||||
/// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved
|
||||
/// file; callers keep the previous document for undo.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This operation is not transactional: an error may leave `doc` partially modified, with
|
||||
/// sensitive content still present. Call this on a working clone and replace the original only
|
||||
/// on success. On any error, discard the working clone; do not save or reuse it.
|
||||
pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, RedactError> {
|
||||
let all_marks = marks(doc);
|
||||
let chosen: Vec<&Mark> = all_marks.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).collect();
|
||||
|
||||
@@ -261,6 +261,12 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
|
||||
}
|
||||
|
||||
/// Remove the chosen categories. Returns what was removed per category.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This operation is not transactional: an error may leave `doc` partially modified, with
|
||||
/// sensitive content still present. Call this on a working clone and replace the original only
|
||||
/// on success. On any error, discard the working clone; do not save or reuse it.
|
||||
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);
|
||||
|
||||
+134
-31
@@ -9,6 +9,8 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
|
||||
use crate::RedactError;
|
||||
|
||||
/// Each MCID's operators (serialized), and whether anything is still drawn inside it.
|
||||
fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap<i64, (Vec<u8>, bool)> {
|
||||
let mut out: HashMap<i64, (Vec<u8>, bool)> = HashMap::new();
|
||||
@@ -103,7 +105,23 @@ pub(crate) fn clean(
|
||||
stream: Option<(ObjRef, ObjRef)>,
|
||||
changed: &HashSet<i64>,
|
||||
empty: &HashSet<i64>,
|
||||
) -> Result<usize, pdfcraft_cos::CosError> {
|
||||
) -> Result<usize, RedactError> {
|
||||
clean_within(doc, page, stream, changed, empty, MAX_STRUCT_REFS)
|
||||
}
|
||||
|
||||
/// [`clean`], visiting at most `limit` structure-tree references. Fail-closed (#3302): when the
|
||||
/// cleanup can't be shown complete, it is [`RedactError::Tags`] and the redaction isn't made. That
|
||||
/// is a tree too large to walk in full (it could hide an element that repeats the redacted text),
|
||||
/// an element whose inherited page can't be found (its ancestry too deep, or looping), and an
|
||||
/// element written in place that would need changing (only the object holding it could be).
|
||||
pub(crate) fn clean_within(
|
||||
doc: &mut Document,
|
||||
page: ObjRef,
|
||||
stream: Option<(ObjRef, ObjRef)>,
|
||||
changed: &HashSet<i64>,
|
||||
empty: &HashSet<i64>,
|
||||
limit: usize,
|
||||
) -> Result<usize, RedactError> {
|
||||
if changed.is_empty() && stream.is_none() {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -114,17 +132,44 @@ pub(crate) fn clean(
|
||||
else {
|
||||
return Ok(0);
|
||||
};
|
||||
// Indirect elements with the page they inherit.
|
||||
let mut todo: Vec<(Object, Option<ObjRef>)> = root.get(b"K").map(|k| vec![(k.clone(), None)]).unwrap_or_default();
|
||||
// Indirect elements with the page they inherit. Elements are reached from the root's /K, and
|
||||
// also from the parent tree and the ID tree: one listed only there still owns marked content
|
||||
// (#3302). The stack pops /K first, so an element reached through /K gets the page it inherits
|
||||
// from its ancestors before a tree reaches it without one.
|
||||
let mut todo: Vec<(Object, Option<ObjRef>)> =
|
||||
[&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None)).collect();
|
||||
let mut seen = HashSet::new();
|
||||
let mut pages = HashMap::new();
|
||||
let mut hits: Vec<(ObjRef, Dict, Option<ObjRef>, bool)> = Vec::new();
|
||||
// Whether marked content `x` (a kid on page `pg`) is one this cleanup must change.
|
||||
let touches = |doc: &Document, x: &Object, pg: Option<ObjRef>| {
|
||||
mcid_of(doc, x, pg, page, stream).is_some_and(|m| stream.is_some() || changed.contains(&m) || empty.contains(&m))
|
||||
};
|
||||
while let Some((k, pg)) = todo.pop() {
|
||||
match k {
|
||||
Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg))),
|
||||
// A number or name tree node written in place.
|
||||
Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))),
|
||||
// An element written in place: its kids are followed, but if it owns marked content
|
||||
// this cleanup changes, only rewriting what holds it could change it.
|
||||
Object::Dict(d) => {
|
||||
let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) {
|
||||
Some(p) => Some(p),
|
||||
None => inherited_page(doc, &d, &mut pages)?,
|
||||
};
|
||||
let kids = kids_of(doc, &d);
|
||||
if kids.iter().any(|x| touches(doc, x, pg)) {
|
||||
return Err(RedactError::Tags);
|
||||
}
|
||||
todo.extend(kids.into_iter().map(|x| (x, pg)));
|
||||
}
|
||||
Object::Ref(r) => {
|
||||
if !seen.insert(r) || seen.len() > 2_000_000 {
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() > limit {
|
||||
return Err(RedactError::Tags);
|
||||
}
|
||||
let object = doc.get(r);
|
||||
if let Object::Array(a) = &*object {
|
||||
todo.extend(a.iter().cloned().map(|x| (x, pg)));
|
||||
@@ -132,16 +177,15 @@ pub(crate) fn clean(
|
||||
}
|
||||
let Some(d) = object.as_dict().cloned() else { continue };
|
||||
if !d.contains(b"S") {
|
||||
// A parent tree or ID tree node: its values may be elements.
|
||||
todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg)));
|
||||
continue;
|
||||
}
|
||||
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
|
||||
let kids: Vec<Object> = match d.get(b"K").map(|k| doc.resolve(k)) {
|
||||
Some(o) => match &*o {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => d.get(b"K").cloned().into_iter().collect(),
|
||||
},
|
||||
None => Vec::new(),
|
||||
let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) {
|
||||
Some(p) => Some(p),
|
||||
None => inherited_page(doc, &d, &mut pages)?,
|
||||
};
|
||||
let kids = kids_of(doc, &d);
|
||||
let sensitive = kids.iter().any(|x| mcid_of(doc, x, pg, page, stream).is_some_and(|m| changed.contains(&m)));
|
||||
let remap = stream.is_some() && kids.iter().any(|x| mcid_of(doc, x, pg, page, stream).is_some());
|
||||
if sensitive || remap {
|
||||
@@ -203,6 +247,49 @@ pub(crate) fn clean(
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// The page an element inherits (§14.7.4.4): the nearest `/Pg` among its ancestors, following
|
||||
/// `/P`. `Ok(None)` when none has one; [`RedactError::Tags`] when the lookup gives up (more than
|
||||
/// [`MAX_PARENTS`] ancestors, or a loop), which isn't the same as having no page. `pages` keeps
|
||||
/// each ancestor's answer, so siblings don't climb the same chain again.
|
||||
fn inherited_page(doc: &Document, d: &Dict, pages: &mut HashMap<ObjRef, Option<ObjRef>>) -> Result<Option<ObjRef>, RedactError> {
|
||||
let mut chain: Vec<ObjRef> = Vec::new();
|
||||
let mut parent = d.get(b"P").and_then(Object::as_ref);
|
||||
let found = loop {
|
||||
let Some(r) = parent else { break None };
|
||||
if let Some(known) = pages.get(&r) {
|
||||
break *known;
|
||||
}
|
||||
if chain.len() >= MAX_PARENTS || chain.contains(&r) {
|
||||
return Err(RedactError::Tags);
|
||||
}
|
||||
chain.push(r);
|
||||
let object = doc.get(r);
|
||||
let Some(p) = object.as_dict() else { break None };
|
||||
if let Some(pg) = p.get(b"Pg").and_then(Object::as_ref) {
|
||||
break Some(pg);
|
||||
}
|
||||
parent = p.get(b"P").and_then(Object::as_ref);
|
||||
};
|
||||
for r in chain {
|
||||
pages.insert(r, found);
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// An element's kids: its `/K` array, or the one kid it has.
|
||||
fn kids_of(doc: &Document, d: &Dict) -> Vec<Object> {
|
||||
match d.get(b"K").map(|k| doc.resolve(k)) {
|
||||
Some(o) => match &*o {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => d.get(b"K").cloned().into_iter().collect(),
|
||||
},
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The most ancestors [`inherited_page`] looks through; deeper trees, and cycles, go unanswered.
|
||||
const MAX_PARENTS: usize = 256;
|
||||
|
||||
/// Whether `o` is an object reference (`/Type /OBJR`, direct or indirect) to one of `gone`.
|
||||
fn refers_to(doc: &Document, o: &Object, gone: &HashSet<ObjRef>) -> bool {
|
||||
doc.resolve(o)
|
||||
@@ -210,22 +297,34 @@ fn refers_to(doc: &Document, o: &Object, gone: &HashSet<ObjRef>) -> bool {
|
||||
.is_some_and(|d| d.name(b"Type") == Some(b"OBJR") && d.get(b"Obj").and_then(Object::as_ref).is_some_and(|r| gone.contains(&r)))
|
||||
}
|
||||
|
||||
/// What a number or name tree node leads on to: its `/Nums` or `/Names` array (keys and values
|
||||
/// alike; the walk ignores numbers and strings) and its `/Kids`.
|
||||
fn tree_node_entries(d: &Dict) -> Vec<Object> {
|
||||
[&b"Nums"[..], b"Names", b"Kids"].iter().filter_map(|key| d.get(key).cloned()).collect()
|
||||
}
|
||||
|
||||
/// Drop the structure tree's object references (OBJR) to `gone`, annotations removed from their
|
||||
/// pages (#2447). A tagged PDF's tree points at each link and comment it tags; left there, the
|
||||
/// reference keeps the annotation, its URL or text included, in a full rewrite after the page
|
||||
/// no longer lists it. Returns how many references were dropped.
|
||||
pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet<ObjRef>) -> Result<usize, pdfcraft_cos::CosError> {
|
||||
///
|
||||
/// Only elements that are objects of their own are edited: changing a structure element written in
|
||||
/// place (a direct dictionary in a `/K` array or a tree) means rewriting the object that holds it,
|
||||
/// so when one of those refers to a removed annotation it is [`RedactError::Tags`] (fail-closed).
|
||||
/// Producers rarely write elements that way.
|
||||
pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet<ObjRef>) -> Result<usize, RedactError> {
|
||||
drop_object_refs_within(doc, gone, MAX_STRUCT_REFS)
|
||||
}
|
||||
|
||||
/// The most structure-tree references [`drop_object_refs`] visits.
|
||||
const MAX_STRUCT_REFS: usize = 2_000_000;
|
||||
|
||||
/// [`drop_object_refs`], visiting at most `limit` references. A tree too large to walk in full
|
||||
/// could keep a reference the walk never reached, so then the whole structure tree goes instead
|
||||
/// (with `/MarkInfo`, which would claim tags there no longer are): fail-closed, at the cost of
|
||||
/// the document's tags.
|
||||
pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>, limit: usize) -> Result<usize, pdfcraft_cos::CosError> {
|
||||
/// [`drop_object_refs`], visiting at most `limit` references. Fail-closed: a tree too large to
|
||||
/// walk in full could keep a reference the walk never reached, and an element written in place
|
||||
/// can only be changed by rewriting what holds it, so either is [`RedactError::Tags`] rather than
|
||||
/// a cleanup that leaves the annotation reachable (removing only the tree would not: an outline
|
||||
/// item's `/SE` or a link's `/SD` still reaches its elements).
|
||||
pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>, limit: usize) -> Result<usize, RedactError> {
|
||||
if gone.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -236,23 +335,31 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
|
||||
else {
|
||||
return Ok(0);
|
||||
};
|
||||
let mut todo: Vec<Object> = root.get(b"K").cloned().into_iter().collect();
|
||||
// Elements are reached from the root's /K, and also from the parent tree and the ID tree:
|
||||
// one listed only there (left behind by an editor, say) still points at its annotation.
|
||||
let mut todo: Vec<Object> = [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).collect();
|
||||
let mut seen = HashSet::new();
|
||||
let mut hits: Vec<ObjRef> = Vec::new();
|
||||
while let Some(k) = todo.pop() {
|
||||
match k {
|
||||
Object::Array(a) => todo.extend(a),
|
||||
// A structure element written in place: its kids are followed; one that tags a
|
||||
// removed annotation can't be changed here (see above).
|
||||
Object::Dict(d) if d.contains(b"S") => {
|
||||
let kids = kids_of(doc, &d);
|
||||
if kids.iter().any(|x| refers_to(doc, x, gone)) {
|
||||
return Err(RedactError::Tags);
|
||||
}
|
||||
todo.extend(kids);
|
||||
}
|
||||
// A number or name tree node written in place (§7.9.6, §7.9.7): its values and kids.
|
||||
Object::Dict(d) => todo.extend(tree_node_entries(&d)),
|
||||
Object::Ref(r) => {
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() > limit {
|
||||
let Some(catalog) = doc.root() else { return Ok(0) };
|
||||
doc.update_dict(catalog, |c| {
|
||||
c.remove(b"StructTreeRoot");
|
||||
c.remove(b"MarkInfo");
|
||||
})?;
|
||||
return Ok(0);
|
||||
return Err(RedactError::Tags);
|
||||
}
|
||||
let object = doc.get(r);
|
||||
if let Object::Array(a) = &*object {
|
||||
@@ -261,15 +368,11 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
|
||||
}
|
||||
let Some(d) = object.as_dict() else { continue };
|
||||
if !d.contains(b"S") {
|
||||
// A parent tree or ID tree node: its values may be elements.
|
||||
todo.extend(tree_node_entries(d));
|
||||
continue;
|
||||
}
|
||||
let kids: Vec<Object> = match d.get(b"K").map(|k| doc.resolve(k)) {
|
||||
Some(o) => match &*o {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => d.get(b"K").cloned().into_iter().collect(),
|
||||
},
|
||||
None => Vec::new(),
|
||||
};
|
||||
let kids = kids_of(doc, d);
|
||||
if kids.iter().any(|x| refers_to(doc, x, gone)) {
|
||||
hits.push(r);
|
||||
}
|
||||
|
||||
+217
-8
@@ -829,11 +829,12 @@ fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() {
|
||||
}
|
||||
|
||||
/// #2532 review: a structure tree larger than the walk's limit can't be searched in full, so a
|
||||
/// reference the walk never reached could keep a removed annotation. Then the whole tree goes
|
||||
/// (with `/MarkInfo`), rather than reporting success with references left behind; under the
|
||||
/// limit only the reference goes.
|
||||
/// reference the walk never reached could keep a removed annotation. Then it is an error, and
|
||||
/// nothing is changed, rather than success with references left behind (removing only the tree
|
||||
/// would leave an outline's `/SE` reaching them, review on #3285); under the limit only the
|
||||
/// reference goes.
|
||||
#[test]
|
||||
fn a_structure_tree_too_large_to_walk_is_removed_whole() {
|
||||
fn a_structure_tree_too_large_to_walk_is_refused() {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
@@ -854,11 +855,219 @@ fn a_structure_tree_too_large_to_walk_is_removed_whole() {
|
||||
assert!(catalog(&doc).contains(b"StructTreeRoot") && catalog(&doc).contains(b"MarkInfo"), "under the limit the tree stays");
|
||||
|
||||
let mut doc = pdf(objs);
|
||||
crate::tags::drop_object_refs_within(&mut doc, &gone, 2).unwrap();
|
||||
assert!(!catalog(&doc).contains(b"StructTreeRoot"), "a tree the walk couldn't finish goes whole");
|
||||
assert!(!catalog(&doc).contains(b"MarkInfo"));
|
||||
assert!(matches!(crate::tags::drop_object_refs_within(&mut doc, &gone, 2), Err(RedactError::Tags)), "a tree the walk couldn't finish is refused");
|
||||
assert!(catalog(&doc).contains(b"StructTreeRoot"), "nothing changed");
|
||||
}
|
||||
|
||||
/// #3302: an element listed only in the parent tree (not under the root's /K) still owns the
|
||||
/// marked content redaction changed, so its /ActualText and /Alt, which repeat the redacted
|
||||
/// words, go as they do for an element under /K.
|
||||
#[test]
|
||||
fn an_element_listed_only_in_the_parent_tree_loses_its_redacted_text() {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>".to_vec(),
|
||||
stream("", b"/P <</MCID 0>> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"),
|
||||
// The root's /K reaches only the empty document element 6; the paragraph 7 is in the
|
||||
// parent tree only.
|
||||
b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) /Alt (Codename Bluefin) >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Note /P 6 0 R >>".to_vec(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
|
||||
];
|
||||
let mut doc = pdf(objs);
|
||||
mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert!(r.glyphs > 0 && r.tags >= 1, "{r:?}");
|
||||
let element = doc.get(ObjRef::new(7, 0)).as_dict().cloned().unwrap();
|
||||
assert!(!element.contains(b"ActualText") && !element.contains(b"Alt"), "{element:?}");
|
||||
}
|
||||
|
||||
/// #3302: a structure tree too large to walk in full fails closed: an error, so the redaction
|
||||
/// isn't made, instead of the walk stopping and reporting success with elements never checked.
|
||||
#[test]
|
||||
fn a_tag_walk_cut_short_is_refused() {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /StructParents 0 >>".to_vec(),
|
||||
b"<< >>".to_vec(),
|
||||
b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Figure /P 6 0 R /Alt (A chart) /E (chart) >>".to_vec(),
|
||||
];
|
||||
let page = ObjRef::new(3, 0);
|
||||
let changed: std::collections::HashSet<i64> = [0].into();
|
||||
let mut doc = pdf(objs.clone());
|
||||
assert_eq!(crate::tags::clean_within(&mut doc, page, None, &changed, &Default::default(), 100).unwrap(), 1);
|
||||
let figure = doc.get(ObjRef::new(8, 0)).as_dict().cloned().unwrap();
|
||||
assert!(figure.contains(b"Alt"), "a walk that finishes keeps an unrelated element's text");
|
||||
|
||||
let mut doc = pdf(objs);
|
||||
assert!(matches!(crate::tags::clean_within(&mut doc, page, None, &changed, &Default::default(), 1), Err(RedactError::Tags)));
|
||||
}
|
||||
|
||||
/// #2532 follow-up: an element reached only through the parent tree or the ID tree, not from
|
||||
/// the root's /K, still points at a removed annotation, and the root keeps it reachable in a full
|
||||
/// rewrite. Both trees are walked too, written in place or as their own objects.
|
||||
#[test]
|
||||
fn elements_listed_only_in_the_parent_tree_or_the_id_tree_lose_their_references() {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>".to_vec(),
|
||||
// 4: the removed link; 8: the removed comment
|
||||
b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(),
|
||||
// 5: the root; its /K reaches only the empty document element 6. The parent tree is
|
||||
// written in place, the ID tree's kid (9) as its own object.
|
||||
b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 7 0 R] >> /IDTree << /Kids [9 0 R] >> >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(),
|
||||
// 7: a link element only the parent tree lists
|
||||
b"<< /Type /StructElem /S /Link /P 6 0 R /K << /Type /OBJR /Obj 4 0 R >> /Alt (secret.example link) >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (hushhush note) >>".to_vec(),
|
||||
b"<< /Names [(note1) 10 0 R] >>".to_vec(),
|
||||
// 10: an annotation element only the ID tree lists
|
||||
b"<< /Type /StructElem /S /Annot /P 6 0 R /ID (note1) /K [<< /Type /OBJR /Obj 8 0 R >>] >>".to_vec(),
|
||||
];
|
||||
let gone: std::collections::HashSet<ObjRef> = [ObjRef::new(4, 0), ObjRef::new(8, 0)].into();
|
||||
let mut doc = pdf(objs);
|
||||
assert_eq!(crate::tags::drop_object_refs(&mut doc, &gone).unwrap(), 2, "both references go");
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
assert!(!bytes.windows(14).any(|w| w == b"secret.example"), "the removed link can't be reached any more");
|
||||
for secret in [&b"secret.example"[..], b"hushhush"] {
|
||||
assert!(!bytes.windows(secret.len()).any(|w| w == secret), "{} survived the rewrite", String::from_utf8_lossy(secret));
|
||||
}
|
||||
}
|
||||
|
||||
/// #3285 review: an element without its own /Pg takes its ancestor's page, also when the parent
|
||||
/// tree lists it. One under /K must not be reached first through the parent tree without that
|
||||
/// page, and one only the parent tree lists finds it through /P.
|
||||
#[test]
|
||||
fn an_element_inheriting_its_page_loses_its_redacted_text_when_the_parent_tree_lists_it() {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>".to_vec(),
|
||||
stream("", b"/P <</MCID 0>> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC /P <</MCID 1>> BDC BT /F1 14 Tf 20 100 Td (Codename Bluefin) Tj ET EMC"),
|
||||
// The document element 6 carries the page; 7 sits under it, 8 only points at it.
|
||||
b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R 8 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Document /P 5 0 R /Pg 3 0 R /K [7 0 R] >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /P /P 6 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /P /P 6 0 R /K 1 /Alt (Codename Bluefin) >>".to_vec(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
|
||||
];
|
||||
let mut doc = pdf(objs);
|
||||
mark(&mut doc, 0, &[[10.0, 90.0, 290.0, 170.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert!(r.glyphs > 0, "{r:?}");
|
||||
for n in [7, 8] {
|
||||
let element = doc.get(ObjRef::new(n, 0)).as_dict().cloned().unwrap();
|
||||
assert!(!element.contains(b"ActualText") && !element.contains(b"Alt"), "{n}: {element:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3285 review: a structure element written in place, here as an ID tree value, can't be
|
||||
/// edited without rewriting what holds it; when it tags a removed annotation, removing hidden
|
||||
/// information is refused instead of keeping the annotation reachable in a full rewrite.
|
||||
/// Direct callers discard the failed working copy to preserve the original document.
|
||||
#[test]
|
||||
fn an_element_written_in_place_that_tags_a_removed_annotation_is_refused() {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [4 0 R] >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (hushhush note) >>".to_vec(),
|
||||
b"<< /Type /StructTreeRoot /K 6 0 R /IDTree << /Names [(note1) << /Type /StructElem /S /Annot /P 6 0 R /K [<< /Type /OBJR /Obj 4 0 R >>] >>] >> >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(),
|
||||
];
|
||||
let original = pdf(objs);
|
||||
let before = write_full(&original, &SaveOptions::default()).unwrap();
|
||||
let mut working = original.clone();
|
||||
assert!(matches!(sanitize::remove_hidden(&mut working, &[sanitize::Hidden::Comments]), Err(RedactError::Tags)));
|
||||
assert!(!working.get(ObjRef::new(3, 0)).as_dict().unwrap().contains(b"Annots"), "the failed working copy was partially changed");
|
||||
drop(working);
|
||||
assert_eq!(write_full(&original, &SaveOptions::default()).unwrap(), before, "discarding the working copy preserves the original");
|
||||
}
|
||||
|
||||
/// Whether a full rewrite of `doc` still holds the redacted word.
|
||||
fn bluefin_survives(doc: &Document) -> bool {
|
||||
let bytes = write_full(doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
bytes.windows(7).any(|w| w == b"Bluefin")
|
||||
}
|
||||
|
||||
/// #3285 review (the maintainer's probes): an element written in place in the parent tree that
|
||||
/// owns the redacted text can't be changed, so applying is refused; the same element as an object
|
||||
/// of its own is cleaned, and nothing of the word is left in a full rewrite.
|
||||
/// Refusal does not promise rollback: direct callers discard the failed working copy.
|
||||
#[test]
|
||||
fn an_element_written_in_place_owning_redacted_text_is_refused() {
|
||||
let objs = |element: &str| -> Vec<Vec<u8>> {
|
||||
vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>"
|
||||
.to_vec(),
|
||||
stream("", b"/P <</MCID 0>> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"),
|
||||
format!("<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [{element}]] >> /ParentTreeNextKey 1 >>").into_bytes(),
|
||||
b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(),
|
||||
b"<< >>".to_vec(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
|
||||
]
|
||||
};
|
||||
let mut doc = pdf(objs("<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>"));
|
||||
mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
|
||||
let before = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
let mut working = doc.clone();
|
||||
let error = apply(&mut working, None).unwrap_err();
|
||||
assert!(matches!(error, RedactError::Tags));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused"
|
||||
);
|
||||
assert!(!content(&working, 0).contains("Bluefin"), "the failed working copy was partially changed");
|
||||
assert!(bluefin_survives(&working), "the failed working copy still holds the word in its tags");
|
||||
drop(working);
|
||||
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before, "discarding the working copy preserves the original");
|
||||
let mut doc = pdf(objs("7 0 R"));
|
||||
mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert!(r.glyphs > 0 && !bluefin_survives(&doc), "{r:?}");
|
||||
}
|
||||
|
||||
/// #3285 review: an element whose inherited page can't be found (more ancestors than the lookup
|
||||
/// follows) isn't taken for one with no page: applying is refused. Within the limit the page is
|
||||
/// found and the text goes.
|
||||
#[test]
|
||||
fn an_element_whose_inherited_page_is_out_of_reach_is_refused() {
|
||||
let objs = |depth: u32| -> Vec<Vec<u8>> {
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>"
|
||||
.to_vec(),
|
||||
stream("", b"/P <</MCID 0>> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"),
|
||||
b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Document /P 5 0 R /Pg 3 0 R >>".to_vec(),
|
||||
format!("<< /Type /StructElem /S /P /P {} 0 R /K 0 /ActualText (Codename Bluefin) >>", 10 + depth - 1).into_bytes(),
|
||||
b"<< >>".to_vec(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
|
||||
];
|
||||
for i in 0..depth {
|
||||
let parent = if i == 0 { 6 } else { 10 + i - 1 };
|
||||
objs.push(format!("<< /Type /StructElem /S /Div /P {parent} 0 R >>").into_bytes());
|
||||
}
|
||||
objs
|
||||
};
|
||||
let mut doc = pdf(objs(300));
|
||||
mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
|
||||
assert!(matches!(apply(&mut doc, None), Err(RedactError::Tags)));
|
||||
let mut doc = pdf(objs(100));
|
||||
mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
|
||||
apply(&mut doc, None).unwrap();
|
||||
assert!(!bluefin_survives(&doc));
|
||||
}
|
||||
|
||||
/// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia
|
||||
|
||||
Reference in New Issue
Block a user