mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 05:42:52 +00:00
fix(redact): apply marks with an indirect /Subtype and judge inline annotations (#5849)
* fix(redact): apply marks with an indirect /Subtype and judge inline annotations A Redact annotation whose /Subtype is an indirect reference was passed over by apply although the audit, which resolves it, reports it as a mark: the other marks applied and the content under that one stayed. Annotation dictionaries written in place in /Annots were kept whatever they covered, and an inline reply or pop-up kept a removed comment reachable through /IRT or /Parent. /Subtype is now resolved wherever redaction classifies an annotation. Inline entries are judged by their rectangle and relationships like referenced ones, on the marked page and across pages, and the pop-up an annotation names (/Popup) goes with it. * address review: an inline widget's /Parent field goes even when no referenced widget names it * fmt
This commit is contained in:
1 parent
2c39b1d625
commit
a23b7c7f73
2 files changed
+289
-53
No files matched your search
+174
-53
@@ -247,6 +247,19 @@ 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()
|
||||
}
|
||||
|
||||
/// An annotation's `/Subtype`, resolved: it may be an indirect reference (ISO 32000-2 §7.3.10),
|
||||
/// and the audit reads it that way, so a mark it reports must not be one [`apply`] passes over.
|
||||
pub(crate) fn subtype(doc: &Document, d: &Dict) -> Option<Vec<u8>> {
|
||||
d.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec))
|
||||
}
|
||||
|
||||
/// The pop-up `d` names (`/Popup`), when it is one: it goes with `d`. Anything else it might name
|
||||
/// (a mark on another page, say) is left alone.
|
||||
fn named_popup(doc: &Document, d: &Dict) -> Option<ObjRef> {
|
||||
let r = d.reference(b"Popup")?;
|
||||
doc.get(r).as_dict().is_some_and(|p| subtype(doc, p).as_deref() == Some(b"Popup")).then_some(r)
|
||||
}
|
||||
|
||||
/// Every annotation the pages list. Compared before and after a removal, what is no longer listed
|
||||
/// leaves the structure tree too ([`tags::drop_object_refs`]).
|
||||
pub(crate) fn listed_annotations(doc: &Document) -> HashSet<ObjRef> {
|
||||
@@ -267,14 +280,14 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec<Mark>, Vec<usize>) {
|
||||
let Some(r) = a.as_ref() else {
|
||||
// A mark written in place has no object to find, apply or remove: refuse it
|
||||
// rather than apply the others as if it were not there.
|
||||
if doc.resolve(&a).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Redact")) {
|
||||
if doc.resolve(&a).as_dict().is_some_and(|d| subtype(doc, d).as_deref() == Some(b"Redact")) {
|
||||
unreadable.push(pi);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let obj = doc.get(r);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
if d.name(b"Subtype") != Some(b"Redact") {
|
||||
if subtype(doc, d).as_deref() != Some(b"Redact") {
|
||||
continue;
|
||||
}
|
||||
let mut rects: Vec<[f64; 4]> = quad_points(doc, d)
|
||||
@@ -580,18 +593,41 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co
|
||||
let annots = annots_of(doc, &p.dict);
|
||||
let mut kept = Vec::with_capacity(annots.len());
|
||||
for a in &annots {
|
||||
let Some(r) = a.as_ref() else {
|
||||
kept.push(a.clone());
|
||||
continue;
|
||||
};
|
||||
let obj = doc.get(r);
|
||||
// An entry written in place is read as one listed by reference: its `/IRT` or
|
||||
// `/Parent` keeps a removed comment reachable all the same, and it has its own text.
|
||||
let obj = doc.resolve(a);
|
||||
let Some(d) = obj.as_dict() else {
|
||||
kept.push(a.clone());
|
||||
continue;
|
||||
};
|
||||
let is_reply =
|
||||
d.get(b"IRT").and_then(Object::as_ref).is_some_and(|x| removed_everywhere.contains(&x)) && d.name(b"Subtype") != Some(b"Widget");
|
||||
let is_popup = d.get(b"Parent").and_then(Object::as_ref).is_some_and(|x| removed_everywhere.contains(&x));
|
||||
let is_reply = d.reference(b"IRT").is_some_and(|x| removed_everywhere.contains(&x)) && subtype(doc, d).as_deref() != Some(b"Widget");
|
||||
let is_popup = d.reference(b"Parent").is_some_and(|x| removed_everywhere.contains(&x));
|
||||
let Some(r) = a.as_ref() else {
|
||||
if is_reply || is_popup {
|
||||
grew = true;
|
||||
if is_reply {
|
||||
report.annotations += 1;
|
||||
}
|
||||
// Its appearance can be shared with an annotation that stays elsewhere.
|
||||
if !appearance_scan_limit {
|
||||
match appearance_refs(doc, d) {
|
||||
Some(references) => removed_appearances.extend(references),
|
||||
None => appearance_scan_limit = true,
|
||||
}
|
||||
if removed_appearances.len() > 16_384 {
|
||||
appearance_scan_limit = true;
|
||||
removed_appearances.clear();
|
||||
}
|
||||
}
|
||||
// It has no reference to remove elsewhere; the pop-up it names has.
|
||||
if let Some(p) = named_popup(doc, d) {
|
||||
removed_everywhere.insert(p);
|
||||
}
|
||||
} else {
|
||||
kept.push(a.clone());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if removed_everywhere.contains(&r) || is_reply || is_popup {
|
||||
// A reply or pop-up removed here can share its appearance with an annotation
|
||||
// that stays on an unselected page, like one removed under a mark: the
|
||||
@@ -638,6 +674,30 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co
|
||||
report.fields += 1;
|
||||
}
|
||||
}
|
||||
// A widget written in place on another page names its deleted field through `/Parent`, which
|
||||
// would keep the field and its value in the file: it goes with the field.
|
||||
if !doomed_fields.is_empty() {
|
||||
for p in pdfcraft_model::pages(doc) {
|
||||
let annots = annots_of(doc, &p.dict);
|
||||
let kept: Vec<Object> = annots
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
a.as_ref().is_some() || !matches!(&**a, Object::Dict(d) if d.reference(b"Parent").is_some_and(|f| doomed_fields.contains(&f)))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if kept.len() != annots.len() {
|
||||
report.annotations += annots.len() - kept.len();
|
||||
doc.update_dict(p.obj, |d| {
|
||||
if kept.is_empty() {
|
||||
d.remove(b"Annots");
|
||||
} else {
|
||||
d.set(b"Annots".to_vec(), Object::Array(kept));
|
||||
}
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3a. A tagged PDF's structure tree still points at the removed comments and widgets
|
||||
// (`/OBJR`, #2874): drop those references, or a full rewrite keeps them reachable, the
|
||||
@@ -885,30 +945,66 @@ fn remove_page_annotations(
|
||||
page_marks: &[&Mark],
|
||||
) -> Result<(), RedactError> {
|
||||
let Applying { report, doomed_fields, widget_owner, removed_everywhere, removed_appearances, appearance_scan_limit, .. } = applying;
|
||||
// 2. Annotations: the marks, and whatever lies under them (with their pop-ups).
|
||||
// 2. Annotations: the marks, and whatever lies under them (with their pop-ups). An entry
|
||||
// written in place (a dictionary in `/Annots`, not a reference) is judged like any other: its
|
||||
// `/Contents` and `/IRT` stay in the file for as long as the page lists it.
|
||||
let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default());
|
||||
/// What decides an entry's fate, read once.
|
||||
struct Entry {
|
||||
r: Option<ObjRef>,
|
||||
widget: bool,
|
||||
irt: Option<ObjRef>,
|
||||
parent: Option<ObjRef>,
|
||||
popup: Option<ObjRef>,
|
||||
}
|
||||
let entries: Vec<Option<Entry>> = annots
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let obj = doc.resolve(a);
|
||||
let d = obj.as_dict()?;
|
||||
Some(Entry {
|
||||
r: a.as_ref(),
|
||||
widget: subtype(doc, d).as_deref() == Some(b"Widget"),
|
||||
irt: d.reference(b"IRT"),
|
||||
parent: d.reference(b"Parent"),
|
||||
popup: named_popup(doc, d),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut removed: Vec<ObjRef> = Vec::new();
|
||||
for a in &annots {
|
||||
let Some(r) = a.as_ref() else { continue };
|
||||
let obj = doc.get(r);
|
||||
// Entries written in place that go, by position in `/Annots`.
|
||||
let mut inline_gone = vec![false; annots.len()];
|
||||
for (i, a) in annots.iter().enumerate() {
|
||||
let obj = doc.resolve(a);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let subtype = d.name(b"Subtype").unwrap_or(b"");
|
||||
if subtype == b"Redact" {
|
||||
if page_marks.iter().any(|m| m.obj == r) {
|
||||
let r = a.as_ref();
|
||||
let kind = subtype(doc, d).unwrap_or_default();
|
||||
if kind == b"Redact" {
|
||||
// A mark written in place was refused before anything changed.
|
||||
if let Some(r) = r
|
||||
&& page_marks.iter().any(|m| m.obj == r)
|
||||
{
|
||||
removed.push(r);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if subtype == b"Popup" {
|
||||
if kind == b"Popup" {
|
||||
continue;
|
||||
}
|
||||
if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) {
|
||||
removed.push(r);
|
||||
if subtype == b"Widget" {
|
||||
if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r)
|
||||
&& !doomed_fields.contains(field)
|
||||
match r {
|
||||
Some(r) => removed.push(r),
|
||||
None => inline_gone[i] = true,
|
||||
}
|
||||
if kind == b"Widget" {
|
||||
// A widget written in place has no reference of its own; its `/Parent` is the field,
|
||||
// even one no referenced widget belongs to (it then has no entry in `widget_owner`).
|
||||
// Step 3 deletes only what the form lists as a field, so any `/Parent` is safe here.
|
||||
let owner = r.and_then(|r| widget_owner.iter().find(|(w, _)| *w == r).map(|(_, f)| *f)).or_else(|| d.reference(b"Parent"));
|
||||
if let Some(field) = owner
|
||||
&& !doomed_fields.contains(&field)
|
||||
{
|
||||
doomed_fields.push(*field);
|
||||
doomed_fields.push(field);
|
||||
}
|
||||
} else {
|
||||
report.annotations += 1;
|
||||
@@ -919,29 +1015,25 @@ fn remove_page_annotations(
|
||||
// those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints
|
||||
// of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at
|
||||
// least one more annotation of this page or stops, so it ends.
|
||||
let replies: Vec<(ObjRef, ObjRef)> = annots
|
||||
.iter()
|
||||
.filter_map(Object::as_ref)
|
||||
.filter_map(|r| {
|
||||
let obj = doc.get(r);
|
||||
let d = obj.as_dict()?;
|
||||
(d.name(b"Subtype") != Some(b"Widget")).then_some(())?;
|
||||
Some((r, d.get(b"IRT").and_then(Object::as_ref)?))
|
||||
})
|
||||
.collect();
|
||||
loop {
|
||||
let before = removed.len();
|
||||
for (r, parent) in &replies {
|
||||
if !removed.contains(r) && removed.contains(parent) {
|
||||
removed.push(*r);
|
||||
report.annotations += 1;
|
||||
let mut grew = false;
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let Some(e) = e else { continue };
|
||||
let gone = e.r.map_or(inline_gone[i], |r| removed.contains(&r));
|
||||
if gone || e.widget || !e.irt.is_some_and(|p| removed.contains(&p)) {
|
||||
continue;
|
||||
}
|
||||
match e.r {
|
||||
Some(r) => removed.push(r),
|
||||
None => inline_gone[i] = true,
|
||||
}
|
||||
report.annotations += 1;
|
||||
grew = true;
|
||||
}
|
||||
if removed.len() == before {
|
||||
if !grew {
|
||||
break;
|
||||
}
|
||||
}
|
||||
removed_everywhere.extend(removed.iter().copied());
|
||||
// A removed annotation's appearance streams can be shared with a kept one on another page,
|
||||
// which would keep a pruned pattern's cell reachable: remember them for the pattern guard.
|
||||
for reference in &removed {
|
||||
@@ -961,24 +1053,53 @@ fn remove_page_annotations(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pop-ups dropped with their parent below count too: a reply on another page may answer one.
|
||||
for a in &annots {
|
||||
let Some(r) = a.as_ref() else { continue };
|
||||
if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) {
|
||||
removed_everywhere.insert(r);
|
||||
// An entry written in place that goes keeps no reference, but its appearances count as well.
|
||||
for (i, a) in annots.iter().enumerate() {
|
||||
if *appearance_scan_limit {
|
||||
break;
|
||||
}
|
||||
if let (true, Some(annotation)) = (inline_gone[i], doc.resolve(a).as_dict()) {
|
||||
match appearance_refs(doc, annotation) {
|
||||
Some(references) => removed_appearances.extend(references),
|
||||
None => *appearance_scan_limit = true,
|
||||
}
|
||||
if removed_appearances.len() > 16_384 {
|
||||
*appearance_scan_limit = true;
|
||||
removed_appearances.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pop-ups go with their annotation: those that name it (`/Parent`), and those it names
|
||||
// (`/Popup`), the only link an annotation written in place can have to its pop-up.
|
||||
let mut going: HashSet<ObjRef> = removed.iter().copied().collect();
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let Some(e) = e else { continue };
|
||||
let gone = e.r.map_or(inline_gone[i], |r| removed.contains(&r));
|
||||
if gone {
|
||||
going.extend(e.popup);
|
||||
}
|
||||
}
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let Some(e) = e else { continue };
|
||||
if e.parent.is_some_and(|p| removed.contains(&p)) {
|
||||
match e.r {
|
||||
Some(r) => {
|
||||
going.insert(r);
|
||||
}
|
||||
None => inline_gone[i] = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pop-ups dropped with their parent count too: a reply on another page may answer one.
|
||||
removed_everywhere.extend(going.iter().copied());
|
||||
let kept: Vec<Object> = annots
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
let Some(r) = a.as_ref() else { return true };
|
||||
if removed.contains(&r) {
|
||||
return false;
|
||||
}
|
||||
// Pop-ups of removed annotations go too.
|
||||
let obj = doc.get(r);
|
||||
!obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p))
|
||||
.enumerate()
|
||||
.filter(|(i, a)| match a.as_ref() {
|
||||
Some(r) => !going.contains(&r),
|
||||
None => !inline_gone[*i],
|
||||
})
|
||||
.map(|(_, a)| a)
|
||||
.collect();
|
||||
doc.update_dict(page.obj, |d| {
|
||||
if kept.is_empty() {
|
||||
|
||||
@@ -6691,6 +6691,78 @@ fn a_type3_text_layer_is_redacted_like_any_other() {
|
||||
assert_eq!(under(&mut doc, 0, &[[98.0, 96.0, 112.0, 110.0]]), 0, "nothing is left under the mark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mark_whose_subtype_is_an_indirect_name_is_applied() {
|
||||
// `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10); the audit reads it that way.
|
||||
// Passing over the mark would leave the text under it while the others report success.
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 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] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [7 0 R] >>".to_vec(),
|
||||
stream("", b"BT /F1 10 Tf 20 20 Td (SECRET) Tj ET"),
|
||||
FONT.replace("95 0 R", "6 0 R").into_bytes(),
|
||||
widths(),
|
||||
b"<< /Type /Annot /Subtype 8 0 R /Rect [10 10 90 40] >>".to_vec(),
|
||||
b"/Redact".to_vec(),
|
||||
];
|
||||
let mut doc = pdf(objs);
|
||||
assert_eq!(marks(&doc).len(), 1, "the mark is found");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!(r.marks, 1);
|
||||
assert!(r.glyphs >= 6, "the text under the mark went: {r:?}");
|
||||
assert!(marks(&doc).is_empty(), "the mark itself went");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mark_written_in_place_with_an_indirect_subtype_is_refused() {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 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] /Contents 4 0 R /Annots [<< /Type /Annot /Subtype 5 0 R /Rect [10 10 70 40] >>] >>"
|
||||
.to_vec(),
|
||||
stream("", b"BT ET"),
|
||||
b"/Redact".to_vec(),
|
||||
];
|
||||
let mut doc = pdf(objs);
|
||||
mark(&mut doc, 0, &[[100.0, 100.0, 120.0, 120.0]], "");
|
||||
assert!(matches!(apply(&mut doc, None), Err(RedactError::UnreadableMark(1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotations_written_in_place_go_when_covered_or_hanging_off_a_removed_one() {
|
||||
// `/Annots` may hold dictionaries rather than references. One under the mark, a reply to a
|
||||
// removed highlight on the same page and on another, and a reply to that inline comment's
|
||||
// pop-up must all go; an inline comment elsewhere stays.
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [6 0 R \
|
||||
<< /Type /Annot /Subtype /Text /Rect [30 15 40 25] /Contents (INLINE-UNDER) /Popup 7 0 R >> \
|
||||
<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 6 0 R /Contents (INLINE-REPLY) >> \
|
||||
<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (INLINE-KEPT) >> 7 0 R] >>"
|
||||
.to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [\
|
||||
<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 6 0 R /Contents (OTHER-PAGE-INLINE-REPLY) >> \
|
||||
<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /IRT 7 0 R /Contents (POPUP-INLINE-REPLY) >>] >>"
|
||||
.to_vec(),
|
||||
stream("", b"BT ET"),
|
||||
b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Contents (INLINE-POPUP) >>".to_vec(),
|
||||
];
|
||||
let mut doc = pdf(objs);
|
||||
mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!(r.annotations, 5, "the highlight, the inline comment and the three replies: {r:?}");
|
||||
let pages = pdfcraft_model::pages(&doc);
|
||||
assert_eq!(annots_of(&doc, &pages[0].dict).len(), 1, "only the inline comment elsewhere stays");
|
||||
assert!(annots_of(&doc, &pages[1].dict).is_empty(), "the replies leave page 2");
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
for gone in [&b"HIGHLIGHT-NOTE"[..], b"INLINE-UNDER", b"INLINE-REPLY", b"OTHER-PAGE-INLINE-REPLY", b"POPUP-INLINE-REPLY", b"INLINE-POPUP"] {
|
||||
assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived the save", String::from_utf8_lossy(gone));
|
||||
}
|
||||
assert!(bytes.windows(11).any(|w| w == b"INLINE-KEPT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rewritten_form_xobject_drops_the_source_text_of_added_content() {
|
||||
// A form drawing invisible text whose dictionary repeats it in /PCAdded.
|
||||
@@ -6717,6 +6789,49 @@ fn a_redacted_form_xobject_drops_the_source_text_of_added_content() {
|
||||
assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inline_widget_takes_its_field_even_with_no_referenced_widget() {
|
||||
// The field's only widget is written in place in `/Annots`, so no referenced widget names it:
|
||||
// the field (and its value) must still go with the widget under the mark.
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 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] /Contents 4 0 R /Annots [<< /Type /Annot /Subtype /Widget /Rect [20 20 60 30] /Parent 6 0 R >>] >>"
|
||||
.to_vec(),
|
||||
stream("", b"BT ET"),
|
||||
b"<< >>".to_vec(),
|
||||
b"<< /FT /Tx /T (name) /V (FIELD-SECRET) >>".to_vec(),
|
||||
];
|
||||
let mut doc = pdf(objs);
|
||||
mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!(r.fields, 1, "{r:?}");
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
assert!(!bytes.windows(12).any(|w| w == b"FIELD-SECRET"), "the field value survived the save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inline_widget_of_the_field_on_another_page_goes_with_it() {
|
||||
// Two pages: the widget under the mark, and a sibling widget in place on the second page with the
|
||||
// same `/Parent`. Deleting the field must not leave the sibling naming it.
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [<< /Type /Annot /Subtype /Widget /Rect [20 20 60 30] /Parent 7 0 R >>] >>"
|
||||
.to_vec(),
|
||||
stream("", b"BT ET"),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [<< /Type /Annot /Subtype /Widget /Rect [200 200 260 230] /Parent 7 0 R >>] >>"
|
||||
.to_vec(),
|
||||
b"<< >>".to_vec(),
|
||||
b"<< /FT /Tx /T (name) /V (SIBLING-SECRET) >>".to_vec(),
|
||||
];
|
||||
let mut doc = pdf(objs);
|
||||
mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], "");
|
||||
apply(&mut doc, None).unwrap();
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
assert!(!bytes.windows(14).any(|w| w == b"SIBLING-SECRET"), "the field value survived through the sibling widget");
|
||||
}
|
||||
|
||||
/// Private data takes an image's prepress data too: its OPI link to the original file and its
|
||||
/// alternate images, which can be the unedited original.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user