use super::edit::{leaf_pages, mediabox_of, page_annots, page_id_of, redact_annotation_areas};
use super::PageMut;
use crate::error::{Error, Result};
use lopdf::{Dictionary, Document as LopdfDocument, Object, ObjectId};
const BOX_TOLERANCE: f64 = 0.5;
type Pending = (usize, Vec<Dictionary>);
fn unmappable(why: String) -> Error {
Error::Unsupported(format!(
"xfa-redaction-annotations-unmappable: {why}. Nothing was redacted and the document \
is unchanged. Flatten the form first (PdfDocument::redact and redact_region do it \
for you), then mark the areas on the flattened pages and apply them."
))
}
impl PageMut<'_> {
pub(super) fn apply_redactions_to_xfa_form(&mut self, index: usize) -> Result<bool> {
let pending = redact_annotations_by_page(self.document().lopdf());
if !pending.iter().any(|(page, _)| *page == index) {
return Ok(false);
}
let mut work = self.document().xfa_flattened_copy()?;
carry_redact_annotations(self.document().lopdf(), &mut work, &pending)?;
let page_id = page_id_of(&work, index)?;
let (areas, victims) = redact_annotation_areas(&work, page_id, index)?;
if areas.is_empty() {
return Err(Error::Unsupported(
"xfa-redaction-annotations-unmappable: the carried Redact annotations \
are not on the flattened page; nothing was redacted"
.to_string(),
));
}
let mut redactor = pdf_redact::Redactor::new();
redactor.mark_all(areas);
let report = redactor.apply(&mut work)?;
remove_annotations(&mut work, page_id, &victims)?;
for id in &victims {
work.objects.remove(id);
}
self.document_mut().adopt_redacted(work)?;
Ok(report.areas_redacted > 0)
}
}
fn redact_annotations_by_page(doc: &LopdfDocument) -> Vec<Pending> {
let mut out = Vec::new();
for (index, page_id) in leaf_pages(doc).into_iter().enumerate() {
let dicts: Vec<Dictionary> = page_annots(doc, page_id)
.into_iter()
.filter_map(|id| doc.get_dictionary(id).ok())
.filter(|d| is_redact(d))
.cloned()
.collect();
if !dicts.is_empty() {
out.push((index, dicts));
}
}
out
}
fn is_redact(dict: &Dictionary) -> bool {
dict.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok())
.is_some_and(|n| n == b"Redact")
}
fn carry_redact_annotations(
original: &LopdfDocument,
work: &mut LopdfDocument,
pending: &[Pending],
) -> Result<()> {
let before = leaf_pages(original);
let after = leaf_pages(work);
if before.len() != after.len() {
return Err(unmappable(format!(
"the form has {} page(s) and its flattened form has {}",
before.len(),
after.len()
)));
}
for (index, dicts) in pending {
let a = Geometry::of(original, before[*index]);
let b = Geometry::of(work, after[*index]);
if !a.same_as(&b) {
return Err(unmappable(format!(
"page {} carries {} Redact annotation(s) and changes size or rotation when \
flattened ({} against {})",
index + 1,
dicts.len(),
a.describe(),
b.describe()
)));
}
}
for (index, dicts) in pending {
for dict in dicts {
let id = work.add_object(Object::Dictionary(without_references(dict)));
append_annotation(work, after[*index], id)?;
}
}
Ok(())
}
struct Geometry {
media: [f64; 4],
crop: [f64; 4],
rotate: i64,
}
impl Geometry {
fn of(doc: &LopdfDocument, page_id: ObjectId) -> Self {
let media = normalised(mediabox_of(doc, page_id));
let crop = inherited(doc, page_id, b"CropBox")
.and_then(|o| rect_of(&o))
.map(normalised)
.unwrap_or(media);
let rotate = inherited(doc, page_id, b"Rotate")
.and_then(|o| match o {
Object::Integer(n) => Some(n),
Object::Real(r) => Some(r as i64),
_ => None,
})
.unwrap_or(0)
.rem_euclid(360);
Self {
media,
crop,
rotate,
}
}
fn same_as(&self, other: &Self) -> bool {
let close = |a: &[f64; 4], b: &[f64; 4]| {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= BOX_TOLERANCE)
};
close(&self.media, &other.media)
&& close(&self.crop, &other.crop)
&& self.rotate == other.rotate
}
fn describe(&self) -> String {
format!(
"{:.1} x {:.1} pt, rotation {}",
self.crop[2] - self.crop[0],
self.crop[3] - self.crop[1],
self.rotate
)
}
}
fn normalised(r: [f64; 4]) -> [f64; 4] {
[
r[0].min(r[2]),
r[1].min(r[3]),
r[0].max(r[2]),
r[1].max(r[3]),
]
}
fn number(o: &Object) -> Option<f64> {
match o {
Object::Integer(i) => Some(*i as f64),
Object::Real(r) => Some(f64::from(*r)),
_ => None,
}
}
fn rect_of(o: &Object) -> Option<[f64; 4]> {
let arr = o.as_array().ok()?;
if arr.len() != 4 {
return None;
}
Some([
number(&arr[0])?,
number(&arr[1])?,
number(&arr[2])?,
number(&arr[3])?,
])
}
fn inherited(doc: &LopdfDocument, page_id: ObjectId, key: &[u8]) -> Option<Object> {
let mut current = Some(page_id);
for _ in 0..64 {
let id = current?;
let dict = doc.get_dictionary(id).ok()?;
if let Ok(obj) = dict.get(key) {
return Some(doc.dereference(obj).map(|(_, o)| o).unwrap_or(obj).clone());
}
current = dict.get(b"Parent").ok().and_then(|o| o.as_reference().ok());
}
None
}
fn without_references(dict: &Dictionary) -> Dictionary {
fn holds_reference(o: &Object) -> bool {
match o {
Object::Reference(_) => true,
Object::Array(items) => items.iter().any(holds_reference),
Object::Dictionary(d) => d.iter().any(|(_, v)| holds_reference(v)),
Object::Stream(s) => s.dict.iter().any(|(_, v)| holds_reference(v)),
_ => false,
}
}
let mut out = Dictionary::new();
for (key, value) in dict.iter() {
if !holds_reference(value) {
out.set(key.clone(), value.clone());
}
}
out
}
fn append_annotation(doc: &mut LopdfDocument, page_id: ObjectId, annot: ObjectId) -> Result<()> {
let mut items = annots_array(doc, page_id);
items.push(Object::Reference(annot));
doc.get_dictionary_mut(page_id)?
.set("Annots", Object::Array(items));
Ok(())
}
fn remove_annotations(doc: &mut LopdfDocument, page_id: ObjectId, gone: &[ObjectId]) -> Result<()> {
let kept: Vec<Object> = annots_array(doc, page_id)
.into_iter()
.filter(|o| !matches!(o, Object::Reference(id) if gone.contains(id)))
.collect();
let page = doc.get_dictionary_mut(page_id)?;
if kept.is_empty() {
page.remove(b"Annots");
} else {
page.set("Annots", Object::Array(kept));
}
Ok(())
}
fn annots_array(doc: &LopdfDocument, page_id: ObjectId) -> Vec<Object> {
doc.get_dictionary(page_id)
.ok()
.and_then(|d| d.get(b"Annots").ok())
.map(|o| doc.dereference(o).map(|(_, o)| o).unwrap_or(o))
.and_then(|o| o.as_array().ok())
.cloned()
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use lopdf::dictionary;
fn doc_with_pages(sizes: &[(i64, i64, i64)]) -> (LopdfDocument, Vec<ObjectId>) {
let mut doc = LopdfDocument::with_version("1.7");
let pages_id = doc.new_object_id();
let mut ids = Vec::new();
for (w, h, rot) in sizes {
let mut page = dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), (*w).into(), (*h).into()],
};
if *rot != 0 {
page.set("Rotate", *rot);
}
ids.push(doc.add_object(page));
}
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => ids.iter().map(|i| Object::Reference(*i)).collect::<Vec<_>>(),
"Count" => ids.len() as i64,
}),
);
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog);
(doc, ids)
}
fn add_redact(doc: &mut LopdfDocument, page: ObjectId, rect: [i64; 4]) -> ObjectId {
let popup = doc.add_object(dictionary! { "Type" => "Annot", "Subtype" => "Popup" });
let annot = doc.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "Redact",
"Rect" => rect.iter().map(|v| Object::Integer(*v)).collect::<Vec<_>>(),
"IC" => vec![Object::Real(1.0), Object::Real(0.0), Object::Real(0.0)],
"Popup" => popup,
"P" => page,
});
let mut items = annots_array(doc, page);
items.push(Object::Reference(annot));
doc.get_dictionary_mut(page)
.unwrap()
.set("Annots", Object::Array(items));
annot
}
fn redact_count(doc: &LopdfDocument, page: ObjectId) -> usize {
page_annots(doc, page)
.into_iter()
.filter(|id| doc.get_dictionary(*id).is_ok_and(is_redact))
.count()
}
#[test]
fn annotations_of_every_page_are_carried_and_stay_pending() {
let (mut before, b) = doc_with_pages(&[(612, 792, 0), (612, 792, 0), (595, 842, 0)]);
add_redact(&mut before, b[0], [10, 20, 30, 40]);
add_redact(&mut before, b[0], [50, 60, 70, 80]);
add_redact(&mut before, b[2], [1, 2, 3, 4]);
let (mut after, a) = doc_with_pages(&[(612, 792, 0), (612, 792, 0), (595, 842, 0)]);
let pending = redact_annotations_by_page(&before);
assert_eq!(
pending
.iter()
.map(|(i, d)| (*i, d.len()))
.collect::<Vec<_>>(),
[(0, 2), (2, 1)]
);
carry_redact_annotations(&before, &mut after, &pending).expect("same pages map");
assert_eq!(redact_count(&after, a[0]), 2);
assert_eq!(redact_count(&after, a[1]), 0);
assert_eq!(
redact_count(&after, a[2]),
1,
"a page not applied now stays pending"
);
let first = page_annots(&after, a[0])[0];
let rect = after.get_dictionary(first).unwrap().get(b"Rect").unwrap();
assert_eq!(rect_of(rect), Some([10.0, 20.0, 30.0, 40.0]));
assert!(is_redact(after.get_dictionary(first).unwrap()));
}
#[test]
fn references_are_not_carried_into_a_document_where_they_mean_something_else() {
let (mut before, b) = doc_with_pages(&[(612, 792, 0)]);
add_redact(&mut before, b[0], [10, 20, 30, 40]);
let (mut after, a) = doc_with_pages(&[(612, 792, 0)]);
let pending = redact_annotations_by_page(&before);
carry_redact_annotations(&before, &mut after, &pending).unwrap();
let carried = after.get_dictionary(page_annots(&after, a[0])[0]).unwrap();
assert!(!carried.has(b"P"), "/P would name an unrelated object");
assert!(!carried.has(b"Popup"));
assert!(carried.has(b"IC"), "values that hold no reference stay");
assert!(carried.has(b"Rect"));
}
#[test]
fn a_different_page_count_is_refused_and_leaves_the_copy_alone() {
let (mut before, b) = doc_with_pages(&[(612, 792, 0)]);
add_redact(&mut before, b[0], [10, 20, 30, 40]);
let (mut after, a) = doc_with_pages(&[(612, 792, 0), (612, 792, 0), (612, 792, 0)]);
let pending = redact_annotations_by_page(&before);
let err = carry_redact_annotations(&before, &mut after, &pending).unwrap_err();
let text = err.to_string();
assert!(
text.contains("xfa-redaction-annotations-unmappable"),
"{text}"
);
assert!(
text.contains("1 page(s)") && text.contains("has 3"),
"{text}"
);
assert!(text.contains("unchanged"), "{text}");
for page in a {
assert_eq!(redact_count(&after, page), 0, "a refusal carries nothing");
}
}
#[test]
fn a_page_that_changes_size_or_rotation_is_refused_by_name() {
let (mut before, b) = doc_with_pages(&[(612, 792, 0), (612, 792, 0)]);
add_redact(&mut before, b[1], [10, 20, 30, 40]);
let (mut resized, r) = doc_with_pages(&[(612, 792, 0), (595, 842, 0)]);
let pending = redact_annotations_by_page(&before);
let err = carry_redact_annotations(&before, &mut resized, &pending).unwrap_err();
assert!(err.to_string().contains("page 2"), "{err}");
assert_eq!(redact_count(&resized, r[1]), 0);
let (mut rotated, _) = doc_with_pages(&[(612, 792, 0), (612, 792, 90)]);
let err = carry_redact_annotations(&before, &mut rotated, &pending).unwrap_err();
assert!(err.to_string().contains("rotation 90"), "{err}");
let (mut other, _) = doc_with_pages(&[(595, 842, 0), (612, 792, 0)]);
carry_redact_annotations(&before, &mut other, &pending)
.expect("an unannotated page is free to differ");
}
#[test]
fn half_a_point_of_rounding_is_the_same_page() {
let (mut before, b) = doc_with_pages(&[(612, 792, 0)]);
add_redact(&mut before, b[0], [10, 20, 30, 40]);
let (mut after, a) = doc_with_pages(&[(612, 792, 0)]);
after.get_dictionary_mut(a[0]).unwrap().set(
"MediaBox",
vec![
Object::Real(0.0),
Object::Real(0.0),
Object::Real(612.4),
Object::Real(791.7),
],
);
let pending = redact_annotations_by_page(&before);
carry_redact_annotations(&before, &mut after, &pending).expect("within tolerance");
after.get_dictionary_mut(a[0]).unwrap().set(
"MediaBox",
vec![
Object::Real(0.0),
Object::Real(0.0),
Object::Real(613.0),
Object::Real(792.0),
],
);
let mut again = after.clone();
assert!(carry_redact_annotations(&before, &mut again, &pending).is_err());
}
#[test]
fn annotations_that_are_not_redact_are_not_pending() {
let (mut before, b) = doc_with_pages(&[(612, 792, 0)]);
let note = before.add_object(dictionary! { "Type" => "Annot", "Subtype" => "Text" });
before
.get_dictionary_mut(b[0])
.unwrap()
.set("Annots", vec![Object::Reference(note)]);
assert!(redact_annotations_by_page(&before).is_empty());
}
#[test]
fn removing_the_victims_keeps_the_other_annotations() {
let (mut doc, p) = doc_with_pages(&[(612, 792, 0)]);
let keep = doc.add_object(dictionary! { "Type" => "Annot", "Subtype" => "Text" });
let gone = add_redact(&mut doc, p[0], [1, 2, 3, 4]);
let mut items = annots_array(&doc, p[0]);
items.insert(0, Object::Reference(keep));
items.push(Object::Dictionary(dictionary! { "Subtype" => "Link" }));
doc.get_dictionary_mut(p[0])
.unwrap()
.set("Annots", Object::Array(items));
remove_annotations(&mut doc, p[0], &[gone]).unwrap();
let left = annots_array(&doc, p[0]);
assert_eq!(left.len(), 2);
assert!(matches!(left[0], Object::Reference(id) if id == keep));
assert!(matches!(left[1], Object::Dictionary(_)));
}
}