M8: tag cleanup refuses what it can't finish instead of removing the tree (#3302)

Maintainer review on #3285: an element written in place that owns redacted
marked content or tags a removed annotation, an inherited page the lookup gave
up on (over 256 ancestors or a loop), and a walk past its limit are now
RedactError::Tags, so nothing is changed. Removing only /StructTreeRoot left
outline /SE and GoTo /SD reaching the elements. Inherited pages are cached per
ancestor. clear_every_element and drop_tree are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
This commit is contained in:
mtlcl410
2026-10-19 03:20:34 +08:00
co-authored by Claude Opus 5.5
parent f62bf71a77
commit 3ef3c9efa9
3 changed files with 168 additions and 140 deletions
+7
View File
@@ -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), so nothing was changed"
)]
Tags,
#[error(transparent)]
Edit(#[from] pdfcraft_edit::EditError),
#[error(transparent)]
+78 -86
View File
@@ -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,13 +105,15 @@ 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. A tree too large to walk in full
/// could hide an element that repeats the redacted text, so then every element in the document
/// loses its alternate and actual text instead (fail-closed, #3302).
/// [`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,
@@ -117,7 +121,7 @@ pub(crate) fn clean_within(
changed: &HashSet<i64>,
empty: &HashSet<i64>,
limit: usize,
) -> Result<usize, pdfcraft_cos::CosError> {
) -> Result<usize, RedactError> {
if changed.is_empty() && stream.is_none() {
return Ok(0);
}
@@ -135,24 +139,36 @@ pub(crate) fn clean_within(
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) {
continue;
}
if seen.len() > limit {
let n = clear_every_element(doc)?;
if stream.is_some() {
// Marked-content references the walk didn't reach still name the
// original stream through /Stm, which a full save would then keep.
drop_tree(doc)?;
}
return Ok(n);
return Err(RedactError::Tags);
}
let object = doc.get(r);
if let Object::Array(a) = &*object {
@@ -165,14 +181,11 @@ pub(crate) fn clean_within(
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).or_else(|| inherited_page(doc, &d));
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 {
@@ -234,57 +247,49 @@ pub(crate) fn clean_within(
Ok(n)
}
/// The fail-closed end of a walk that couldn't finish: every structure element in the document,
/// found by reading each object rather than through the tree, loses the text that could repeat
/// redacted words. Returns how many elements changed.
fn clear_every_element(doc: &mut Document) -> Result<usize, pdfcraft_cos::CosError> {
let keys = [&b"Alt"[..], b"ActualText", b"E"];
let elements: Vec<ObjRef> = doc
.scan_objects()
.filter(|(_, o)| {
o.as_dict().is_some_and(|d| {
(d.name(b"Type") == Some(b"StructElem") || (d.contains(b"S") && d.contains(b"P"))) && keys.iter().any(|k| d.contains(k))
})
})
.map(|(r, _)| r)
.collect();
for &r in &elements {
doc.update_dict(r, |e| {
for key in keys {
e.remove(key);
}
})?;
}
Ok(elements.len())
}
/// The page an element inherits (§14.7.4.4): the nearest `/Pg` among its ancestors, following
/// `/P` at most [`MAX_PARENTS`] steps.
fn inherited_page(doc: &Document, d: &Dict) -> Option<ObjRef> {
/// `/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);
for _ in 0..MAX_PARENTS {
let p = doc.get(parent?);
let p = p.as_dict()?;
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) {
return Some(pg);
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(),
}
None
}
/// The most ancestors [`inherited_page`] looks through; deeper trees, and cycles, go unanswered.
const MAX_PARENTS: usize = 256;
/// Remove the structure tree, with `/MarkInfo`, which would claim tags there no longer are.
fn drop_tree(doc: &mut Document) -> Result<(), pdfcraft_cos::CosError> {
let Some(catalog) = doc.root() else { return Ok(()) };
doc.update_dict(catalog, |c| {
c.remove(b"StructTreeRoot");
c.remove(b"MarkInfo");
})
}
/// 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)
@@ -305,20 +310,21 @@ fn tree_node_entries(d: &Dict) -> Vec<Object> {
///
/// 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 the whole tree goes instead (fail-closed).
/// 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, pdfcraft_cos::CosError> {
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);
}
@@ -337,19 +343,12 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
while let Some(k) = todo.pop() {
match k {
Object::Array(a) => todo.extend(a),
// A structure element written in place: its kids, and if it tags a removed
// annotation, the whole tree (see above).
// 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: 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)) {
drop_tree(doc)?;
return Ok(0);
return Err(RedactError::Tags);
}
todo.extend(kids);
}
@@ -360,8 +359,7 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
continue;
}
if seen.len() > limit {
drop_tree(doc)?;
return Ok(0);
return Err(RedactError::Tags);
}
let object = doc.get(r);
if let Object::Array(a) = &*object {
@@ -374,13 +372,7 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
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);
}
+83 -54
View File
@@ -810,11 +810,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(),
@@ -835,11 +836,8 @@ 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"));
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");
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
@@ -868,11 +866,10 @@ fn an_element_listed_only_in_the_parent_tree_loses_its_redacted_text() {
assert!(!element.contains(b"ActualText") && !element.contains(b"Alt"), "{element:?}");
}
/// #3302: a structure tree too large to walk in full fails closed: every element loses its
/// alternate and actual text, found by reading each object, instead of the walk stopping and
/// reporting success with elements never checked.
/// #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_clears_every_element() {
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(),
@@ -886,17 +883,12 @@ fn a_tag_walk_cut_short_clears_every_element() {
let page = ObjRef::new(3, 0);
let changed: std::collections::HashSet<i64> = [0].into();
let mut doc = pdf(objs.clone());
crate::tags::clean_within(&mut doc, page, None, &changed, &Default::default(), 100).unwrap();
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);
let n = crate::tags::clean_within(&mut doc, page, None, &changed, &Default::default(), 1).unwrap();
assert_eq!(n, 2, "both elements with text change");
for r in [7, 8] {
let e = doc.get(ObjRef::new(r, 0)).as_dict().cloned().unwrap();
assert!(!e.contains(b"ActualText") && !e.contains(b"Alt") && !e.contains(b"E"), "{r}: {e:?}");
}
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
@@ -957,37 +949,11 @@ fn an_element_inheriting_its_page_loses_its_redacted_text_when_the_parent_tree_l
}
}
/// #3285 review: when the tag walk is cut short while redaction replaced a form's stream, the
/// marked-content references it never reached still name the original stream through /Stm, so
/// the tree goes, as it does for removed annotations. Without a replaced stream it stays.
#[test]
fn a_tag_walk_cut_short_over_a_replaced_stream_removes_the_tree() {
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] /StructParents 0 >>".to_vec(),
stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", b"/P <</MCID 0>> BDC EMC"),
b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(),
b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R] >>".to_vec(),
b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K << /Type /MCR /MCID 0 /Stm 4 0 R >> >>".to_vec(),
stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", b""),
];
let page = ObjRef::new(3, 0);
let streams = Some((ObjRef::new(4, 0), ObjRef::new(8, 0)));
let has_tree = |doc: &Document| doc.get(doc.root().unwrap()).as_dict().is_some_and(|c| c.contains(b"StructTreeRoot") && c.contains(b"MarkInfo"));
let mut doc = pdf(objs.clone());
crate::tags::clean_within(&mut doc, page, None, &[0].into(), &Default::default(), 1).unwrap();
assert!(has_tree(&doc), "without a replaced stream the tree stays");
let mut doc = pdf(objs);
crate::tags::clean_within(&mut doc, page, streams, &Default::default(), &Default::default(), 1).unwrap();
assert!(!has_tree(&doc), "a cut-short walk over a replaced stream drops the tree");
}
/// #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 the tree goes
/// instead of keeping the annotation reachable in a full rewrite.
/// 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.
#[test]
fn an_element_written_in_place_that_tags_a_removed_annotation_removes_the_tree() {
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(),
@@ -998,11 +964,74 @@ fn an_element_written_in_place_that_tags_a_removed_annotation_removes_the_tree()
];
let gone: std::collections::HashSet<ObjRef> = [ObjRef::new(4, 0)].into();
let mut doc = pdf(objs);
crate::tags::drop_object_refs(&mut doc, &gone).unwrap();
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
assert!(!catalog.contains(b"StructTreeRoot") && !catalog.contains(b"MarkInfo"), "{catalog:?}");
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
assert!(!bytes.windows(8).any(|w| w == b"hushhush"), "the removed comment can't be reached any more");
assert!(matches!(crate::tags::drop_object_refs(&mut doc, &gone), Err(RedactError::Tags)));
}
/// 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.
#[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]], "");
assert!(matches!(apply(&mut doc, None), Err(RedactError::Tags)));
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