use crate::error::{RedactError, Result};
use crate::redact::{RedactionArea, Redactor};
use lopdf::{Document, Object, ObjectId};
use regex::Regex;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub struct RedactSearchOptions {
pub case_sensitive: bool,
pub regex: bool,
pub fill_color: [f64; 3],
pub pages: Option<Vec<u32>>,
pub overlay_text: Option<String>,
}
impl Default for RedactSearchOptions {
fn default() -> Self {
Self {
case_sensitive: true,
regex: false,
fill_color: [0.0, 0.0, 0.0],
pages: None,
overlay_text: None,
}
}
}
impl RedactSearchOptions {
pub fn exact(pattern: &str) -> Self {
let _ = pattern; Self::default()
}
pub fn case_insensitive() -> Self {
Self {
case_sensitive: false,
..Self::default()
}
}
pub fn with_regex() -> Self {
Self {
regex: true,
..Self::default()
}
}
pub fn fill_color(mut self, r: f64, g: f64, b: f64) -> Self {
self.fill_color = [r, g, b];
self
}
pub fn pages(mut self, pages: Vec<u32>) -> Self {
self.pages = Some(pages);
self
}
pub fn overlay_text(mut self, text: impl Into<String>) -> Self {
self.overlay_text = Some(text.into());
self
}
}
#[derive(Debug, Clone)]
pub struct SearchRedactReport {
pub matches_found: usize,
pub areas_redacted: usize,
pub operations_removed: usize,
pub pages_affected: usize,
pub metadata_cleaned: bool,
pub pages_unreadable: Vec<u32>,
pub redacted_rects: Vec<(u32, [f64; 4])>,
}
fn page_contents_resolve(doc: &Document, page_id: ObjectId) -> bool {
let Ok(dict) = doc.get_dictionary(page_id) else {
return false;
};
let contents = match dict.get(b"Contents") {
Ok(c) => c,
Err(_) => return true,
};
let mut verwijzingen = Vec::new();
match contents {
Object::Reference(id) => verwijzingen.push(*id),
Object::Array(items) => {
for item in items {
if let Object::Reference(id) = item {
verwijzingen.push(*id);
}
}
}
_ => return true,
}
verwijzingen
.into_iter()
.all(|id| doc.get_object(id).is_ok())
}
pub fn search_and_redact(
doc: &mut Document,
pattern: &str,
options: &RedactSearchOptions,
) -> Result<SearchRedactReport> {
let pages = doc.get_pages();
let total = pages.len() as u32;
let page_range: Vec<u32> = match &options.pages {
Some(ps) => ps.clone(),
None => (1..=total).collect(),
};
for &p in &page_range {
if p == 0 || p > total {
return Err(RedactError::PageOutOfRange(p, total));
}
}
crate::redact::refuse_xfa(doc)?;
let matcher = build_matcher(pattern, options)?;
let mut all_areas: Vec<RedactionArea> = Vec::new();
let mut pages_unreadable: Vec<u32> = Vec::new();
let mut total_matches = 0;
let mut page_bboxes: std::collections::HashMap<u32, Vec<[f64; 4]>> =
std::collections::HashMap::new();
for &page_num in &page_range {
if let Some(&page_id) = pages.get(&page_num) {
if !page_contents_resolve(doc, page_id) {
pages_unreadable.push(page_num);
continue;
}
}
if let Some(&page_id) = pages.get(&page_num) {
let resources =
crate::redact::resolve_inherited_resources_clone(doc, page_id).unwrap_or_default();
crate::area_filter::check_page_decodes(doc, page_num, page_id, &resources)?;
}
let chars = match pdf_extract::extract_positioned_chars(doc, page_num) {
Ok(c) => c,
Err(_) => {
pages_unreadable.push(page_num);
continue;
}
};
if chars.is_empty() {
continue;
}
let text: String = chars.iter().map(|c| c.ch).collect();
let byte_to_char: Vec<usize> = {
let mut map = Vec::with_capacity(text.len() + 1);
for (ci, ch) in text.chars().enumerate() {
for _ in 0..ch.len_utf8() {
map.push(ci);
}
}
map.push(chars.len()); map
};
let match_ranges = matcher.find_all(&text);
for range in &match_ranges {
total_matches += 1;
let char_start = byte_to_char.get(range.start).copied().unwrap_or(0);
let char_end = byte_to_char.get(range.end).copied().unwrap_or(chars.len());
if char_start >= chars.len() || char_end > chars.len() || char_start >= char_end {
continue;
}
let matched_chars = &chars[char_start..char_end];
if matched_chars.is_empty() {
continue;
}
let bbox = compute_bounding_rect(matched_chars);
page_bboxes.entry(page_num).or_default().push(bbox);
let mut area = RedactionArea::new(page_num, bbox);
area = area.with_color(
options.fill_color[0],
options.fill_color[1],
options.fill_color[2],
);
if let Some(ref overlay) = options.overlay_text {
area = area.with_overlay(overlay);
}
all_areas.push(area);
}
}
if all_areas.is_empty() {
let hits = std::cell::Cell::new(0usize);
let mut work = doc.clone();
crate::sidecar::scrub_sidecar_text(
&mut work,
&|text: &str| {
let found: Vec<_> = matcher
.find_all(text)
.into_iter()
.map(|m| m.start..m.end)
.collect();
if found.iter().any(|r| r.start < r.end) {
hits.set(hits.get() + 1);
}
found
},
crate::redact::MINIMAL_XMP,
)?;
if hits.get() > 0 {
*doc = work;
}
return Ok(SearchRedactReport {
pages_unreadable: pages_unreadable.clone(),
matches_found: 0,
areas_redacted: 0,
operations_removed: 0,
pages_affected: 0,
metadata_cleaned: false,
redacted_rects: Vec::new(),
});
}
let redacted_rects: Vec<(u32, [f64; 4])> = all_areas.iter().map(|a| (a.page, a.rect)).collect();
let mut redactor = Redactor::new();
redactor.mark_all(all_areas);
let mut work = doc.clone();
#[cfg(test)]
let skip_removal = tests::SKIP_REMOVAL.with(|c| c.get());
#[cfg(not(test))]
let skip_removal = false;
let report = if skip_removal {
crate::redact::RedactionReport {
areas_redacted: 0,
operations_removed: 0,
pages_affected: 0,
metadata_cleaned: false,
}
} else {
redactor.apply(&mut work)?
};
let mut extra_ops_removed = 0;
for &page_num in &page_range {
if skip_removal {
break;
}
let bboxes: &[[f64; 4]] = page_bboxes
.get(&page_num)
.map(|v| v.as_slice())
.unwrap_or(&[]);
let removed = remove_text_ops_for_page(&mut work, page_num, pattern, options, bboxes)?;
extra_ops_removed += removed;
}
let overlay_matches = options
.overlay_text
.as_deref()
.is_some_and(|t| !matcher.find_all(t).is_empty());
if !overlay_matches {
for &page_num in &page_range {
if pages_unreadable.contains(&page_num) {
continue;
}
if let Ok(chars) = pdf_extract::extract_positioned_chars(&work, page_num) {
let text: String = chars.iter().map(|c| c.ch).collect();
if !matcher.find_all(&text).is_empty() {
return Err(RedactError::Other(format!(
"the searched text is still extractable on page {page_num} after \
redaction; redaction refused instead of reporting success"
)));
}
}
}
}
crate::sidecar::scrub_sidecar_text(
&mut work,
&|text: &str| {
matcher
.find_all(text)
.into_iter()
.map(|m| m.start..m.end)
.collect()
},
crate::redact::MINIMAL_XMP,
)?;
*doc = work;
Ok(SearchRedactReport {
pages_unreadable: pages_unreadable.clone(),
matches_found: total_matches,
areas_redacted: report.areas_redacted,
operations_removed: report.operations_removed + extra_ops_removed,
pages_affected: report.pages_affected,
metadata_cleaned: report.metadata_cleaned,
redacted_rects,
})
}
struct TextMatcher {
regex: Regex,
}
struct MatchRange {
start: usize,
end: usize,
}
impl TextMatcher {
fn find_all(&self, text: &str) -> Vec<MatchRange> {
self.regex
.find_iter(text)
.map(|m| MatchRange {
start: m.start(),
end: m.end(),
})
.collect()
}
}
fn build_matcher(pattern: &str, options: &RedactSearchOptions) -> Result<TextMatcher> {
let regex_pattern = if options.regex {
if options.case_sensitive {
pattern.to_string()
} else {
format!("(?i){}", pattern)
}
} else {
let escaped = regex::escape(pattern);
if options.case_sensitive {
escaped
} else {
format!("(?i){}", escaped)
}
};
let regex = Regex::new(®ex_pattern)
.map_err(|e| RedactError::Other(format!("invalid pattern: {e}")))?;
Ok(TextMatcher { regex })
}
fn run_overlaps_single_bbox(run: &pdf_manip::text_run::TextRun, bbox: [f64; 4]) -> bool {
let run_x1 = run.x + run.width.max(1.0);
let tol = 4.0_f64;
let x_overlap = run.x < bbox[2] + tol && run_x1 > bbox[0] - tol;
let y_overlap = run.y <= bbox[3] + tol && run.y >= bbox[1] - tol;
x_overlap && y_overlap
}
fn run_on_same_baseline(run: &pdf_manip::text_run::TextRun, bbox: [f64; 4]) -> bool {
let same_y = (run.y - bbox[1]).abs() <= 0.5;
let run_x1 = run.x + run.width.max(1.0);
let x_overlap = run.x < bbox[2] + 4.0 && run_x1 > bbox[0] - 4.0;
same_y && x_overlap
}
fn raw_text_from_op(op: &lopdf::content::Operation) -> Option<String> {
use lopdf::Object;
match op.operator.as_str() {
"Tj" | "'" => {
if let Some(Object::String(ref bytes, _)) = op.operands.first() {
Some(bytes.iter().map(|&b| b as char).collect())
} else {
None
}
}
"TJ" => {
if let Some(Object::Array(ref arr)) = op.operands.first() {
let s: String = arr
.iter()
.filter_map(|item| match item {
Object::String(ref bytes, _) => {
Some(bytes.iter().map(|&b| b as char).collect::<String>())
}
_ => None,
})
.collect();
if s.is_empty() {
None
} else {
Some(s)
}
} else {
None
}
}
"\"" => op.operands.get(2).and_then(|obj| match obj {
Object::String(ref bytes, _) => Some(bytes.iter().map(|&b| b as char).collect()),
_ => None,
}),
_ => None,
}
}
fn apply_per_bbox_combined_fallback(
runs: &[pdf_manip::text_run::TextRun],
indices_to_remove: &mut Vec<usize>,
bboxes: &[[f64; 4]],
ops: &[lopdf::content::Operation],
matcher: &TextMatcher,
) {
let text_matched: HashSet<usize> = indices_to_remove.iter().copied().collect();
let mut to_add: HashSet<usize> = HashSet::new();
let op_to_y: HashMap<usize, f64> = runs
.iter()
.flat_map(|run| run.ops_range.clone().map(move |i| (i, run.y)))
.collect();
for &bbox in bboxes {
let covered = runs.iter().any(|run| {
run_on_same_baseline(run, bbox)
&& run.ops_range.clone().any(|i| text_matched.contains(&i))
});
if covered {
continue;
}
let bbox_y = bbox[1];
let mut y_line: Vec<(usize, &lopdf::content::Operation)> = ops
.iter()
.enumerate()
.filter(|(idx, _)| {
op_to_y
.get(idx)
.map(|&y| (y - bbox_y).abs() <= 6.0)
.unwrap_or(false)
})
.collect();
y_line.sort_by_key(|(idx, _)| *idx);
let mut combined = String::new();
let mut byte_to_op: Vec<usize> = Vec::new();
for &(idx, op) in &y_line {
if let Some(raw) = raw_text_from_op(op) {
let before = combined.len(); combined.push_str(&raw);
byte_to_op.extend(std::iter::repeat_n(idx, combined.len() - before));
}
}
let raw_matches = matcher.find_all(&combined);
if !raw_matches.is_empty() {
for m in &raw_matches {
for i in m.start..m.end {
if let Some(&op_idx) = byte_to_op.get(i) {
if !text_matched.contains(&op_idx) {
to_add.insert(op_idx);
}
}
}
}
continue; }
for run in runs {
if run_overlaps_single_bbox(run, bbox) {
for idx in run.ops_range.clone() {
to_add.insert(idx);
}
}
}
}
for idx in to_add {
if !text_matched.contains(&idx) {
indices_to_remove.push(idx);
}
}
}
fn compute_bounding_rect(chars: &[pdf_extract::PositionedChar]) -> [f64; 4] {
let mut x0 = f64::MAX;
let mut y0 = f64::MAX;
let mut x1 = f64::MIN;
let mut y1 = f64::MIN;
for ch in chars {
x0 = x0.min(ch.bbox[0]);
y0 = y0.min(ch.bbox[1]);
x1 = x1.max(ch.bbox[2]);
y1 = y1.max(ch.bbox[3]);
}
[x0 - 1.0, y0 - 1.0, x1 + 1.0, y1 + 1.0]
}
fn remove_text_ops_for_page(
doc: &mut Document,
page_num: u32,
pattern: &str,
options: &RedactSearchOptions,
match_bboxes: &[[f64; 4]],
) -> Result<usize> {
let fonts = match pdf_manip::text_run::FontMap::from_page(doc, page_num) {
Ok(f) => f,
Err(_) => return Ok(0),
};
let matcher = build_matcher(pattern, options)?;
let mut visited: HashSet<ObjectId> = HashSet::new();
let removed = match pdf_manip::content_editor::editor_for_page(doc, page_num) {
Ok(editor) => {
remove_text_ops_via_editor(doc, page_num, editor, &matcher, &fonts, match_bboxes)?
}
Err(_) => {
remove_text_ops_with_inline_images(doc, page_num, &matcher, &fonts, match_bboxes)?
}
};
let removed = removed
+ remove_text_ops_from_xobjects(
doc,
page_num,
&matcher,
&fonts,
match_bboxes,
&mut visited,
)?;
let removed = removed
+ remove_text_ops_from_annotations(
doc,
page_num,
&matcher,
&fonts,
match_bboxes,
&mut visited,
)?;
Ok(removed)
}
fn remove_text_ops_via_editor(
doc: &mut Document,
page_num: u32,
editor: pdf_manip::content_editor::ContentEditor,
matcher: &TextMatcher,
fonts: &pdf_manip::text_run::FontMap,
match_bboxes: &[[f64; 4]],
) -> Result<usize> {
let runs = pdf_manip::text_run::extract_text_runs(&editor, fonts);
let mut indices_to_remove: Vec<usize> = Vec::new();
for run in &runs {
if !matcher.find_all(&run.text).is_empty() {
for idx in run.ops_range.clone() {
indices_to_remove.push(idx);
}
}
}
if !match_bboxes.is_empty() {
apply_per_bbox_combined_fallback(
&runs,
&mut indices_to_remove,
match_bboxes,
editor.operations(),
matcher,
);
}
if !match_bboxes.is_empty() {
let mut text_matched_set: HashSet<usize> = indices_to_remove.iter().cloned().collect();
let op_to_y: HashMap<usize, f64> = runs
.iter()
.flat_map(|run| run.ops_range.clone().map(move |i| (i, run.y)))
.collect();
let ops = editor.operations();
let mut ops_by_y: HashMap<i64, Vec<usize>> = HashMap::new();
for (idx, _) in ops.iter().enumerate() {
if let Some(&y) = op_to_y.get(&idx) {
let y_bucket = (y * 10.0).round() as i64;
ops_by_y.entry(y_bucket).or_default().push(idx);
}
}
for (_, mut op_indices) in ops_by_y {
op_indices.sort();
let mut combined = String::new();
let mut byte_to_op: Vec<usize> = Vec::new();
for &idx in &op_indices {
if text_matched_set.contains(&idx) {
continue;
}
if let Some(raw) = raw_text_from_op(&ops[idx]) {
let before = combined.len();
combined.push_str(&raw);
byte_to_op.extend(std::iter::repeat_n(idx, combined.len() - before));
}
}
if combined.is_empty() {
continue;
}
let matches = matcher.find_all(&combined);
for m in matches {
for i in m.start..m.end {
if let Some(&op_idx) = byte_to_op.get(i) {
if !text_matched_set.contains(&op_idx) {
indices_to_remove.push(op_idx);
text_matched_set.insert(op_idx);
}
}
}
}
}
}
if indices_to_remove.is_empty() {
return Ok(0);
}
indices_to_remove.sort_unstable();
indices_to_remove.dedup();
let mut new_editor = editor;
for &idx in indices_to_remove.iter().rev() {
new_editor.remove_range(idx..idx + 1);
}
let removed = indices_to_remove.len();
pdf_manip::content_editor::write_editor_to_page(doc, page_num, &new_editor)
.map_err(|e| RedactError::Other(format!("write content: {e}")))?;
Ok(removed)
}
fn remove_text_ops_with_inline_images(
doc: &mut Document,
page_num: u32,
matcher: &TextMatcher,
fonts: &pdf_manip::text_run::FontMap,
match_bboxes: &[[f64; 4]],
) -> Result<usize> {
let pages = doc.get_pages();
let &page_id = match pages.get(&page_num) {
Some(id) => id,
None => return Ok(0),
};
let content_bytes = crate::area_filter::page_content_strict(doc, page_num, page_id)?;
let (stripped, inline_images) = pdf_manip::content_editor::strip_inline_images(&content_bytes);
let editor = match pdf_manip::content_editor::ContentEditor::from_stream(&stripped) {
Ok(e) => e,
Err(_) => return Ok(0),
};
let runs = pdf_manip::text_run::extract_text_runs(&editor, fonts);
let mut indices_to_remove: Vec<usize> = Vec::new();
for run in &runs {
if !matcher.find_all(&run.text).is_empty() {
for idx in run.ops_range.clone() {
indices_to_remove.push(idx);
}
}
}
if !match_bboxes.is_empty() {
apply_per_bbox_combined_fallback(
&runs,
&mut indices_to_remove,
match_bboxes,
editor.operations(),
matcher,
);
}
if !match_bboxes.is_empty() {
let mut text_matched_set: HashSet<usize> = indices_to_remove.iter().cloned().collect();
let ops = editor.operations();
for (idx, op) in ops.iter().enumerate() {
if text_matched_set.contains(&idx) {
continue;
}
if let Some(raw_text) = raw_text_from_op(op) {
if !matcher.find_all(&raw_text).is_empty() {
indices_to_remove.push(idx);
text_matched_set.insert(idx);
}
}
}
}
if indices_to_remove.is_empty() {
return Ok(0);
}
indices_to_remove.sort_unstable();
indices_to_remove.dedup();
let mut new_editor = editor;
for &idx in indices_to_remove.iter().rev() {
new_editor.remove_range(idx..idx + 1);
}
let removed = indices_to_remove.len();
let re_encoded = new_editor
.encode()
.map_err(|e| RedactError::Other(format!("encode: {e}")))?;
let mut final_content = Vec::new();
for img in &inline_images {
final_content.extend_from_slice(img);
final_content.push(b'\n');
}
final_content.extend_from_slice(&re_encoded);
let compressed = {
let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
use std::io::Write as _;
if enc.write_all(&final_content).is_ok() {
enc.finish().unwrap_or_else(|_| final_content.clone())
} else {
final_content.clone()
}
};
let (stream_bytes, use_flate) = if compressed.len() < final_content.len() {
(compressed, true)
} else {
(final_content, false)
};
let content_ids = pdf_manip::content_editor::get_content_stream_ids(doc, page_id);
if let Some(&first_id) = content_ids.first() {
if let Ok(Object::Stream(ref mut s)) = doc.get_object_mut(first_id) {
s.content = stream_bytes;
if use_flate {
s.dict.set("Filter", Object::Name(b"FlateDecode".to_vec()));
} else {
s.dict.remove(b"Filter");
}
s.dict
.set("Length", Object::Integer(s.content.len() as i64));
}
if content_ids.len() > 1 {
if let Ok(Object::Dictionary(ref mut page_dict)) = doc.get_object_mut(page_id) {
page_dict.set("Contents", Object::Reference(first_id));
}
}
}
Ok(removed)
}
fn remove_text_ops_from_xobjects(
doc: &mut Document,
page_num: u32,
matcher: &TextMatcher,
fonts: &pdf_manip::text_run::FontMap,
match_bboxes: &[[f64; 4]],
visited: &mut HashSet<ObjectId>,
) -> Result<usize> {
let pages = doc.get_pages();
let &page_id = match pages.get(&page_num) {
Some(id) => id,
None => return Ok(0),
};
let xobject_ids = collect_form_xobject_ids(doc, page_id);
if xobject_ids.is_empty() {
return Ok(0);
}
let mut total_removed = 0;
for xobj_id in xobject_ids {
total_removed +=
remove_text_ops_from_stream(doc, xobj_id, matcher, fonts, match_bboxes, visited)?;
}
Ok(total_removed)
}
fn remove_text_ops_from_annotations(
doc: &mut Document,
page_num: u32,
matcher: &TextMatcher,
fonts: &pdf_manip::text_run::FontMap,
match_bboxes: &[[f64; 4]],
visited: &mut HashSet<ObjectId>,
) -> Result<usize> {
let pages = doc.get_pages();
let &page_id = match pages.get(&page_num) {
Some(id) => id,
None => return Ok(0),
};
let ap_stream_ids = collect_annotation_appearance_ids(doc, page_id);
if ap_stream_ids.is_empty() {
return Ok(0);
}
let mut total_removed = 0;
for stream_id in ap_stream_ids {
total_removed +=
remove_text_ops_from_stream(doc, stream_id, matcher, fonts, match_bboxes, visited)?;
}
Ok(total_removed)
}
fn remove_text_ops_from_stream(
doc: &mut Document,
stream_id: ObjectId,
matcher: &TextMatcher,
page_fonts: &pdf_manip::text_run::FontMap,
match_bboxes: &[[f64; 4]],
visited: &mut HashSet<ObjectId>,
) -> Result<usize> {
if !visited.insert(stream_id) {
return Ok(0);
}
let content_bytes = match doc.get_object(stream_id) {
Ok(Object::Stream(ref s)) => {
let mut stream = s.clone();
let _ = stream.decompress();
stream.content.clone()
}
_ => return Ok(0),
};
let editor = match pdf_manip::content_editor::ContentEditor::from_stream(&content_bytes) {
Ok(e) => e,
Err(_) => return Ok(0),
};
let stream_fonts =
pdf_manip::text_run::FontMap::from_xobject_stream(doc, stream_id, page_fonts);
let fonts = &stream_fonts;
let runs = pdf_manip::text_run::extract_text_runs(&editor, fonts);
let mut indices_to_remove: Vec<usize> = Vec::new();
for run in &runs {
if !matcher.find_all(&run.text).is_empty() {
for idx in run.ops_range.clone() {
indices_to_remove.push(idx);
}
}
}
if !match_bboxes.is_empty() {
apply_per_bbox_combined_fallback(
&runs,
&mut indices_to_remove,
match_bboxes,
editor.operations(),
matcher,
);
}
if !match_bboxes.is_empty() {
let mut text_matched_set: HashSet<usize> = indices_to_remove.iter().cloned().collect();
let op_to_y: HashMap<usize, f64> = runs
.iter()
.flat_map(|run| run.ops_range.clone().map(move |i| (i, run.y)))
.collect();
let ops = editor.operations();
let mut ops_by_y: HashMap<i64, Vec<usize>> = HashMap::new();
for (idx, _) in ops.iter().enumerate() {
if let Some(&y) = op_to_y.get(&idx) {
let y_bucket = (y * 10.0).round() as i64;
ops_by_y.entry(y_bucket).or_default().push(idx);
}
}
for (_, mut op_indices) in ops_by_y {
op_indices.sort();
let mut combined = String::new();
let mut byte_to_op: Vec<usize> = Vec::new();
for &idx in &op_indices {
if text_matched_set.contains(&idx) {
continue;
}
if let Some(raw) = raw_text_from_op(&ops[idx]) {
let before = combined.len();
combined.push_str(&raw);
byte_to_op.extend(std::iter::repeat_n(idx, combined.len() - before));
}
}
if combined.is_empty() {
continue;
}
let matches = matcher.find_all(&combined);
for m in matches {
for i in m.start..m.end {
if let Some(&op_idx) = byte_to_op.get(i) {
if !text_matched_set.contains(&op_idx) {
indices_to_remove.push(op_idx);
text_matched_set.insert(op_idx);
}
}
}
}
}
}
if indices_to_remove.is_empty() {
let nested_ids = collect_nested_form_xobjects(doc, stream_id);
let mut nested_removed = 0;
for nested_id in nested_ids {
nested_removed +=
remove_text_ops_from_stream(doc, nested_id, matcher, fonts, match_bboxes, visited)?;
}
return Ok(nested_removed);
}
indices_to_remove.sort_unstable();
indices_to_remove.dedup();
let mut new_editor = editor;
for &idx in indices_to_remove.iter().rev() {
new_editor.remove_range(idx..idx + 1);
}
let removed = indices_to_remove.len();
let encoded = new_editor
.encode()
.map_err(|e| RedactError::Other(format!("encode annotation stream: {e}")))?;
if let Ok(Object::Stream(ref mut s)) = doc.get_object_mut(stream_id) {
s.dict.remove(b"Filter");
s.content = encoded;
s.dict
.set("Length", Object::Integer(s.content.len() as i64));
}
let nested_ids = collect_nested_form_xobjects(doc, stream_id);
let mut nested_removed = removed;
for nested_id in nested_ids {
nested_removed +=
remove_text_ops_from_stream(doc, nested_id, matcher, fonts, match_bboxes, visited)?;
}
Ok(nested_removed)
}
fn collect_annotation_appearance_ids(doc: &Document, page_id: ObjectId) -> Vec<ObjectId> {
let mut result = Vec::new();
let page_dict = match doc.get_object(page_id) {
Ok(Object::Dictionary(ref d)) => d.clone(),
_ => return result,
};
let annots = match page_dict.get(b"Annots") {
Ok(Object::Array(ref arr)) => arr.clone(),
Ok(Object::Reference(id)) => match doc.get_object(*id) {
Ok(Object::Array(ref arr)) => arr.clone(),
_ => return result,
},
_ => return result,
};
for annot_ref in &annots {
let annot_id = match annot_ref {
Object::Reference(id) => *id,
_ => continue,
};
let annot_dict = match doc.get_object(annot_id) {
Ok(Object::Dictionary(ref d)) => d.clone(),
_ => continue,
};
let ap_dict = match annot_dict.get(b"AP") {
Ok(Object::Dictionary(ref d)) => d.clone(),
Ok(Object::Reference(id)) => match doc.get_object(*id) {
Ok(Object::Dictionary(ref d)) => d.clone(),
_ => continue,
},
_ => continue,
};
match ap_dict.get(b"N") {
Ok(Object::Reference(id)) => {
result.push(*id);
}
Ok(Object::Dictionary(ref d)) => {
for (_key, val) in d.iter() {
if let Object::Reference(id) = val {
result.push(*id);
}
}
}
_ => {}
}
}
result
}
fn collect_nested_form_xobjects(doc: &Document, stream_id: ObjectId) -> Vec<ObjectId> {
let mut result = Vec::new();
let stream_dict = match doc.get_object(stream_id) {
Ok(Object::Stream(ref s)) => s.dict.clone(),
_ => return result,
};
let resources = match stream_dict.get(b"Resources") {
Ok(Object::Dictionary(ref d)) => d.clone(),
Ok(Object::Reference(id)) => match doc.get_object(*id) {
Ok(Object::Dictionary(ref d)) => d.clone(),
_ => return result,
},
_ => return result,
};
let xobject_dict = match resources.get(b"XObject") {
Ok(Object::Dictionary(ref d)) => d.clone(),
Ok(Object::Reference(id)) => match doc.get_object(*id) {
Ok(Object::Dictionary(ref d)) => d.clone(),
_ => return result,
},
_ => return result,
};
for (_key, value) in xobject_dict.iter() {
let obj_id = match value {
Object::Reference(id) => *id,
_ => continue,
};
if let Ok(Object::Stream(ref s)) = doc.get_object(obj_id) {
let is_form = s
.dict
.get(b"Subtype")
.ok()
.and_then(|v| match v {
Object::Name(ref n) => Some(n.as_slice()),
_ => None,
})
.map(|n| n == b"Form")
.unwrap_or(false);
if is_form {
result.push(obj_id);
}
}
}
result
}
fn collect_form_xobject_ids(doc: &Document, page_id: ObjectId) -> Vec<ObjectId> {
let mut result = Vec::new();
let page_dict = match doc.get_object(page_id) {
Ok(Object::Dictionary(ref d)) => d.clone(),
_ => return result,
};
let resources = match page_dict.get(b"Resources") {
Ok(Object::Dictionary(ref d)) => d.clone(),
Ok(Object::Reference(id)) => match doc.get_object(*id) {
Ok(Object::Dictionary(ref d)) => d.clone(),
_ => return result,
},
_ => return result,
};
let xobject_dict = match resources.get(b"XObject") {
Ok(Object::Dictionary(ref d)) => d.clone(),
Ok(Object::Reference(id)) => match doc.get_object(*id) {
Ok(Object::Dictionary(ref d)) => d.clone(),
_ => return result,
},
_ => return result,
};
for (_key, value) in xobject_dict.iter() {
let obj_id = match value {
Object::Reference(id) => *id,
_ => continue,
};
if let Ok(Object::Stream(ref s)) = doc.get_object(obj_id) {
let is_form = s
.dict
.get(b"Subtype")
.ok()
.and_then(|v| match v {
Object::Name(ref n) => Some(n.as_slice()),
_ => None,
})
.map(|n| n == b"Form")
.unwrap_or(false);
if is_form {
result.push(obj_id);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use lopdf::{dictionary, Document, Object, Stream};
thread_local! {
pub(super) static SKIP_REMOVAL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
#[test]
fn post_check_refuses_when_the_match_survives_and_leaves_the_document_alone() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret Data) Tj ET");
let mut before = Vec::new();
doc.clone().save_to(&mut before).unwrap();
SKIP_REMOVAL.with(|c| c.set(true));
let res = search_and_redact(&mut doc, "Secret", &RedactSearchOptions::default());
SKIP_REMOVAL.with(|c| c.set(false));
let err = res.expect_err("a surviving match must be refused");
assert!(
matches!(&err, RedactError::Other(m) if m.contains("still extractable")),
"wrong error: {err}"
);
let mut after = Vec::new();
doc.save_to(&mut after).unwrap();
assert_eq!(before, after, "the caller's document must be unchanged");
}
fn make_doc_with_text(content: &[u8]) -> Document {
let mut doc = Document::with_version("1.7");
let font = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
};
let font_id = doc.add_object(Object::Dictionary(font));
let font_resources = dictionary! {
"F1" => Object::Reference(font_id),
};
let resources = dictionary! {
"Font" => Object::Dictionary(font_resources),
};
let content_stream = Stream::new(dictionary! {}, content.to_vec());
let content_id = doc.add_object(Object::Stream(content_stream));
let page_dict = dictionary! {
"Type" => "Page",
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => Object::Reference(content_id),
"Resources" => Object::Dictionary(resources),
};
let page_id = doc.add_object(Object::Dictionary(page_dict));
let pages_dict = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages_dict));
if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
d.set("Parent", Object::Reference(pages_id));
}
let info = dictionary! {
"Title" => Object::String(b"Test".to_vec(), lopdf::StringFormat::Literal),
};
let info_id = doc.add_object(Object::Dictionary(info));
doc.trailer.set("Info", Object::Reference(info_id));
let catalog = dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
let catalog_id = doc.add_object(Object::Dictionary(catalog));
doc.trailer.set("Root", Object::Reference(catalog_id));
doc
}
#[test]
fn search_and_redact_exact_match() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret Data) Tj ET");
let opts = RedactSearchOptions::default();
let report = search_and_redact(&mut doc, "Secret", &opts).unwrap();
assert!(report.matches_found >= 1);
assert!(report.areas_redacted >= 1);
}
#[test]
fn search_and_redact_no_match() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET");
let opts = RedactSearchOptions::default();
let report = search_and_redact(&mut doc, "Missing", &opts).unwrap();
assert_eq!(report.matches_found, 0);
assert_eq!(report.areas_redacted, 0);
}
#[test]
fn search_and_redact_case_insensitive() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret Data) Tj ET");
let opts = RedactSearchOptions::case_insensitive();
let report = search_and_redact(&mut doc, "secret", &opts).unwrap();
assert!(report.matches_found >= 1);
}
#[test]
fn search_and_redact_regex() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (SSN 123-45-6789) Tj ET");
let opts = RedactSearchOptions::with_regex();
let report = search_and_redact(&mut doc, r"\d{3}-\d{2}-\d{4}", &opts).unwrap();
assert!(report.matches_found >= 1);
}
#[test]
fn search_and_redact_with_overlay() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Confidential) Tj ET");
let opts = RedactSearchOptions::default().overlay_text("[REDACTED]");
let report = search_and_redact(&mut doc, "Confidential", &opts).unwrap();
assert!(report.matches_found >= 1);
}
#[test]
fn search_and_redact_specific_pages() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
let opts = RedactSearchOptions::default().pages(vec![1]);
let report = search_and_redact(&mut doc, "Secret", &opts).unwrap();
assert!(report.matches_found >= 1);
}
#[test]
fn search_and_redact_page_out_of_range() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Hello) Tj ET");
let opts = RedactSearchOptions::default().pages(vec![5]);
let result = search_and_redact(&mut doc, "Hello", &opts);
assert!(result.is_err());
}
#[test]
fn search_and_redact_cleans_metadata() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
let opts = RedactSearchOptions::default();
let report = search_and_redact(&mut doc, "Secret", &opts).unwrap();
assert!(report.metadata_cleaned);
assert!(doc.trailer.get(b"Info").is_err());
}
#[test]
fn search_and_redact_custom_color() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
let opts = RedactSearchOptions::default().fill_color(1.0, 0.0, 0.0);
let report = search_and_redact(&mut doc, "Secret", &opts).unwrap();
assert!(report.matches_found >= 1);
}
fn make_doc_with_xobject_text() -> (Document, ObjectId) {
let mut doc = Document::with_version("1.7");
let xobj_font = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Times-Roman",
};
let xobj_font_id = doc.add_object(Object::Dictionary(xobj_font));
let xobj_font_res = dictionary! { "FX" => Object::Reference(xobj_font_id) };
let xobj_resources = dictionary! {
"Font" => Object::Dictionary(xobj_font_res),
};
let xobj_content = b"BT /FX 12 Tf 0 0 Td (Classified) Tj ET".to_vec();
let xobj_stream = Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => "Form",
"BBox" => vec![0.into(), 0.into(), 300_i64.into(), 20_i64.into()],
"Resources" => Object::Dictionary(xobj_resources),
},
xobj_content,
);
let xobj_id = doc.add_object(Object::Stream(xobj_stream));
let page_font = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
};
let page_font_id = doc.add_object(Object::Dictionary(page_font));
let page_font_res = dictionary! { "F1" => Object::Reference(page_font_id) };
let xobj_map = dictionary! { "Xobj1" => Object::Reference(xobj_id) };
let page_resources = dictionary! {
"Font" => Object::Dictionary(page_font_res),
"XObject" => Object::Dictionary(xobj_map),
};
let page_content = b"q 1 0 0 1 100 700 cm /Xobj1 Do Q".to_vec();
let content_stream = Stream::new(dictionary! {}, page_content);
let content_id = doc.add_object(Object::Stream(content_stream));
let page_dict = dictionary! {
"Type" => "Page",
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => Object::Reference(content_id),
"Resources" => Object::Dictionary(page_resources),
};
let page_id = doc.add_object(Object::Dictionary(page_dict));
let pages_dict = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages_dict));
if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
d.set("Parent", Object::Reference(pages_id));
}
let catalog = dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
let catalog_id = doc.add_object(Object::Dictionary(catalog));
doc.trailer.set("Root", Object::Reference(catalog_id));
(doc, xobj_id)
}
#[test]
fn redact_removes_text_from_xobject_stream() {
let (mut doc, xobj_id) = make_doc_with_xobject_text();
let page_fonts = pdf_manip::text_run::FontMap::empty();
let matcher_opts = RedactSearchOptions::default();
let matcher = build_matcher("Classified", &matcher_opts).unwrap();
let removed = remove_text_ops_from_stream(
&mut doc,
xobj_id,
&matcher,
&page_fonts,
&[],
&mut HashSet::new(),
)
.unwrap();
assert!(
removed > 0,
"Expected at least one op removed from XObject stream, got 0"
);
if let Ok(Object::Stream(ref s)) = doc.get_object(xobj_id) {
let content = std::str::from_utf8(&s.content).unwrap_or("");
assert!(
!content.contains("Classified"),
"XObject stream still contains 'Classified' after redaction"
);
} else {
panic!("XObject is not a stream after redaction");
}
}
#[test]
fn redact_split_token_per_bbox_spatial_fallback() {
let content = b"BT /F1 12 Tf 0 700 Td (ALICE) Tj 200 0 Td (LI) Tj 30 0 Td (C) Tj ET";
let mut doc = make_doc_with_text(content);
let opts = RedactSearchOptions::default();
let report = search_and_redact(&mut doc, "LIC", &opts).unwrap();
assert!(report.matches_found >= 1);
assert!(report.areas_redacted >= 1);
}
#[test]
fn redact_xobject_raw_byte_fallback() {
let (mut doc, xobj_id) = make_doc_with_xobject_text();
let page_fonts = pdf_manip::text_run::FontMap::empty();
let matcher_opts = RedactSearchOptions::default();
let matcher = build_matcher("Classified", &matcher_opts).unwrap();
let dummy_bboxes = [[0.0_f64, 0.0, 300.0, 20.0]];
let removed = remove_text_ops_from_stream(
&mut doc,
xobj_id,
&matcher,
&page_fonts,
&dummy_bboxes,
&mut HashSet::new(),
)
.unwrap();
assert!(
removed > 0,
"Expected raw-byte fallback to remove ops from XObject stream"
);
}
}