use crate::error::{RedactError, Result};
use lopdf::content::{Content, Operation};
use lopdf::{dictionary, Document, Object, ObjectId, Stream};
use std::io::Write;
pub(crate) const MINIMAL_XMP: &[u8] = b"<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n <rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\">\n <pdf:Producer>pdfluent</pdf:Producer>\n </rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>";
#[derive(Debug, Clone)]
pub struct RedactionArea {
pub page: u32,
pub rect: [f64; 4],
pub fill_color: [f64; 3],
pub overlay_text: Option<String>,
}
impl RedactionArea {
pub fn new(page: u32, rect: [f64; 4]) -> Self {
Self {
page,
rect,
fill_color: [0.0, 0.0, 0.0],
overlay_text: None,
}
}
pub fn with_color(mut self, r: f64, g: f64, b: f64) -> Self {
self.fill_color = [r, g, b];
self
}
pub fn with_overlay(mut self, text: impl Into<String>) -> Self {
self.overlay_text = Some(text.into());
self
}
}
#[derive(Debug, Clone)]
pub struct RedactionReport {
pub areas_redacted: usize,
pub operations_removed: usize,
pub pages_affected: usize,
pub metadata_cleaned: bool,
}
#[derive(Debug)]
pub struct Redactor {
areas: Vec<RedactionArea>,
}
impl Redactor {
pub fn new() -> Self {
Self { areas: Vec::new() }
}
pub fn mark(&mut self, area: RedactionArea) {
self.areas.push(area);
}
pub fn mark_all(&mut self, areas: impl IntoIterator<Item = RedactionArea>) {
self.areas.extend(areas);
}
pub fn pending_count(&self) -> usize {
self.areas.len()
}
pub fn apply(&self, doc: &mut Document) -> Result<RedactionReport> {
if self.areas.is_empty() {
return Err(RedactError::NoAreas);
}
refuse_xfa(doc)?;
let pages = doc.get_pages();
let total = pages.len() as u32;
for area in &self.areas {
if area.page == 0 || area.page > total {
return Err(RedactError::PageOutOfRange(area.page, total));
}
}
let mut work = doc.clone();
let mut total_ops_removed = 0;
let mut affected_pages = std::collections::HashSet::new();
let mut orphans: Vec<ObjectId> = Vec::new();
let mut page_areas: std::collections::BTreeMap<u32, Vec<&RedactionArea>> =
std::collections::BTreeMap::new();
for area in &self.areas {
page_areas.entry(area.page).or_default().push(area);
}
for (&page_num, areas) in &page_areas {
let page_id = match pages.get(&page_num) {
Some(&id) => id,
None => continue,
};
let resources = resolve_inherited_resources_clone(&work, page_id).unwrap_or_default();
let (ops_removed, unused) =
crate::area_filter::redact_page(&mut work, page_num, page_id, resources, areas)?;
total_ops_removed += ops_removed;
orphans.extend(unused);
ensure_no_text_under(&work, page_num, areas)?;
draw_redaction_overlays(&mut work, page_id, areas)?;
strip_annotation_contents(&mut work, page_id, areas)?;
orphans.extend(crate::area_filter::redact_annotations(
&mut work, page_num, page_id, areas,
)?);
strip_tounicode_for_redacted_page(&mut work, page_id)?;
affected_pages.insert(page_num);
}
clean_metadata(&mut work);
crate::area_filter::drop_orphans(&mut work, &orphans);
crate::area_filter::drop_unreachable(&mut work);
*doc = work;
Ok(RedactionReport {
areas_redacted: self.areas.len(),
operations_removed: total_ops_removed,
pages_affected: affected_pages.len(),
metadata_cleaned: true,
})
}
}
impl Default for Redactor {
fn default() -> Self {
Self::new()
}
}
fn draw_redaction_overlays(
doc: &mut Document,
page_id: ObjectId,
areas: &[&RedactionArea],
) -> Result<()> {
wrap_existing_content_in_save_restore(doc, page_id);
let mut ops = Vec::new();
for area in areas {
let [r, g, b] = area.fill_color;
let [x0, y0, x1, y1] = area.rect;
let (tx0, ty0, tx1, ty1) = (x0, y0, x1, y1);
let w = tx1 - tx0;
let h = ty1 - ty0;
ops.push(Operation::new("q", vec![]));
ops.push(Operation::new(
"rg",
vec![
Object::Real(r as f32),
Object::Real(g as f32),
Object::Real(b as f32),
],
));
ops.push(Operation::new(
"re",
vec![
Object::Real(tx0 as f32),
Object::Real(ty0 as f32),
Object::Real(w as f32),
Object::Real(h as f32),
],
));
ops.push(Operation::new("f", vec![]));
if let Some(ref text) = area.overlay_text {
let max_font_size = h.abs() * 0.7;
let font_size = max_font_size.clamp(4.0, 12.0) as f32;
ops.push(Operation::new("BT", vec![]));
ops.push(Operation::new(
"rg",
vec![Object::Real(1.0), Object::Real(1.0), Object::Real(1.0)],
));
ops.push(Operation::new(
"Tf",
vec![Object::Name(b"Helvetica".to_vec()), Object::Real(font_size)],
));
ops.push(Operation::new(
"Td",
vec![
Object::Real((tx0 + 2.0) as f32),
Object::Real((ty0 + 2.0) as f32),
],
));
ops.push(Operation::new(
"Tj",
vec![Object::String(
text.as_bytes().to_vec(),
lopdf::StringFormat::Literal,
)],
));
ops.push(Operation::new("ET", vec![]));
}
ops.push(Operation::new("Q", vec![]));
}
let new_content = Content { operations: ops };
let encoded = new_content
.encode()
.map_err(|e| RedactError::Other(format!("failed to encode overlay: {e}")))?;
let overlay_stream = Stream::new(dictionary! {}, encoded);
let overlay_id = doc.add_object(Object::Stream(overlay_stream));
append_content_to_page(doc, page_id, overlay_id);
Ok(())
}
pub(crate) fn check_image_filter(dict: &lopdf::Dictionary) -> Result<()> {
let filter_obj = match dict.get(b"Filter") {
Ok(f) => f.clone(),
Err(_) => return Ok(()),
};
let names: Vec<String> = match filter_obj {
Object::Name(ref n) => vec![String::from_utf8_lossy(n).into_owned()],
Object::Array(ref arr) => arr
.iter()
.filter_map(|o| {
if let Object::Name(ref n) = o {
Some(String::from_utf8_lossy(n).into_owned())
} else {
None
}
})
.collect(),
_ => vec![],
};
for name in &names {
if matches!(name.as_str(), "JBIG2Decode" | "JPXDecode" | "Crypt") {
return Err(RedactError::UnsupportedImageFilter(name.clone()));
}
}
Ok(())
}
fn bbox_overlaps_any(bbox: [f64; 4], areas: &[&RedactionArea]) -> bool {
let [bx0, by0, bx1, by1] = bbox;
for area in areas {
let [ax0, ay0, ax1, ay1] = area.rect;
let (rax0, rax1) = if ax0 < ax1 { (ax0, ax1) } else { (ax1, ax0) };
let (ray0, ray1) = if ay0 < ay1 { (ay0, ay1) } else { (ay1, ay0) };
let no_overlap = bx1 < rax0 || bx0 > rax1 || by1 < ray0 || by0 > ray1;
if !no_overlap {
return true;
}
}
false
}
fn strip_annotation_contents(
doc: &mut Document,
page_id: ObjectId,
areas: &[&RedactionArea],
) -> Result<()> {
let annot_ids = get_page_annotation_ids(doc, page_id);
for annot_id in annot_ids {
let rect = match get_annotation_rect(doc, annot_id) {
Some(r) => r,
None => continue,
};
if bbox_overlaps_any(rect, areas) {
if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(annot_id) {
d.remove(b"Contents");
d.remove(b"T");
}
}
}
Ok(())
}
fn get_page_annotation_ids(doc: &Document, page_id: ObjectId) -> Vec<ObjectId> {
let page_dict = match doc.get_object(page_id) {
Ok(Object::Dictionary(ref d)) => d.clone(),
_ => return Vec::new(),
};
let annots_arr = 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 Vec::new(),
},
_ => return Vec::new(),
};
annots_arr
.iter()
.filter_map(|o| {
if let Object::Reference(id) = o {
Some(*id)
} else {
None
}
})
.collect()
}
fn get_annotation_rect(doc: &Document, annot_id: ObjectId) -> Option<[f64; 4]> {
let dict = match doc.get_object(annot_id) {
Ok(Object::Dictionary(ref d)) => d.clone(),
_ => return None,
};
match dict.get(b"Rect") {
Ok(Object::Array(ref arr)) if arr.len() >= 4 => {
let vals: Vec<f64> = arr.iter().filter_map(as_number).collect();
if vals.len() >= 4 {
Some([vals[0], vals[1], vals[2], vals[3]])
} else {
None
}
}
_ => None,
}
}
pub(crate) fn refuse_xfa(doc: &Document) -> Result<()> {
if has_xfa(doc) {
return Err(RedactError::Other(
"xfa-redaction-needs-flatten: the document carries an XFA packet that holds its own \
copy of the form text; flatten the form before redacting"
.to_string(),
));
}
Ok(())
}
pub fn has_xfa(doc: &Document) -> bool {
match doc.catalog() {
Ok(catalog) => {
let acro = match catalog.get(b"AcroForm") {
Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok(),
Ok(Object::Dictionary(d)) => Some(d),
_ => None,
};
acro.is_some_and(|d| d.has(b"XFA")) || catalog.has(b"XFA")
}
Err(_) => false,
}
}
fn ensure_no_text_under(doc: &Document, page_num: u32, areas: &[&RedactionArea]) -> Result<()> {
let Some(&page_id) = doc.get_pages().get(&page_num) else {
return Ok(());
};
let resources = resolve_inherited_resources_clone(doc, page_id).unwrap_or_default();
let left = crate::area_filter::text_left_under(doc, page_num, page_id, resources, areas)?;
if left > 0 {
return Err(RedactError::Other(format!(
"text is still extractable under a redaction area on page {page_num} \
({left} text operation(s) the removal rule still takes glyphs from); \
redaction refused instead of reporting success"
)));
}
Ok(())
}
fn clean_metadata(doc: &mut Document) {
doc.trailer.remove(b"Info");
replace_xmp_metadata(doc);
let page_ids: Vec<ObjectId> = doc.get_pages().values().copied().collect();
for page_id in page_ids {
if let Ok(Object::Dictionary(ref mut page)) = doc.get_object_mut(page_id) {
page.remove(b"Thumb");
}
}
}
fn replace_xmp_metadata(doc: &mut Document) {
let root_id = match doc.trailer.get(b"Root") {
Ok(Object::Reference(id)) => *id,
_ => return,
};
let meta_id: Option<ObjectId> = {
match doc.get_object(root_id) {
Ok(Object::Dictionary(ref d)) => match d.get(b"Metadata") {
Ok(Object::Reference(id)) => Some(*id),
_ => None,
},
_ => return,
}
};
let xmp = MINIMAL_XMP.to_vec();
let xmp_len = xmp.len() as i64;
if let Some(meta_stream_id) = meta_id {
if let Ok(Object::Stream(ref mut s)) = doc.get_object_mut(meta_stream_id) {
s.content = xmp;
s.dict.set("Length", Object::Integer(xmp_len));
s.dict.remove(b"Filter");
}
} else {
let xmp_stream = Stream::new(
dictionary! { "Type" => "Metadata", "Subtype" => "XML" },
xmp,
);
let new_id = doc.add_object(Object::Stream(xmp_stream));
if let Ok(Object::Dictionary(ref mut catalog)) = doc.get_object_mut(root_id) {
catalog.set("Metadata", Object::Reference(new_id));
}
}
}
fn resolve_content_streams(doc: &Document, page_id: ObjectId) -> Vec<ObjectId> {
let page_obj = match doc.get_object(page_id) {
Ok(obj) => obj,
Err(_) => return Vec::new(),
};
let page_dict = match page_obj {
Object::Dictionary(ref d) => d,
_ => return Vec::new(),
};
match page_dict.get(b"Contents").ok() {
Some(c) => flatten_content_refs(doc, c),
None => Vec::new(),
}
}
fn flatten_content_refs(doc: &Document, obj: &Object) -> Vec<ObjectId> {
match obj {
Object::Reference(id) => {
if let Ok(Object::Array(arr)) = doc.get_object(*id) {
return arr
.iter()
.flat_map(|o| flatten_content_refs(doc, o))
.collect();
}
vec![*id]
}
Object::Array(arr) => arr
.iter()
.flat_map(|o| flatten_content_refs(doc, o))
.collect(),
_ => Vec::new(),
}
}
fn append_content_to_page(doc: &mut Document, page_id: ObjectId, content_id: ObjectId) {
let existing = resolve_content_streams(doc, page_id);
let new_contents = if existing.is_empty() {
Object::Reference(content_id)
} else {
let mut arr: Vec<Object> = existing.into_iter().map(Object::Reference).collect();
arr.push(Object::Reference(content_id));
Object::Array(arr)
};
if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
d.set("Contents", new_contents);
}
}
fn wrap_existing_content_in_save_restore(doc: &mut Document, page_id: ObjectId) {
let existing = resolve_content_streams(doc, page_id);
if existing.is_empty() {
return;
}
let q_stream = Stream::new(dictionary! {}, b"q\n".to_vec());
let q_id = doc.add_object(Object::Stream(q_stream));
let big_q_stream = Stream::new(dictionary! {}, b"\nQ\n".to_vec());
let big_q_id = doc.add_object(Object::Stream(big_q_stream));
let mut new_arr = Vec::with_capacity(existing.len() + 2);
new_arr.push(Object::Reference(q_id));
for id in existing {
new_arr.push(Object::Reference(id));
}
new_arr.push(Object::Reference(big_q_id));
let wrapped = Object::Array(new_arr);
if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
d.set("Contents", wrapped);
}
}
#[allow(dead_code)]
fn page_rotation_and_media_box(doc: &Document, page_id: ObjectId) -> (i64, [f64; 4]) {
let default_box = [0.0, 0.0, 612.0, 792.0];
let page_obj = match doc.get_object(page_id) {
Ok(obj) => obj,
Err(_) => return (0, default_box),
};
let page_dict = match page_obj {
Object::Dictionary(ref d) => d,
_ => return (0, default_box),
};
let rotate = page_dict
.get(b"Rotate")
.ok()
.and_then(|r| match r {
Object::Integer(i) => Some(*i),
_ => None,
})
.unwrap_or(0);
let media_box = page_dict
.get(b"MediaBox")
.ok()
.and_then(|mb| {
if let Object::Array(arr) = mb {
if arr.len() >= 4 {
let vals: Vec<f64> = arr.iter().filter_map(as_number).collect();
if vals.len() >= 4 {
return Some([vals[0], vals[1], vals[2], vals[3]]);
}
}
}
None
})
.unwrap_or(default_box);
(rotate, media_box)
}
#[allow(dead_code)]
fn transform_rect_for_rotation(
x0: f64,
y0: f64,
x1: f64,
y1: f64,
rotate: i64,
media_box: &[f64; 4],
) -> (f64, f64, f64, f64) {
let page_w = media_box[2] - media_box[0];
let page_h = media_box[3] - media_box[1];
match rotate % 360 {
0 => (x0, y0, x1, y1),
90 | -270 => (page_w - y1, x0, page_w - y0, x1),
180 | -180 => (page_w - x1, page_h - y1, page_w - x0, page_h - y0),
270 | -90 => (y0, page_h - x1, y1, page_h - x0),
_ => (x0, y0, x1, y1),
}
}
fn compress_flate(data: &[u8]) -> Vec<u8> {
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
if encoder.write_all(data).is_ok() {
encoder.finish().unwrap_or_else(|_| data.to_vec())
} else {
data.to_vec()
}
}
fn strip_tounicode_for_redacted_page(doc: &mut Document, page_id: ObjectId) -> Result<usize> {
let mut form_clones: std::collections::HashMap<ObjectId, ObjectId> =
std::collections::HashMap::new();
strip_tounicode_for_container(doc, page_id, ContainerKind::Page, &mut form_clones)
}
#[derive(Copy, Clone)]
enum ContainerKind {
Page,
FormXObject,
}
fn strip_tounicode_for_container(
doc: &mut Document,
container_id: ObjectId,
kind: ContainerKind,
form_clones: &mut std::collections::HashMap<ObjectId, ObjectId>,
) -> Result<usize> {
let snapshot = match kind {
ContainerKind::Page => resolve_page_resources_snapshot(doc, container_id),
ContainerKind::FormXObject => resolve_form_xobject_resources_snapshot(doc, container_id),
};
let snapshot = match snapshot {
Some(s) => s,
None => return Ok(0),
};
let font_actions = collect_strip_actions(doc, &snapshot)?;
let form_xobject_entries = collect_form_xobject_entries(doc, &snapshot);
let needs_font_clone = !font_actions.is_empty();
let needs_xobject_clone = !form_xobject_entries.is_empty();
if !needs_font_clone && !needs_xobject_clone {
return Ok(0);
}
let private = materialize_private_path(
doc,
container_id,
kind,
&snapshot,
needs_font_clone,
needs_xobject_clone,
);
let mut stripped = 0;
if let Some(font_dict_id) = private.font_dict_id {
for FontStripPlan {
font_name_bytes,
font_dict_template,
codes,
} in font_actions
{
let stub_id = build_and_insert_stub_stream(doc, &codes);
let mut new_font = font_dict_template;
new_font.set("ToUnicode", Object::Reference(stub_id));
let new_font_id = doc.add_object(Object::Dictionary(new_font));
if let Ok(Object::Dictionary(font_dict)) = doc.get_object_mut(font_dict_id) {
font_dict.set(font_name_bytes.clone(), Object::Reference(new_font_id));
}
stripped += 1;
}
}
if let Some(xobject_dict_id) = private.xobject_dict_id {
for entry in form_xobject_entries {
let (cloned_form_id, recurse_into_new_clone) =
if let Some(&existing) = form_clones.get(&entry.original_id) {
(existing, false)
} else {
let new_clone = clone_form_xobject_stream(doc, entry.original_id);
form_clones.insert(entry.original_id, new_clone);
(new_clone, true)
};
if let Ok(Object::Dictionary(d)) = doc.get_object_mut(xobject_dict_id) {
d.set(entry.name.clone(), Object::Reference(cloned_form_id));
}
if recurse_into_new_clone {
stripped += strip_tounicode_for_container(
doc,
cloned_form_id,
ContainerKind::FormXObject,
form_clones,
)?;
}
}
}
Ok(stripped)
}
struct ResourcesSnapshot {
#[allow(dead_code)]
resources_dict: lopdf::Dictionary,
font_dict: Option<lopdf::Dictionary>,
xobject_dict: Option<lopdf::Dictionary>,
}
struct FontStripPlan {
font_name_bytes: Vec<u8>,
font_dict_template: lopdf::Dictionary,
codes: crate::tounicode::TounicodeCodes,
}
pub(crate) fn resolve_inherited_resources_clone(
doc: &Document,
page_id: ObjectId,
) -> Option<lopdf::Dictionary> {
let mut current = page_id;
let mut visited: std::collections::HashSet<ObjectId> = std::collections::HashSet::new();
loop {
if !visited.insert(current) {
return None;
}
let dict = doc.get_dictionary(current).ok()?;
if let Ok(res_obj) = dict.get(b"Resources") {
return match res_obj {
Object::Dictionary(d) => Some(d.clone()),
Object::Reference(id) => doc.get_dictionary(*id).ok().cloned(),
_ => None,
};
}
match dict.get(b"Parent") {
Ok(Object::Reference(id)) => current = *id,
_ => return None,
}
}
}
fn resolve_page_resources_snapshot(doc: &Document, page_id: ObjectId) -> Option<ResourcesSnapshot> {
let resources_dict = resolve_inherited_resources_clone(doc, page_id)?;
Some(snapshot_from_resources_dict(doc, resources_dict))
}
fn resolve_form_xobject_resources_snapshot(
doc: &Document,
form_id: ObjectId,
) -> Option<ResourcesSnapshot> {
let stream_dict = match doc.get_object(form_id).ok()? {
Object::Stream(s) => &s.dict,
_ => return None,
};
let res_obj = stream_dict.get(b"Resources").ok()?;
let resources_dict = match res_obj {
Object::Dictionary(d) => d.clone(),
Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
_ => return None,
};
Some(snapshot_from_resources_dict(doc, resources_dict))
}
fn snapshot_from_resources_dict(
doc: &Document,
resources_dict: lopdf::Dictionary,
) -> ResourcesSnapshot {
let font_dict = match resources_dict.get(b"Font") {
Ok(Object::Dictionary(d)) => Some(d.clone()),
Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok().cloned(),
_ => None,
};
let xobject_dict = match resources_dict.get(b"XObject") {
Ok(Object::Dictionary(d)) => Some(d.clone()),
Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok().cloned(),
_ => None,
};
ResourcesSnapshot {
resources_dict,
font_dict,
xobject_dict,
}
}
struct FormXObjectEntry {
name: Vec<u8>,
original_id: ObjectId,
}
fn collect_form_xobject_entries(
doc: &Document,
snapshot: &ResourcesSnapshot,
) -> Vec<FormXObjectEntry> {
let Some(xobject_dict) = snapshot.xobject_dict.as_ref() else {
return Vec::new();
};
let mut out = Vec::new();
for (name_bytes, val) in xobject_dict.iter() {
let id = match val {
Object::Reference(id) => *id,
_ => continue,
};
let stream = match doc.get_object(id) {
Ok(Object::Stream(s)) => s,
_ => continue,
};
let is_form = matches!(
stream.dict.get(b"Subtype"),
Ok(Object::Name(n)) if n == b"Form"
);
if !is_form {
continue;
}
out.push(FormXObjectEntry {
name: name_bytes.to_vec(),
original_id: id,
});
}
out
}
fn clone_form_xobject_stream(doc: &mut Document, original_id: ObjectId) -> ObjectId {
let cloned = match doc.get_object(original_id) {
Ok(Object::Stream(s)) => s.clone(),
_ => {
return original_id;
}
};
doc.add_object(Object::Stream(cloned))
}
fn collect_strip_actions(
doc: &Document,
snapshot: &ResourcesSnapshot,
) -> Result<Vec<FontStripPlan>> {
let Some(font_dict) = snapshot.font_dict.as_ref() else {
return Ok(Vec::new());
};
let mut plans = Vec::new();
for (name_bytes, font_obj) in font_dict.iter() {
let font_name_bytes: Vec<u8> = name_bytes.to_vec();
let display_name = String::from_utf8_lossy(&font_name_bytes).into_owned();
let resolved_font_dict: lopdf::Dictionary = match font_obj {
Object::Dictionary(d) => d.clone(),
Object::Reference(id) => match doc.get_dictionary(*id).ok() {
Some(d) => d.clone(),
None => continue,
},
_ => continue,
};
let to_unicode = match resolved_font_dict.get(b"ToUnicode") {
Ok(o) => o,
Err(_) => continue, };
if existing_tounicode_is_safe_stub(doc, to_unicode) {
continue; }
let bytes = read_tounicode_bytes(doc, to_unicode).ok_or_else(|| {
RedactError::UnsupportedToUnicodeCMap {
font_resource_name: display_name.clone(),
reason: "could not read /ToUnicode stream bytes".to_string(),
}
})?;
let codes = crate::tounicode::extract_tounicode_codes(&bytes, &display_name)?;
plans.push(FontStripPlan {
font_name_bytes,
font_dict_template: resolved_font_dict,
codes,
});
}
Ok(plans)
}
struct PrivateContainerPath {
font_dict_id: Option<ObjectId>,
xobject_dict_id: Option<ObjectId>,
}
fn materialize_private_path(
doc: &mut Document,
container_id: ObjectId,
kind: ContainerKind,
snapshot: &ResourcesSnapshot,
clone_font: bool,
clone_xobject: bool,
) -> PrivateContainerPath {
let font_dict_id = if clone_font {
let font_dict_clone = snapshot.font_dict.clone().unwrap_or_default();
Some(doc.add_object(Object::Dictionary(font_dict_clone)))
} else {
None
};
let xobject_dict_id = if clone_xobject {
let xobject_dict_clone = snapshot.xobject_dict.clone().unwrap_or_default();
Some(doc.add_object(Object::Dictionary(xobject_dict_clone)))
} else {
None
};
let mut new_resources = snapshot.resources_dict.clone();
if let Some(id) = font_dict_id {
new_resources.set("Font", Object::Reference(id));
}
if let Some(id) = xobject_dict_id {
new_resources.set("XObject", Object::Reference(id));
}
let new_resources_id = doc.add_object(Object::Dictionary(new_resources));
set_container_resources(doc, container_id, kind, Object::Reference(new_resources_id));
PrivateContainerPath {
font_dict_id,
xobject_dict_id,
}
}
fn set_container_resources(
doc: &mut Document,
container_id: ObjectId,
kind: ContainerKind,
value: Object,
) {
match (kind, doc.get_object_mut(container_id)) {
(ContainerKind::Page, Ok(Object::Dictionary(page))) => {
page.set("Resources", value);
}
(ContainerKind::FormXObject, Ok(Object::Stream(s))) => {
s.dict.set("Resources", value);
}
_ => {}
}
}
fn existing_tounicode_is_safe_stub(doc: &Document, to_unicode: &Object) -> bool {
let stream = match to_unicode {
Object::Reference(id) => match doc.get_object(*id) {
Ok(Object::Stream(s)) => s.clone(),
_ => return false,
},
Object::Stream(s) => s.clone(),
_ => return false,
};
let marker_present = matches!(
stream.dict.get(crate::tounicode::STUB_MARKER_KEY),
Ok(Object::Boolean(true))
);
if !marker_present {
return false;
}
let mut s = stream;
let _ = s.decompress();
crate::tounicode::is_safe_stub_cmap(&s.content)
}
fn read_tounicode_bytes(doc: &Document, to_unicode: &Object) -> Option<Vec<u8>> {
let stream = match to_unicode {
Object::Reference(id) => match doc.get_object(*id) {
Ok(Object::Stream(s)) => s.clone(),
_ => return None,
},
Object::Stream(s) => s.clone(),
_ => return None,
};
let mut s = stream;
let _ = s.decompress();
Some(s.content)
}
fn build_and_insert_stub_stream(
doc: &mut Document,
codes: &crate::tounicode::TounicodeCodes,
) -> ObjectId {
let stub_bytes = crate::tounicode::build_stub_cmap(codes);
let compressed = compress_flate(&stub_bytes);
let mut dict = lopdf::Dictionary::new();
if compressed.len() < stub_bytes.len() {
dict.set("Filter", Object::Name(b"FlateDecode".to_vec()));
dict.set("Length", Object::Integer(compressed.len() as i64));
dict.set(crate::tounicode::STUB_MARKER_KEY, Object::Boolean(true));
let stream = lopdf::Stream::new(dict, compressed);
doc.add_object(Object::Stream(stream))
} else {
dict.set("Length", Object::Integer(stub_bytes.len() as i64));
dict.set(crate::tounicode::STUB_MARKER_KEY, Object::Boolean(true));
let stream = lopdf::Stream::new(dict, stub_bytes);
doc.add_object(Object::Stream(stream))
}
}
fn as_number(obj: &Object) -> Option<f64> {
match obj {
Object::Integer(i) => Some(*i as f64),
Object::Real(f) => Some(*f as f64),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_doc_with_text(content: &[u8]) -> Document {
let mut doc = Document::with_version("1.7");
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),
};
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),
"Author" => Object::String(b"Tester".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 redact_empty_returns_error() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf (Hello) Tj ET");
let redactor = Redactor::new();
let result = redactor.apply(&mut doc);
assert!(result.is_err());
}
#[test]
fn redact_text_in_area() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]));
let report = redactor.apply(&mut doc).unwrap();
assert_eq!(report.areas_redacted, 1);
assert!(report.operations_removed > 0);
assert_eq!(report.pages_affected, 1);
assert!(report.metadata_cleaned);
}
#[test]
fn redact_preserves_text_outside_area() {
let mut doc =
make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Keep) Tj 100 200 Td (Remove) Tj ET");
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [190.0, 890.0, 310.0, 920.0]));
let report = redactor.apply(&mut doc).unwrap();
assert!(report.operations_removed > 0);
}
#[test]
fn redact_with_overlay_text() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]).with_overlay("REDACTED"));
let report = redactor.apply(&mut doc).unwrap();
assert_eq!(report.areas_redacted, 1);
}
#[test]
fn redact_with_custom_color() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]).with_color(1.0, 0.0, 0.0));
let report = redactor.apply(&mut doc).unwrap();
assert_eq!(report.areas_redacted, 1);
}
#[test]
fn redact_cleans_metadata() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]));
let report = redactor.apply(&mut doc).unwrap();
assert!(report.metadata_cleaned);
assert!(doc.trailer.get(b"Info").is_err());
}
#[test]
fn redact_page_out_of_range() {
let mut doc = make_doc_with_text(b"BT /F1 12 Tf (Hello) Tj ET");
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(5, [0.0, 0.0, 100.0, 100.0]));
let result = redactor.apply(&mut doc);
assert!(result.is_err());
}
#[test]
fn redact_multiple_areas() {
let mut doc =
make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret1) Tj 100 600 Td (Secret2) Tj ET");
let mut redactor = Redactor::new();
redactor.mark_all(vec![
RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]),
RedactionArea::new(1, [190.0, 1290.0, 310.0, 1320.0]),
]);
assert_eq!(redactor.pending_count(), 2);
let report = redactor.apply(&mut doc).unwrap();
assert_eq!(report.areas_redacted, 2);
}
const ORIGINAL_TOUNICODE: &[u8] = b"\
/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
/CIDSystemInfo
<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
/CMapName /Adobe-Identity-UCS def
/CMapType 2 def
1 begincodespacerange
<00> <FF>
endcodespacerange
3 beginbfchar
<41> <0041>
<42> <0042>
<43> <0043>
endbfchar
endcmap
CMapName currentdict /CMap defineresource pop
end
end
";
fn make_doc_with_tounicode_font() -> (Document, ObjectId, ObjectId, ObjectId) {
let mut doc = Document::with_version("1.7");
let content_stream = Stream::new(
dictionary! {},
b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
);
let content_id = doc.add_object(Object::Stream(content_stream));
let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
let tu_id = doc.add_object(Object::Stream(tu_stream));
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"ToUnicode" => Object::Reference(tu_id),
};
let font_id = doc.add_object(Object::Dictionary(font_dict));
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(font_id),
},
};
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 = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
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, page_id, font_id, tu_id)
}
fn current_tounicode_ref(doc: &Document, page_id: ObjectId) -> Option<ObjectId> {
let page = doc.get_dictionary(page_id).ok()?;
let resources = match page.get(b"Resources").ok()? {
Object::Dictionary(d) => d.clone(),
Object::Reference(id) => match doc.get_object(*id).ok()? {
Object::Dictionary(d) => d.clone(),
_ => return None,
},
_ => return None,
};
let font_dict_obj = resources.get(b"Font").ok()?;
let font_dict = match font_dict_obj {
Object::Dictionary(d) => d.clone(),
Object::Reference(id) => match doc.get_object(*id).ok()? {
Object::Dictionary(d) => d.clone(),
_ => return None,
},
_ => return None,
};
let font_obj = font_dict.get(b"F1").ok()?;
let font = match font_obj {
Object::Dictionary(d) => d.clone(),
Object::Reference(id) => match doc.get_object(*id).ok()? {
Object::Dictionary(d) => d.clone(),
_ => return None,
},
_ => return None,
};
match font.get(b"ToUnicode").ok()? {
Object::Reference(id) => Some(*id),
_ => None,
}
}
fn current_font_id(doc: &Document, page_id: ObjectId) -> Option<ObjectId> {
let page = doc.get_dictionary(page_id).ok()?;
let resources = match page.get(b"Resources").ok()? {
Object::Dictionary(d) => d.clone(),
Object::Reference(id) => match doc.get_object(*id).ok()? {
Object::Dictionary(d) => d.clone(),
_ => return None,
},
_ => return None,
};
let font_dict = match resources.get(b"Font").ok()? {
Object::Dictionary(d) => d.clone(),
Object::Reference(id) => match doc.get_object(*id).ok()? {
Object::Dictionary(d) => d.clone(),
_ => return None,
},
_ => return None,
};
match font_dict.get(b"F1").ok()? {
Object::Reference(id) => Some(*id),
_ => None,
}
}
#[test]
fn tounicode_strip_replaces_cmap_on_redacted_page() {
let (mut doc, _page_id, original_font_id, original_tu_id) = make_doc_with_tounicode_font();
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
let report = redactor.apply(&mut doc).unwrap();
assert!(report.pages_affected >= 1);
let new_font_id = current_font_id(&doc, _page_id).expect("F1 must still resolve");
assert_ne!(
new_font_id, original_font_id,
"redacted page must point at a cloned font dict, not the shared original"
);
let new_tu_id = current_tounicode_ref(&doc, _page_id)
.expect("redacted page font must still have /ToUnicode (now a stub)");
assert_ne!(
new_tu_id, original_tu_id,
"stub /ToUnicode must be a fresh stream, not the original"
);
let stub_stream = match doc.get_object(new_tu_id).unwrap() {
Object::Stream(s) => s.clone(),
_ => panic!("/ToUnicode must be a stream"),
};
assert!(
matches!(
stub_stream.dict.get(crate::tounicode::STUB_MARKER_KEY),
Ok(Object::Boolean(true))
),
"stub stream must carry the XfaRedactionStub marker"
);
}
#[test]
fn tounicode_strip_preserves_original_for_non_redacted_pages_with_shared_font() {
let (mut doc, page1_id, shared_font_id, original_tu_id) = make_doc_with_tounicode_font();
let content2 = Stream::new(
dictionary! {},
b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
);
let content2_id = doc.add_object(Object::Stream(content2));
let resources2 = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(shared_font_id),
},
};
let page2_dict = dictionary! {
"Type" => "Page",
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => Object::Reference(content2_id),
"Resources" => Object::Dictionary(resources2),
};
let page2_id = doc.add_object(Object::Dictionary(page2_dict));
let pages_id = match doc
.get_dictionary(page1_id)
.unwrap()
.get(b"Parent")
.unwrap()
{
Object::Reference(id) => *id,
_ => panic!("page must have indirect Parent"),
};
if let Ok(Object::Dictionary(d)) = doc.get_object_mut(page2_id) {
d.set("Parent", Object::Reference(pages_id));
}
if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
pages.set(
"Kids",
vec![Object::Reference(page1_id), Object::Reference(page2_id)],
);
pages.set("Count", 2_i64);
}
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
redactor.apply(&mut doc).unwrap();
let page1_font_id = current_font_id(&doc, page1_id).unwrap();
assert_ne!(
page1_font_id, shared_font_id,
"redacted page 1 must have a cloned font"
);
let page2_font_id = current_font_id(&doc, page2_id).unwrap();
assert_eq!(
page2_font_id, shared_font_id,
"non-redacted page 2 must keep its shared font reference"
);
let page2_tu_id = current_tounicode_ref(&doc, page2_id).unwrap();
assert_eq!(
page2_tu_id, original_tu_id,
"non-redacted page 2 must keep the original /ToUnicode reference"
);
}
#[test]
fn tounicode_strip_idempotent_on_repeated_redaction() {
let (mut doc, page_id, _, _) = make_doc_with_tounicode_font();
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
redactor.apply(&mut doc).unwrap();
let after_first = current_tounicode_ref(&doc, page_id).unwrap();
let mut redactor2 = Redactor::new();
redactor2.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
redactor2.apply(&mut doc).unwrap();
let after_second = current_tounicode_ref(&doc, page_id).unwrap();
assert_eq!(
after_first, after_second,
"repeated redaction must not allocate a new stub /ToUnicode"
);
}
#[test]
fn tounicode_strip_skips_font_without_existing_tounicode() {
let mut doc = Document::with_version("1.7");
let content = Stream::new(
dictionary! {},
b"BT /F1 12 Tf 100 700 Td (X) Tj ET".to_vec(),
);
let content_id = doc.add_object(Object::Stream(content));
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
};
let font_id = doc.add_object(Object::Dictionary(font_dict));
let resources = dictionary! {
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
};
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 = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
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));
let original_font_id = font_id;
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
redactor.apply(&mut doc).unwrap();
let after_font_id = current_font_id(&doc, page_id).unwrap();
assert_eq!(
after_font_id, original_font_id,
"font without /ToUnicode must not be cloned"
);
let font_dict = doc.get_dictionary(after_font_id).unwrap();
assert!(
font_dict.get(b"ToUnicode").is_err(),
"no /ToUnicode key should be added by the strip"
);
}
#[test]
fn tounicode_strip_returns_err_on_unsupported_cmap_shape() {
let mut doc = Document::with_version("1.7");
let content = Stream::new(
dictionary! {},
b"BT /F1 12 Tf 100 700 Td (X) Tj ET".to_vec(),
);
let content_id = doc.add_object(Object::Stream(content));
let tu_stream = Stream::new(dictionary! {}, b"begincmap\nendcmap\n".to_vec());
let tu_id = doc.add_object(Object::Stream(tu_stream));
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"ToUnicode" => Object::Reference(tu_id),
};
let font_id = doc.add_object(Object::Dictionary(font_dict));
let resources = dictionary! {
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
};
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 = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
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));
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
let err = redactor.apply(&mut doc).unwrap_err();
assert!(
matches!(err, RedactError::UnsupportedToUnicodeCMap { .. }),
"expected UnsupportedToUnicodeCMap, got {err:?}"
);
}
#[test]
fn tounicode_strip_resolves_resources_inherited_from_pages_parent() {
let mut doc = Document::with_version("1.7");
let content = Stream::new(
dictionary! {},
b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
);
let content_id = doc.add_object(Object::Stream(content));
let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
let tu_id = doc.add_object(Object::Stream(tu_stream));
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"ToUnicode" => Object::Reference(tu_id),
};
let font_id = doc.add_object(Object::Dictionary(font_dict));
let page_dict = dictionary! {
"Type" => "Page",
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => Object::Reference(content_id),
};
let page_id = doc.add_object(Object::Dictionary(page_dict));
let inherited_resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(font_id),
},
};
let pages = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
"Resources" => Object::Dictionary(inherited_resources),
};
let pages_id = doc.add_object(Object::Dictionary(pages));
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));
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
redactor.apply(&mut doc).unwrap();
let new_tu_id = current_tounicode_ref(&doc, page_id)
.expect("inherited-resources page must have private /ToUnicode after redaction");
assert_ne!(
new_tu_id, tu_id,
"stub /ToUnicode must be a fresh stream, not the original inherited one"
);
let stub_stream = match doc.get_object(new_tu_id).unwrap() {
Object::Stream(s) => s.clone(),
_ => panic!("/ToUnicode must be a stream"),
};
assert!(
matches!(
stub_stream.dict.get(crate::tounicode::STUB_MARKER_KEY),
Ok(Object::Boolean(true))
),
"inherited-Resources page must end up with a stub-marked /ToUnicode"
);
}
#[test]
fn tounicode_strip_does_not_mutate_shared_indirect_font_subdict() {
let mut doc = Document::with_version("1.7");
let content1 = Stream::new(
dictionary! {},
b"BT /F1 12 Tf 100 700 Td <41> Tj ET".to_vec(),
);
let content1_id = doc.add_object(Object::Stream(content1));
let content2 = Stream::new(
dictionary! {},
b"BT /F1 12 Tf 100 700 Td <42> Tj ET".to_vec(),
);
let content2_id = doc.add_object(Object::Stream(content2));
let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
let tu_id = doc.add_object(Object::Stream(tu_stream));
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"ToUnicode" => Object::Reference(tu_id),
};
let font_id = doc.add_object(Object::Dictionary(font_dict));
let shared_font_subdict = dictionary! {
"F1" => Object::Reference(font_id),
};
let shared_font_subdict_id = doc.add_object(Object::Dictionary(shared_font_subdict));
let resources1 = dictionary! {
"Font" => Object::Reference(shared_font_subdict_id),
};
let resources2 = dictionary! {
"Font" => Object::Reference(shared_font_subdict_id),
};
let page1_dict = dictionary! {
"Type" => "Page",
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => Object::Reference(content1_id),
"Resources" => Object::Dictionary(resources1),
};
let page1_id = doc.add_object(Object::Dictionary(page1_dict));
let page2_dict = dictionary! {
"Type" => "Page",
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => Object::Reference(content2_id),
"Resources" => Object::Dictionary(resources2),
};
let page2_id = doc.add_object(Object::Dictionary(page2_dict));
let pages = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page1_id), Object::Reference(page2_id)],
"Count" => 2_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
for pid in [page1_id, page2_id] {
if let Ok(Object::Dictionary(d)) = doc.get_object_mut(pid) {
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));
let before_subdict_f1 = match doc
.get_dictionary(shared_font_subdict_id)
.unwrap()
.get(b"F1")
.unwrap()
{
Object::Reference(id) => *id,
_ => panic!("F1 entry must be indirect to be shareable"),
};
assert_eq!(before_subdict_f1, font_id);
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
redactor.apply(&mut doc).unwrap();
let shared_after = doc.get_dictionary(shared_font_subdict_id).unwrap();
let after_subdict_f1 = match shared_after.get(b"F1").unwrap() {
Object::Reference(id) => *id,
_ => panic!("F1 must still be indirect"),
};
assert_eq!(
after_subdict_f1, font_id,
"shared /Font sub-dict was mutated in place — page 2 will inherit page 1's stub"
);
let page2_tu = current_tounicode_ref(&doc, page2_id)
.expect("non-redacted page 2 must keep its /ToUnicode");
assert_eq!(
page2_tu, tu_id,
"non-redacted page 2 must still use the original /ToUnicode"
);
let page1_tu = current_tounicode_ref(&doc, page1_id)
.expect("redacted page 1 must have its private /ToUnicode stub");
assert_ne!(
page1_tu, tu_id,
"redacted page 1 must use the stub /ToUnicode, not the original"
);
}
#[test]
fn tounicode_strip_rejects_crafted_marker_without_safe_stub_content() {
let mut doc = Document::with_version("1.7");
let content = Stream::new(
dictionary! {},
b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
);
let content_id = doc.add_object(Object::Stream(content));
let mut tu_dict = lopdf::Dictionary::new();
tu_dict.set(crate::tounicode::STUB_MARKER_KEY, Object::Boolean(true));
let tu_stream = Stream::new(tu_dict, ORIGINAL_TOUNICODE.to_vec());
let crafted_tu_id = doc.add_object(Object::Stream(tu_stream));
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"ToUnicode" => Object::Reference(crafted_tu_id),
};
let font_id = doc.add_object(Object::Dictionary(font_dict));
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(font_id),
},
};
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 = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
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));
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
redactor.apply(&mut doc).unwrap();
let new_tu_id = current_tounicode_ref(&doc, page_id)
.expect("page must still have /ToUnicode after redaction");
assert_ne!(
new_tu_id, crafted_tu_id,
"crafted-marker /ToUnicode must NOT be honored as already-stubbed"
);
let new_stream = match doc.get_object(new_tu_id).unwrap() {
Object::Stream(s) => s.clone(),
_ => panic!("/ToUnicode must be a stream"),
};
let mut s = new_stream;
let _ = s.decompress();
assert!(
crate::tounicode::is_safe_stub_cmap(&s.content),
"redactor must replace crafted-marker stream with a content-validated stub"
);
}
#[test]
fn tounicode_strip_preserves_non_utf8_font_resource_key() {
let mut doc = Document::with_version("1.7");
let content = Stream::new(
dictionary! {},
b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
);
let content_id = doc.add_object(Object::Stream(content));
let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
let tu_id = doc.add_object(Object::Stream(tu_stream));
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"ToUnicode" => Object::Reference(tu_id),
};
let original_font_id = doc.add_object(Object::Dictionary(font_dict));
let raw_key: Vec<u8> = vec![0xFF, b'F', b'1'];
let mut font_subdict = lopdf::Dictionary::new();
font_subdict.set(raw_key.clone(), Object::Reference(original_font_id));
let resources = dictionary! {
"Font" => Object::Dictionary(font_subdict),
};
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 = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
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));
let lossy_key_bytes = String::from_utf8_lossy(&raw_key).into_owned().into_bytes();
assert_ne!(
lossy_key_bytes, raw_key,
"test sanity: lossy conversion must produce different bytes for 0xFF F1"
);
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
redactor.apply(&mut doc).unwrap();
let page_dict = doc.get_dictionary(page_id).unwrap();
let resources_id = match page_dict.get(b"Resources").unwrap() {
Object::Reference(id) => *id,
_ => panic!("strip should have made /Resources indirect"),
};
let resources = doc.get_dictionary(resources_id).unwrap();
let font_subdict_id = match resources.get(b"Font").unwrap() {
Object::Reference(id) => *id,
_ => panic!("strip should have made /Font indirect"),
};
let font_subdict = doc.get_dictionary(font_subdict_id).unwrap();
let entry = font_subdict
.get(raw_key.as_slice())
.expect("raw byte key must still resolve in the private /Font sub-dict");
let cloned_font_id = match entry {
Object::Reference(id) => *id,
_ => panic!("raw key entry must be indirect"),
};
assert_ne!(
cloned_font_id, original_font_id,
"raw key must reference the cloned font dict, not the original"
);
assert!(
font_subdict.get(lossy_key_bytes.as_slice()).is_err(),
"lossy-converted key must not exist in /Font — silent bypass guard"
);
let cloned_font = doc.get_dictionary(cloned_font_id).unwrap();
let new_tu_id = match cloned_font.get(b"ToUnicode").unwrap() {
Object::Reference(id) => *id,
_ => panic!("cloned font must have indirect /ToUnicode"),
};
assert_ne!(new_tu_id, tu_id, "stub must be a fresh stream");
let new_stream = match doc.get_object(new_tu_id).unwrap() {
Object::Stream(s) => s.clone(),
_ => panic!("/ToUnicode must be a stream"),
};
let mut s = new_stream;
let _ = s.decompress();
assert!(
crate::tounicode::is_safe_stub_cmap(&s.content),
"raw-key path must end up at a content-validated stub stream"
);
}
#[test]
fn tounicode_strip_rejects_crafted_usecmap_with_trivial_bfchar() {
let mut doc = Document::with_version("1.7");
let content = Stream::new(
dictionary! {},
b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
);
let content_id = doc.add_object(Object::Stream(content));
let crafted_cmap = b"\
/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
/CIDSystemInfo
<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
/CMapName /Adobe-Identity-UCS def
/CMapType 2 def
/Identity-H usecmap
1 begincodespacerange
<00> <FF>
endcodespacerange
1 beginbfchar
<00> <FFFD>
endbfchar
endcmap
CMapName currentdict /CMap defineresource pop
end
end
";
let mut tu_dict = lopdf::Dictionary::new();
tu_dict.set(crate::tounicode::STUB_MARKER_KEY, Object::Boolean(true));
let tu_stream = Stream::new(tu_dict, crafted_cmap.to_vec());
let crafted_tu_id = doc.add_object(Object::Stream(tu_stream));
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"ToUnicode" => Object::Reference(crafted_tu_id),
};
let font_id = doc.add_object(Object::Dictionary(font_dict));
let resources = dictionary! {
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
};
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 = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
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));
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
redactor.apply(&mut doc).unwrap();
let new_tu_id = current_tounicode_ref(&doc, page_id)
.expect("page must still have /ToUnicode after redaction");
assert_ne!(
new_tu_id, crafted_tu_id,
"usecmap-carrying /ToUnicode must NOT be honoured as already-stubbed"
);
let new_stream = match doc.get_object(new_tu_id).unwrap() {
Object::Stream(s) => s.clone(),
_ => panic!("/ToUnicode must be a stream"),
};
let mut s = new_stream;
let _ = s.decompress();
assert!(
!s.content.windows(b"usecmap".len()).any(|w| w == b"usecmap"),
"replacement stub must not contain usecmap"
);
assert!(
crate::tounicode::is_safe_stub_cmap(&s.content),
"replacement stream must validate as safe stub"
);
}
fn add_font_with_tounicode(doc: &mut Document) -> (ObjectId, ObjectId) {
let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
let tu_id = doc.add_object(Object::Stream(tu_stream));
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"ToUnicode" => Object::Reference(tu_id),
};
let font_id = doc.add_object(Object::Dictionary(font_dict));
(font_id, tu_id)
}
fn add_form_xobject_with_font(
doc: &mut Document,
font_resource_name: &[u8],
font_id: ObjectId,
) -> ObjectId {
let resources = dictionary! {
"Font" => dictionary! {
font_resource_name.to_vec() => Object::Reference(font_id),
},
};
let mut form_dict = dictionary! {
"Type" => "XObject",
"Subtype" => "Form",
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
"Resources" => Object::Dictionary(resources),
};
form_dict.set("Length", Object::Integer(0));
let form_stream = Stream::new(form_dict, Vec::new());
doc.add_object(Object::Stream(form_stream))
}
fn page_form_font_tounicode(
doc: &Document,
page_id: ObjectId,
xobject_name: &[u8],
font_name: &[u8],
) -> Option<(ObjectId, ObjectId)> {
let page = doc.get_dictionary(page_id).ok()?;
let resources = match page.get(b"Resources").ok()? {
Object::Dictionary(d) => d.clone(),
Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
_ => return None,
};
let xobject_dict = match resources.get(b"XObject").ok()? {
Object::Dictionary(d) => d.clone(),
Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
_ => return None,
};
let form_id = match xobject_dict.get(xobject_name).ok()? {
Object::Reference(id) => *id,
_ => return None,
};
let form_stream = match doc.get_object(form_id).ok()? {
Object::Stream(s) => s,
_ => return None,
};
let form_resources = match form_stream.dict.get(b"Resources").ok()? {
Object::Dictionary(d) => d.clone(),
Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
_ => return None,
};
let font_sub = match form_resources.get(b"Font").ok()? {
Object::Dictionary(d) => d.clone(),
Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
_ => return None,
};
let font_id = match font_sub.get(font_name).ok()? {
Object::Reference(id) => *id,
_ => return None,
};
let font_dict = doc.get_dictionary(font_id).ok()?;
let tu_id = match font_dict.get(b"ToUnicode").ok()? {
Object::Reference(id) => *id,
_ => return None,
};
Some((form_id, tu_id))
}
#[test]
fn tounicode_strip_traverses_form_xobject_fonts() {
let mut doc = Document::with_version("1.7");
let content = Stream::new(
dictionary! {},
b"BT /Fp1 12 Tf 100 700 Td (page-level) Tj ET q /Fm1 Do Q".to_vec(),
);
let content_id = doc.add_object(Object::Stream(content));
let (page_font_id, _page_tu_id) = add_font_with_tounicode(&mut doc);
let (form_font_id, original_form_tu_id) = add_font_with_tounicode(&mut doc);
let form_id = add_form_xobject_with_font(&mut doc, b"F1", form_font_id);
let resources = dictionary! {
"Font" => dictionary! { "Fp1" => Object::Reference(page_font_id) },
"XObject" => dictionary! { "Fm1" => Object::Reference(form_id) },
};
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 = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
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));
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
redactor.apply(&mut doc).unwrap();
let (cloned_form_id, new_form_tu_id) =
page_form_font_tounicode(&doc, page_id, b"Fm1", b"F1")
.expect("Form XObject + font + /ToUnicode must still be reachable");
assert_ne!(
cloned_form_id, form_id,
"Form XObject must be cloned for the redacted page"
);
assert_ne!(
new_form_tu_id, original_form_tu_id,
"Form XObject's font must point at a fresh stub /ToUnicode"
);
let stub_stream = match doc.get_object(new_form_tu_id).unwrap() {
Object::Stream(s) => s.clone(),
_ => panic!("/ToUnicode must be a stream"),
};
let mut s = stub_stream;
let _ = s.decompress();
assert!(
crate::tounicode::is_safe_stub_cmap(&s.content),
"Form XObject font's new /ToUnicode must validate as safe stub"
);
}
#[test]
fn tounicode_strip_clones_shared_form_xobject_for_redacted_page_only() {
let mut doc = Document::with_version("1.7");
let (form_font_id, original_tu_id) = add_font_with_tounicode(&mut doc);
let shared_form_id = add_form_xobject_with_font(&mut doc, b"F1", form_font_id);
let make_page_dict = |form_id: ObjectId| {
dictionary! {
"Type" => "Page",
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => Object::Reference(doc_objects_dummy()),
"Resources" => Object::Dictionary(dictionary! {
"XObject" => dictionary! { "Fm1" => Object::Reference(form_id) },
}),
}
};
let content = Stream::new(dictionary! {}, b"q /Fm1 Do Q".to_vec());
let content_id = doc.add_object(Object::Stream(content));
let page1_dict = dictionary! {
"Type" => "Page",
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => Object::Reference(content_id),
"Resources" => Object::Dictionary(dictionary! {
"XObject" => dictionary! { "Fm1" => Object::Reference(shared_form_id) },
}),
};
let page1_id = doc.add_object(Object::Dictionary(page1_dict));
let page2_dict = dictionary! {
"Type" => "Page",
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => Object::Reference(content_id),
"Resources" => Object::Dictionary(dictionary! {
"XObject" => dictionary! { "Fm1" => Object::Reference(shared_form_id) },
}),
};
let page2_id = doc.add_object(Object::Dictionary(page2_dict));
let _ = make_page_dict;
let pages = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page1_id), Object::Reference(page2_id)],
"Count" => 2_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
for pid in [page1_id, page2_id] {
if let Ok(Object::Dictionary(d)) = doc.get_object_mut(pid) {
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));
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [0.0, 0.0, 100.0, 100.0]));
redactor.apply(&mut doc).unwrap();
let (page1_form, page1_tu) =
page_form_font_tounicode(&doc, page1_id, b"Fm1", b"F1").unwrap();
assert_ne!(page1_form, shared_form_id);
assert_ne!(page1_tu, original_tu_id);
let (page2_form, page2_tu) =
page_form_font_tounicode(&doc, page2_id, b"Fm1", b"F1").unwrap();
assert_eq!(
page2_form, shared_form_id,
"non-redacted page must still reference the original Form XObject"
);
assert_eq!(
page2_tu, original_tu_id,
"non-redacted page must still see the original /ToUnicode"
);
let shared_form_stream = match doc.get_object(shared_form_id).unwrap() {
Object::Stream(s) => s.clone(),
_ => panic!("shared Form XObject must still exist as a Stream"),
};
let shared_resources = match shared_form_stream.dict.get(b"Resources").unwrap() {
Object::Dictionary(d) => d.clone(),
_ => panic!("shared Form Resources must be inline dict"),
};
let shared_font_dict = match shared_resources.get(b"Font").unwrap() {
Object::Dictionary(d) => d.clone(),
_ => panic!("shared Form /Font must be inline dict"),
};
let shared_font_ref = match shared_font_dict.get(b"F1").unwrap() {
Object::Reference(id) => *id,
_ => panic!("shared Form /F1 must be indirect"),
};
let shared_font_dict_obj = doc.get_dictionary(shared_font_ref).unwrap();
let shared_tu_ref = match shared_font_dict_obj.get(b"ToUnicode").unwrap() {
Object::Reference(id) => *id,
_ => panic!("shared Form font /ToUnicode must be indirect"),
};
assert_eq!(
shared_tu_ref, original_tu_id,
"shared Form's font must still reference the ORIGINAL /ToUnicode"
);
}
#[test]
fn tounicode_strip_recurses_into_nested_form_xobject() {
let mut doc = Document::with_version("1.7");
let (inner_font_id, inner_original_tu) = add_font_with_tounicode(&mut doc);
let inner_form_id = add_form_xobject_with_font(&mut doc, b"FInner", inner_font_id);
let outer_resources = dictionary! {
"XObject" => dictionary! { "Fm2" => Object::Reference(inner_form_id) },
};
let mut outer_form_dict = dictionary! {
"Type" => "XObject",
"Subtype" => "Form",
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
"Resources" => Object::Dictionary(outer_resources),
};
outer_form_dict.set("Length", Object::Integer(0));
let outer_form_stream = Stream::new(outer_form_dict, Vec::new());
let outer_form_id = doc.add_object(Object::Stream(outer_form_stream));
let content = Stream::new(dictionary! {}, b"q /Fm1 Do Q".to_vec());
let content_id = doc.add_object(Object::Stream(content));
let resources = dictionary! {
"XObject" => dictionary! { "Fm1" => Object::Reference(outer_form_id) },
};
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 = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
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));
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [0.0, 0.0, 100.0, 100.0]));
redactor.apply(&mut doc).unwrap();
let page_outer_form_id = match doc
.get_dictionary(page_id)
.unwrap()
.get(b"Resources")
.unwrap()
{
Object::Reference(rid) => {
match doc.get_dictionary(*rid).unwrap().get(b"XObject").unwrap() {
Object::Reference(xrid) => {
match doc.get_dictionary(*xrid).unwrap().get(b"Fm1").unwrap() {
Object::Reference(id) => *id,
_ => panic!("Fm1 must be indirect"),
}
}
Object::Dictionary(d) => match d.get(b"Fm1").unwrap() {
Object::Reference(id) => *id,
_ => panic!("Fm1 must be indirect"),
},
_ => panic!("/XObject must be dict or ref"),
}
}
_ => panic!("page /Resources must be indirect after redaction"),
};
assert_ne!(
page_outer_form_id, outer_form_id,
"outer Form must be cloned for the redacted page"
);
let outer_form_stream = match doc.get_object(page_outer_form_id).unwrap() {
Object::Stream(s) => s.clone(),
_ => panic!("outer Form must still be a Stream"),
};
let outer_resources = match outer_form_stream.dict.get(b"Resources").unwrap() {
Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
Object::Dictionary(d) => d.clone(),
_ => panic!("outer Form Resources must be present"),
};
let inner_xobject_dict = match outer_resources.get(b"XObject").unwrap() {
Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
Object::Dictionary(d) => d.clone(),
_ => panic!("outer Form /XObject must be dict"),
};
let inner_clone_id = match inner_xobject_dict.get(b"Fm2").unwrap() {
Object::Reference(id) => *id,
_ => panic!("Fm2 must be indirect"),
};
assert_ne!(
inner_clone_id, inner_form_id,
"inner Form must also be cloned by recursion"
);
let inner_form_stream = match doc.get_object(inner_clone_id).unwrap() {
Object::Stream(s) => s.clone(),
_ => panic!("inner clone must still be a Stream"),
};
let inner_resources = match inner_form_stream.dict.get(b"Resources").unwrap() {
Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
Object::Dictionary(d) => d.clone(),
_ => panic!("inner Resources missing"),
};
let inner_font_subdict = match inner_resources.get(b"Font").unwrap() {
Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
Object::Dictionary(d) => d.clone(),
_ => panic!("inner /Font missing"),
};
let inner_font_ref = match inner_font_subdict.get(b"FInner").unwrap() {
Object::Reference(id) => *id,
_ => panic!("inner FInner must be indirect"),
};
let inner_font_dict = doc.get_dictionary(inner_font_ref).unwrap();
let inner_new_tu_ref = match inner_font_dict.get(b"ToUnicode").unwrap() {
Object::Reference(id) => *id,
_ => panic!("inner font /ToUnicode must be indirect"),
};
assert_ne!(
inner_new_tu_ref, inner_original_tu,
"inner Form's font /ToUnicode must be a fresh stub"
);
}
#[test]
fn tounicode_strip_handles_cyclic_form_xobject_graph_without_infinite_recursion() {
let mut doc = Document::with_version("1.7");
let (font_id, _original_tu) = add_font_with_tounicode(&mut doc);
let resources = dictionary! {
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
};
let mut form_dict = dictionary! {
"Type" => "XObject",
"Subtype" => "Form",
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
"Resources" => Object::Dictionary(resources),
};
form_dict.set("Length", Object::Integer(0));
let form_stream = Stream::new(form_dict, Vec::new());
let form_id = doc.add_object(Object::Stream(form_stream));
if let Ok(Object::Stream(s)) = doc.get_object_mut(form_id) {
if let Ok(Object::Dictionary(res)) = s.dict.get_mut(b"Resources") {
res.set(
"XObject",
Object::Dictionary(dictionary! {
"Fm1" => Object::Reference(form_id),
}),
);
}
}
let content = Stream::new(dictionary! {}, b"q /Fm1 Do Q".to_vec());
let content_id = doc.add_object(Object::Stream(content));
let page_resources = dictionary! {
"XObject" => dictionary! { "Fm1" => Object::Reference(form_id) },
};
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 = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
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));
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [0.0, 0.0, 100.0, 100.0]));
let report = redactor
.apply(&mut doc)
.expect("cyclic Form XObject graph must not panic the redactor");
assert!(report.pages_affected >= 1);
}
#[test]
fn tounicode_strip_skips_form_xobject_without_resources() {
let mut doc = Document::with_version("1.7");
let mut form_dict = dictionary! {
"Type" => "XObject",
"Subtype" => "Form",
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
};
form_dict.set("Length", Object::Integer(0));
let form_stream = Stream::new(form_dict, Vec::new());
let form_id = doc.add_object(Object::Stream(form_stream));
let (page_font_id, _) = add_font_with_tounicode(&mut doc);
let content = Stream::new(
dictionary! {},
b"BT /Fp1 12 Tf 100 700 Td (X) Tj ET q /Fm1 Do Q".to_vec(),
);
let content_id = doc.add_object(Object::Stream(content));
let resources = dictionary! {
"Font" => dictionary! { "Fp1" => Object::Reference(page_font_id) },
"XObject" => dictionary! { "Fm1" => Object::Reference(form_id) },
};
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 = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
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));
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
redactor.apply(&mut doc).unwrap();
let page_xobject = match doc
.get_dictionary(page_id)
.unwrap()
.get(b"Resources")
.unwrap()
{
Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
Object::Dictionary(d) => d.clone(),
_ => panic!("page /Resources missing"),
};
let xobject_sub = match page_xobject.get(b"XObject").unwrap() {
Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
Object::Dictionary(d) => d.clone(),
_ => panic!("page /XObject missing"),
};
let resolved_form = match xobject_sub.get(b"Fm1").unwrap() {
Object::Reference(id) => *id,
_ => panic!("Fm1 must be indirect"),
};
let resolved_stream = match doc.get_object(resolved_form).unwrap() {
Object::Stream(s) => s.clone(),
_ => panic!("Form XObject must be a Stream"),
};
assert!(
resolved_stream.dict.get(b"Resources").is_err(),
"no-Resources Form XObject should remain without /Resources after strip"
);
}
fn doc_objects_dummy() -> ObjectId {
ObjectId::default()
}
#[test]
fn tounicode_strip_coalesces_aliased_form_xobject_to_one_stubbed_clone() {
let mut doc = Document::with_version("1.7");
let (form_font_id, original_tu_id) = add_font_with_tounicode(&mut doc);
let shared_form_id = add_form_xobject_with_font(&mut doc, b"F1", form_font_id);
let content = Stream::new(dictionary! {}, b"q /Fm1 Do Q q /Fm2 Do Q".to_vec());
let content_id = doc.add_object(Object::Stream(content));
let resources = dictionary! {
"XObject" => dictionary! {
"Fm1" => Object::Reference(shared_form_id),
"Fm2" => Object::Reference(shared_form_id),
},
};
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 = dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
};
let pages_id = doc.add_object(Object::Dictionary(pages));
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));
let mut redactor = Redactor::new();
redactor.mark(RedactionArea::new(1, [0.0, 0.0, 100.0, 100.0]));
redactor.apply(&mut doc).unwrap();
let (fm1_form, fm1_tu) =
page_form_font_tounicode(&doc, page_id, b"Fm1", b"F1").expect("Fm1 path must resolve");
let (fm2_form, fm2_tu) =
page_form_font_tounicode(&doc, page_id, b"Fm2", b"F1").expect("Fm2 path must resolve");
assert_eq!(
fm1_form, fm2_form,
"aliased /XObject entries must share the same memoised clone"
);
assert_ne!(
fm1_form, shared_form_id,
"alias clone must be different from the shared original"
);
assert_eq!(fm1_tu, fm2_tu);
assert_ne!(fm1_tu, original_tu_id);
let stub_stream = match doc.get_object(fm1_tu).unwrap() {
Object::Stream(s) => s.clone(),
_ => panic!("/ToUnicode must be a stream"),
};
let mut s = stub_stream;
let _ = s.decompress();
assert!(
crate::tounicode::is_safe_stub_cmap(&s.content),
"shared alias clone's font must point at a content-validated stub /ToUnicode"
);
}
fn doc_with_text_at(x: f32, y: f32) -> Document {
let mut doc = Document::with_version("1.7");
let content = format!("BT /F1 12 Tf {x} {y} Td (Wq7Kx9Zp) Tj ET\n");
let content_id = doc.add_object(Object::Stream(Stream::new(
dictionary! {},
content.into_bytes(),
)));
let pages_id = doc.new_object_id();
let page_id = doc.add_object(Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Contents" => Object::Reference(content_id),
"Resources" => Object::Dictionary(dictionary! {
"Font" => Object::Dictionary(dictionary! {
"F1" => dictionary! {
"Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica",
},
}),
}),
}));
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1_i64,
}),
);
let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
"Type" => "Catalog", "Pages" => Object::Reference(pages_id),
}));
doc.trailer.set("Root", Object::Reference(catalog_id));
doc
}
#[test]
fn post_condition_refuses_when_text_is_still_under_an_area() {
let doc = doc_with_text_at(72.0, 700.0);
let under = RedactionArea::new(1, [60.0, 690.0, 200.0, 720.0]);
let err = ensure_no_text_under(&doc, 1, &[&under]).expect_err("text is under the area");
assert!(matches!(err, RedactError::Other(m) if m.contains("still extractable")));
let elsewhere = RedactionArea::new(1, [300.0, 100.0, 400.0, 150.0]);
ensure_no_text_under(&doc, 1, &[&elsewhere]).expect("no text under this area");
let mut done = doc.clone();
let mut r = Redactor::new();
r.mark(under.clone());
r.apply(&mut done).expect("apply");
ensure_no_text_under(&done, 1, &[&under]).expect("a redacted page passes");
}
}