fix(edit): fail closed on malformed image consumers

This commit is contained in:
skylar taylor-barrick
2026-10-19 14:07:18 -06:00
parent 9092945df1
commit d7c207bde0
3 changed files with 1293 additions and 103 deletions
+2 -1
View File
@@ -6213,7 +6213,8 @@ fn a_deleted_image_is_not_kept_by_reduce_or_optimize() {
assert_eq!(ok(&mut a, "page_images", json!({ "doc": doc, "page": 1 }))["count"], 0);
// The page is blank, so the copies are tiny: the picture's data is not carried along.
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "optimized.pdf" }));
assert_eq!(r["unused_xobjects"], 1, "{r}");
// Edit Delete may already unlink the image before Optimize runs; the saved size is the
// behavior this regression is meant to protect, not Optimize's intermediate stage count.
assert!(r["bytes_after"].as_u64().unwrap() < 4_000, "{r}");
let r = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" }));
assert!(r["bytes_after"].as_u64().unwrap() < 4_000, "{r}");
+724 -98
View File
@@ -108,65 +108,729 @@ fn xobjects(doc: &Document, page: &Dict) -> Dict {
.unwrap_or_default()
}
/// Whether a page resource name is used by the page or by a reachable Form that inherits the
/// page's resources. A Form with its own `/Resources` has an independent namespace; forms without
/// one inherit the caller's. On an unreadable or over-budget Form, preserve the resource.
fn resource_used_by_inheriting_forms(doc: &Document, ops: &[Op], resources: &Dict, name: &[u8], budget: &mut usize) -> bool {
fn walk(
doc: &Document,
ops: &[Op],
resources: &Dict,
name: &[u8],
depth: usize,
budget: &mut usize,
path: &mut std::collections::HashSet<ObjRef>,
) -> bool {
if depth > 32 || *budget == 0 {
return true;
const IMAGE_CENSUS_MAX_OBJECTS: usize = 100_000;
const IMAGE_CENSUS_MAX_BYTES: usize = 128 * 1024 * 1024;
const IMAGE_CENSUS_MAX_OPERATORS: usize = 5_000_000;
const IMAGE_CENSUS_MAX_VISITS: usize = 50_000;
const IMAGE_CENSUS_MAX_DEPTH: usize = 64;
const IMAGE_CENSUS_MAX_ALIASES: usize = 255;
/// A full-document, bounded census of executable resource consumers. `incomplete` means a
/// conservative cleanup cannot prove that an image is unused, so callers must leave its bytes.
struct ImageUseCensus<'a> {
doc: &'a Document,
target: ObjRef,
used: bool,
incomplete: bool,
bytes: usize,
operators: usize,
visits: usize,
}
impl ImageUseCensus<'_> {
fn charge_visit(&mut self) -> bool {
self.visits = self.visits.saturating_add(1);
if self.visits > IMAGE_CENSUS_MAX_VISITS {
self.incomplete = true;
false
} else {
true
}
let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
for op in ops.iter().filter(|op| op.is("Do")) {
let Some(call_name) = op.name(0) else {
continue;
};
if call_name == name {
return true;
}
let Some(resource) = xo.get(call_name) else {
continue;
};
let resolved = doc.resolve(resource);
let Object::Stream(stream) = &*resolved else {
continue;
};
let Some(reference) = resource.as_ref() else {
if stream.dict.name(b"Subtype") == Some(b"Form") && !stream.dict.contains(b"Resources") {
return true;
}
continue;
};
if stream.dict.name(b"Subtype") != Some(b"Form") || stream.dict.contains(b"Resources") {
continue;
}
if !path.insert(reference) {
return true;
}
*budget -= 1;
let decoded = match stream.decoded() {
Ok(decoded) => decoded,
Err(_) => return true,
};
let nested_ops = parse(&decoded).ops;
let used = walk(doc, &nested_ops, resources, name, depth + 1, budget, path);
path.remove(&reference);
if used {
return true;
}
}
false
}
walk(doc, ops, resources, name, 0, budget, &mut std::collections::HashSet::new())
fn charge_stream(&mut self, stream: &pdfcraft_cos::Stream) -> Option<Vec<u8>> {
if !self.charge_visit() {
return None;
}
let data = match stream.decoded() {
Ok(data) => data,
Err(_) => {
self.incomplete = true;
return None;
}
};
self.bytes = self.bytes.saturating_add(data.len());
if self.bytes > IMAGE_CENSUS_MAX_BYTES {
self.incomplete = true;
return None;
}
Some(data)
}
fn walk_stream(&mut self, object: &Object, inherited: &Dict, depth: usize, path: &mut std::collections::HashSet<ObjRef>) {
if self.used || self.incomplete {
return;
}
if !self.charge_visit() {
return;
}
if depth > IMAGE_CENSUS_MAX_DEPTH {
self.incomplete = true;
return;
}
let reference = object.as_ref();
if reference == Some(self.target) {
self.used = true;
return;
}
if let Some(reference) = reference
&& !path.insert(reference)
{
// A cycle could be rendered differently under an inherited resource scope.
self.incomplete = true;
return;
}
let resolved = self.doc.resolve(object);
let Object::Stream(stream) = &*resolved else {
self.incomplete = true;
return;
};
// Image masks and alternates are serialized image consumers even though they are not
// reached by a `Do` of their own.
if stream.dict.name(b"Subtype") == Some(b"Image") {
for key in [b"SMask".as_slice(), b"Mask".as_slice()] {
let Some(dependency) = stream.dict.get(key) else { continue };
if dependency.as_ref() == Some(self.target) {
self.used = true;
return;
}
match dependency {
Object::Ref(_) => self.walk_stream(dependency, inherited, depth + 1, path),
Object::Null => {}
Object::Name(name) if key == b"SMask" && name == b"None" => {}
Object::Array(values) if key == b"Mask" && values.iter().all(|value| value.as_ref().is_none()) => {}
_ => self.incomplete = true,
}
if self.incomplete || self.used {
return;
}
}
if let Some(alternates) = stream.dict.get(b"Alternates").map(|o| self.doc.resolve(o)) {
let Some(items) = alternates.as_array() else {
self.incomplete = true;
return;
};
for item in items {
let item = self.doc.resolve(item);
let Some(image) = item.as_dict().and_then(|d| d.get(b"Image")) else {
self.incomplete = true;
return;
};
if image.as_ref() == Some(self.target) {
self.used = true;
return;
}
self.walk_stream(image, inherited, depth + 1, path);
if self.incomplete || self.used {
return;
}
}
}
if let Some(reference) = reference {
path.remove(&reference);
}
return;
}
let subtype = stream.dict.name(b"Subtype");
let pattern = stream.dict.int(b"PatternType") == Some(1);
if subtype != Some(b"Form") && !pattern {
self.incomplete = true;
if let Some(reference) = reference {
path.remove(&reference);
}
return;
}
let resources =
stream.dict.get(b"Resources").map(|r| self.doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_else(|| inherited.clone());
let Some(data) = self.charge_stream(stream) else {
if let Some(reference) = reference {
path.remove(&reference);
}
return;
};
self.walk_content(&data, &resources, depth + 1, path);
if let Some(reference) = reference {
path.remove(&reference);
}
}
fn walk_type3(&mut self, font: &Object, inherited: &Dict, depth: usize, path: &mut std::collections::HashSet<ObjRef>) {
if !self.charge_visit() {
return;
}
let resolved = self.doc.resolve(font);
let Some(dict) = resolved.as_dict() else {
self.incomplete = true;
return;
};
if dict.name(b"Subtype") != Some(b"Type3") {
return;
}
let resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_else(|| inherited.clone());
let Some(charprocs) = dict.get(b"CharProcs").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()) else {
self.incomplete = true;
return;
};
for (_, charproc) in charprocs.iter() {
self.walk_charproc(charproc, &resources, depth + 1, path);
if self.used || self.incomplete {
return;
}
}
}
fn walk_charproc(&mut self, object: &Object, inherited: &Dict, depth: usize, path: &mut std::collections::HashSet<ObjRef>) {
if self.used || self.incomplete {
return;
}
if !self.charge_visit() {
return;
}
if depth > IMAGE_CENSUS_MAX_DEPTH {
self.incomplete = true;
return;
}
let reference = object.as_ref();
if reference.is_some_and(|r| !path.insert(r)) {
self.incomplete = true;
return;
}
let resolved = self.doc.resolve(object);
let Object::Stream(stream) = &*resolved else {
self.incomplete = true;
return;
};
let resources =
stream.dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_else(|| inherited.clone());
if let Some(data) = self.charge_stream(stream) {
self.walk_content(&data, &resources, depth + 1, path);
}
if let Some(reference) = reference {
path.remove(&reference);
}
}
fn walk_resources_for_operator(&mut self, op: &Op, resources: &Dict, depth: usize, path: &mut std::collections::HashSet<ObjRef>) {
if op.is("Tf") {
let Some(name) = op.name(0) else {
self.incomplete = true;
return;
};
if let Some(fonts) = resources.get(b"Font").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned())
&& let Some(font) = fonts.get(name)
{
self.walk_type3(font, resources, depth + 1, path);
}
} else if op.is("gs") {
let Some(name) = op.name(0) else {
self.incomplete = true;
return;
};
let states = resources.get(b"ExtGState").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned());
let Some(state) = states.as_ref().and_then(|d| d.get(name)).map(|o| self.doc.resolve(o)) else {
self.incomplete = true;
return;
};
let Some(state_dict) = state.as_dict() else {
self.incomplete = true;
return;
};
if let Some(font_value) = state_dict.get(b"Font") {
let font = self.doc.resolve(font_value);
let Some(values) = font.as_array() else {
self.incomplete = true;
return;
};
if values.len() != 2 {
self.incomplete = true;
return;
}
self.walk_type3(&values[0], resources, depth + 1, path);
if self.incomplete || self.used {
return;
}
}
let Some(mask_value) = state_dict.get(b"SMask") else { return };
let mask = self.doc.resolve(mask_value);
match &*mask {
Object::Name(name) if name == b"None" => {}
Object::Dict(mask_dict) => {
let Some(group) = mask_dict.get(b"G") else {
self.incomplete = true;
return;
};
let resolved_group = self.doc.resolve(group);
if !matches!(&*resolved_group, Object::Stream(stream) if stream.dict.name(b"Subtype") == Some(b"Form")) {
self.incomplete = true;
return;
}
self.walk_stream(group, resources, depth + 1, path);
}
_ => self.incomplete = true,
}
} else if op.is("scn") || op.is("SCN") {
let Some(name) = op.operands.last().and_then(Object::as_name) else { return };
let patterns = resources.get(b"Pattern").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned());
if let Some(pattern) = patterns.as_ref().and_then(|d| d.get(name)) {
let resolved = self.doc.resolve(pattern);
if resolved.as_dict().is_some_and(|d| d.int(b"PatternType") == Some(1)) {
self.walk_stream(pattern, resources, depth + 1, path);
}
}
}
}
fn walk_content(&mut self, data: &[u8], resources: &Dict, depth: usize, path: &mut std::collections::HashSet<ObjRef>) {
self.walk_parsed(parse(data), resources, depth, path);
}
fn walk_parsed(&mut self, parsed: pdfcraft_content::Parsed, resources: &Dict, depth: usize, path: &mut std::collections::HashSet<ObjRef>) {
if self.used || self.incomplete {
return;
}
if depth > IMAGE_CENSUS_MAX_DEPTH {
self.incomplete = true;
return;
}
self.operators = self.operators.saturating_add(parsed.ops.len());
if parsed.skipped != 0 || self.operators > IMAGE_CENSUS_MAX_OPERATORS {
self.incomplete = true;
return;
}
for op in &parsed.ops {
self.walk_resources_for_operator(op, resources, depth, path);
if self.incomplete || self.used {
return;
}
if !op.is("Do") {
continue;
}
let Some(name) = op.name(0) else {
self.incomplete = true;
return;
};
let Some(xobjects) = resources.get(b"XObject").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()) else {
self.incomplete = true;
return;
};
let Some(xobject) = xobjects.get(name) else {
self.incomplete = true;
return;
};
if xobject.as_ref() == Some(self.target) {
self.used = true;
return;
}
self.walk_stream(xobject, resources, depth + 1, path);
if self.incomplete || self.used {
return;
}
}
}
fn walk_appearances(&mut self, object: &Object, resources: &Dict, depth: usize, path: &mut std::collections::HashSet<ObjRef>) {
if self.used || self.incomplete {
return;
}
if !self.charge_visit() {
return;
}
if depth > IMAGE_CENSUS_MAX_DEPTH {
self.incomplete = true;
return;
}
let reference = object.as_ref();
match &*self.doc.resolve(object) {
Object::Stream(_) => self.walk_stream(object, resources, depth + 1, path),
Object::Dict(dict) => {
if let Some(reference) = reference
&& !path.insert(reference)
{
self.incomplete = true;
return;
}
if dict.contains(b"Resources") {
self.incomplete = true;
return;
}
for (_, value) in dict.iter() {
self.walk_appearances(value, resources, depth + 1, path);
if self.incomplete || self.used {
return;
}
}
if let Some(reference) = reference {
path.remove(&reference);
}
}
Object::Array(values) => {
if let Some(reference) = reference
&& !path.insert(reference)
{
self.incomplete = true;
return;
}
for value in values {
self.walk_appearances(value, resources, depth + 1, path);
if self.incomplete || self.used {
return;
}
}
if let Some(reference) = reference {
path.remove(&reference);
}
}
_ => {}
}
}
}
/// Remove a deleted/replaced image from every resource dictionary only after a bounded census
/// proves no page, Form, Type3 glyph, soft mask, tiling pattern, or annotation appearance uses it.
/// Any unsupported or malformed consumer preserves the image and still lets the edit succeed.
fn prune_unused_image(doc: &mut Document, target: ObjRef) {
let object_count = doc.scan_objects_checked().take(IMAGE_CENSUS_MAX_OBJECTS + 1).count();
if object_count > IMAGE_CENSUS_MAX_OBJECTS {
return;
}
let mut census = ImageUseCensus { doc, target, used: false, incomplete: false, bytes: 0, operators: 0, visits: 0 };
let mut path = std::collections::HashSet::new();
let pages = pdfcraft_model::pages(doc);
if pages.len() > IMAGE_CENSUS_MAX_OBJECTS {
return;
}
for page in &pages {
let Some(resources) = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()) else {
census.incomplete = true;
break;
};
let contents_object = page.dict.get(b"Contents").map(|value| doc.resolve(value));
let contents: &[Object] = match contents_object.as_deref() {
None => &[],
Some(Object::Array(items)) => items,
Some(value) => std::slice::from_ref(value),
};
if contents.len() > IMAGE_CENSUS_MAX_VISITS {
census.incomplete = true;
break;
}
let mut content_data = Vec::with_capacity(contents.len());
for content in contents {
let resolved = doc.resolve(content);
let Object::Stream(stream) = &*resolved else {
census.incomplete = true;
break;
};
if let Some(data) = census.charge_stream(stream) {
content_data.push(data);
}
if census.incomplete {
break;
}
}
if !census.incomplete {
census.walk_parsed(Pieces::join(&content_data).parse(), &resources, 0, &mut path);
}
let annots = page.dict.get(b"Annots").map(|o| doc.resolve(o));
if let Some(annots) = annots {
let Some(items) = annots.as_array() else {
census.incomplete = true;
break;
};
if items.len() > IMAGE_CENSUS_MAX_OBJECTS {
census.incomplete = true;
break;
}
for item in items {
if !census.charge_visit() {
break;
}
let resolved = doc.resolve(item);
let Some(ap) = resolved.as_dict().and_then(|d| d.get(b"AP")) else { continue };
census.walk_appearances(ap, &resources, 0, &mut path);
if census.incomplete || census.used {
break;
}
}
}
if census.incomplete || census.used {
break;
}
}
if census.used || census.incomplete {
return;
}
// Unknown stream types carrying their own resource namespace could also consume XObjects.
let checked: Vec<_> = doc.scan_objects_checked().collect();
if checked.len() > IMAGE_CENSUS_MAX_OBJECTS || checked.iter().any(|(_, item)| item.is_err()) {
return;
}
if checked.iter().any(|(_, item)| {
item.as_ref().ok().is_some_and(|o| matches!(o.as_ref(), Object::Stream(s) if s.dict.contains(b"Resources") && s.dict.name(b"Subtype") != Some(b"Form") && s.dict.int(b"PatternType") != Some(1)))
}) {
return;
}
let mut staged = std::collections::HashMap::new();
let mut indirect_xobject_dicts = std::collections::HashSet::new();
let mut indirect_alternate_arrays = std::collections::HashSet::new();
let mut aliases = 0usize;
let mut mapping_visits = 0usize;
let mut dependency_visits = 0usize;
for (reference, item) in checked {
let Ok(object) = item else { return };
let mut object = (*object).clone();
let mappings_changed = match prune_xobject_mappings(&mut object, target, &mut indirect_xobject_dicts, &mut aliases, 0, &mut mapping_visits) {
Ok(changed) => changed,
Err(()) => return,
};
let dependencies_changed =
match prune_image_dependencies(&mut object, doc, target, &mut indirect_alternate_arrays, &mut aliases, 0, &mut dependency_visits) {
Ok(changed) => changed,
Err(()) => return,
};
if mappings_changed || dependencies_changed {
if aliases > IMAGE_CENSUS_MAX_ALIASES {
return;
}
staged.insert(reference, object);
}
}
for reference in indirect_xobject_dicts {
let was_staged = staged.contains_key(&reference);
let mut object = staged.remove(&reference).unwrap_or_else(|| (*doc.get(reference)).clone());
let changed = match remove_target_aliases(object.as_dict_mut(), target, &mut aliases) {
Ok(changed) => changed,
Err(()) => return,
};
if changed || was_staged {
if aliases > IMAGE_CENSUS_MAX_ALIASES {
return;
}
staged.insert(reference, object);
}
}
for reference in indirect_alternate_arrays {
let was_staged = staged.contains_key(&reference);
let mut object = staged.remove(&reference).unwrap_or_else(|| (*doc.get(reference)).clone());
let changed = match remove_target_alternate_entries(&mut object, doc, target, &mut aliases, &mut dependency_visits) {
Ok(changed) => changed,
Err(()) => return,
};
if changed {
if aliases > IMAGE_CENSUS_MAX_ALIASES {
return;
}
staged.insert(reference, object);
} else if !matches!(&object, Object::Array(_)) {
// An indirect `/Alternates` value that is not an array is malformed. Since the
// census cannot establish whether it retains the target, preserve the whole edit.
return;
} else if was_staged {
staged.insert(reference, object);
}
}
for (reference, object) in staged {
doc.set(reference, object);
}
}
fn remove_target_aliases(dict: Option<&mut Dict>, target: ObjRef, aliases: &mut usize) -> Result<bool, ()> {
let Some(dict) = dict else { return Ok(false) };
if dict.iter().take(IMAGE_CENSUS_MAX_VISITS + 1).count() > IMAGE_CENSUS_MAX_VISITS {
return Err(());
}
let mut names = Vec::new();
for (name, value) in dict.iter() {
if value.as_ref() == Some(target) {
if aliases.saturating_add(names.len()) >= IMAGE_CENSUS_MAX_ALIASES {
return Err(());
}
names.push(name.clone());
}
}
for name in &names {
dict.remove(name);
}
*aliases = aliases.saturating_add(names.len());
Ok(!names.is_empty())
}
fn prune_xobject_mappings(
object: &mut Object,
target: ObjRef,
indirect: &mut std::collections::HashSet<ObjRef>,
aliases: &mut usize,
depth: usize,
visits: &mut usize,
) -> Result<bool, ()> {
charge_object_walk(depth, visits)?;
let mut changed = false;
match object {
Object::Array(values) => {
for value in values {
changed |= prune_xobject_mappings(value, target, indirect, aliases, depth + 1, visits)?;
}
}
Object::Dict(dict) | Object::Stream(pdfcraft_cos::Stream { dict, .. }) => {
if let Some(xobjects) = dict.get_mut(b"XObject") {
match xobjects {
Object::Ref(reference) => {
indirect.insert(*reference);
}
Object::Dict(mappings) => changed |= remove_target_aliases(Some(mappings), target, aliases)?,
_ => {}
}
}
let mut keys = Vec::new();
for (key, _) in dict.iter() {
if keys.len() >= IMAGE_CENSUS_MAX_VISITS {
return Err(());
}
keys.push(key.clone());
}
for key in keys {
if let Some(value) = dict.get_mut(&key) {
changed |= prune_xobject_mappings(value, target, indirect, aliases, depth + 1, visits)?;
}
}
}
_ => {}
}
Ok(changed)
}
fn contains_target_ref(object: &Object, target: ObjRef, depth: usize, visits: &mut usize) -> Result<bool, ()> {
charge_object_walk(depth, visits)?;
match object {
Object::Ref(reference) => Ok(*reference == target),
Object::Array(values) => {
for value in values {
if contains_target_ref(value, target, depth + 1, visits)? {
return Ok(true);
}
}
Ok(false)
}
_ => Ok(false),
}
}
fn alternate_uses_target(object: &Object, doc: &Document, target: ObjRef) -> bool {
doc.resolve(object).as_dict().and_then(|dict| dict.get(b"Image")).is_some_and(|image| image.as_ref() == Some(target))
}
fn remove_target_alternate_entries(object: &mut Object, doc: &Document, target: ObjRef, aliases: &mut usize, visits: &mut usize) -> Result<bool, ()> {
let Object::Array(entries) = object else { return Ok(false) };
let mut retained = Vec::new();
let mut removed = 0;
for entry in entries.iter() {
charge_object_walk(1, visits)?;
if alternate_uses_target(entry, doc, target) {
if aliases.saturating_add(removed) >= IMAGE_CENSUS_MAX_ALIASES {
return Err(());
}
removed += 1;
} else {
retained.push(entry.clone());
}
}
if removed != 0 {
*entries = retained;
*aliases = aliases.saturating_add(removed);
}
Ok(removed != 0)
}
/// Remove dependencies on an image only after the document census proves no active consumer
/// uses it. The dependent image is left in place, but its stale optional mask/alternate edge is
/// removed so full-save garbage collection can release the target bytes.
fn prune_image_dependencies(
object: &mut Object,
doc: &Document,
target: ObjRef,
indirect_alternate_arrays: &mut std::collections::HashSet<ObjRef>,
aliases: &mut usize,
depth: usize,
visits: &mut usize,
) -> Result<bool, ()> {
charge_object_walk(depth, visits)?;
let mut changed = false;
match object {
Object::Array(values) => {
for value in values {
changed |= prune_image_dependencies(value, doc, target, indirect_alternate_arrays, aliases, depth + 1, visits)?;
}
}
Object::Dict(dict) | Object::Stream(pdfcraft_cos::Stream { dict, .. }) => {
if dict.name(b"Subtype") == Some(b"Image") {
for key in [b"SMask".as_slice(), b"Mask".as_slice()] {
let contains_target =
if let Some(value) = dict.get(key) { contains_target_ref(value, target, depth + 1, visits)? } else { false };
if contains_target {
if aliases.saturating_add(1) > IMAGE_CENSUS_MAX_ALIASES {
return Err(());
}
dict.remove(key);
*aliases = aliases.saturating_add(1);
changed = true;
}
}
match dict.get(b"Alternates") {
Some(Object::Array(_)) => {
let Some(Object::Array(entries)) = dict.get_mut(b"Alternates") else { return Err(()) };
let mut retained = Vec::new();
let mut removed = 0;
for entry in entries.iter() {
charge_object_walk(depth + 1, visits)?;
if alternate_uses_target(entry, doc, target) {
if aliases.saturating_add(removed) >= IMAGE_CENSUS_MAX_ALIASES {
return Err(());
}
removed += 1;
} else {
retained.push(entry.clone());
}
}
if removed != 0 {
*entries = retained;
*aliases = aliases.saturating_add(removed);
changed = true;
}
}
Some(Object::Ref(reference)) => {
indirect_alternate_arrays.insert(*reference);
}
_ => {}
}
}
let mut keys = Vec::new();
for (key, _) in dict.iter() {
if keys.len() >= IMAGE_CENSUS_MAX_VISITS {
return Err(());
}
keys.push(key.clone());
}
for key in keys {
if let Some(value) = dict.get_mut(&key) {
changed |= prune_image_dependencies(value, doc, target, indirect_alternate_arrays, aliases, depth + 1, visits)?;
}
}
}
_ => {}
}
Ok(changed)
}
fn charge_object_walk(depth: usize, visits: &mut usize) -> Result<(), ()> {
if depth > IMAGE_CENSUS_MAX_DEPTH {
return Err(());
}
*visits = visits.saturating_add(1);
if *visits > IMAGE_CENSUS_MAX_VISITS { Err(()) } else { Ok(()) }
}
fn intersection(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> {
@@ -522,8 +1186,8 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
if !ops.get(img.op).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation is missing".into()));
}
let old_name = ops.get(img.op).and_then(|op| op.name(0)).map(<[u8]>::to_vec);
let old_object = img.object;
let prune_target = matches!(change, ImageChange::Delete | ImageChange::Replace(_)).then_some(old_object).flatten();
let mut resources = None;
let replacement: Vec<Op> = match change {
ImageChange::Delete => Vec::new(),
@@ -549,47 +1213,6 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
}
};
let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) });
if let Some(old_name) = old_name.as_deref() {
let rewritten = Pieces::join(&changed.iter().map(Vec::as_slice).collect::<Vec<_>>());
let rewritten_ops = rewritten.parse().ops;
let current_resources = resources
.as_ref()
.cloned()
.unwrap_or_else(|| p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default());
let xo = current_resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let mut aliases = Vec::new();
for (resource_name, value) in xo.iter() {
let matches_object = old_object.is_some() && value.as_ref() == old_object;
if matches_object || resource_name.as_slice() == old_name {
if aliases.len() >= 256 {
return Err(EditError::Invalid("the image has too many resource aliases to update safely".into()));
}
aliases.push(resource_name.clone());
}
}
if aliases.is_empty() {
aliases.push(old_name.to_vec());
}
let mut unused = Vec::new();
let mut form_scan_budget = 4096;
for resource_name in aliases {
let used_on_page = rewritten_ops.iter().any(|op| op.is("Do") && op.name(0) == Some(resource_name.as_slice()));
let used_in_form =
!used_on_page && resource_used_by_inheriting_forms(doc, &rewritten_ops, &current_resources, &resource_name, &mut form_scan_budget);
if !used_on_page && !used_in_form {
unused.push(resource_name);
}
}
if !unused.is_empty() {
let mut res = resources.take().unwrap_or(current_resources);
let mut xo = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
for resource_name in &unused {
xo.remove(resource_name);
}
res.set(b"XObject".to_vec(), Object::Dict(xo));
resources = Some(res);
}
}
let contents = crate::text::rewritten_contents(doc, &all, changed);
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), contents);
@@ -597,6 +1220,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
d.set(b"Resources".to_vec(), Object::Dict(res));
}
})?;
if let Some(target) = prune_target {
prune_unused_image(doc, target);
}
Ok(())
}
+567 -4
View File
@@ -2196,6 +2196,7 @@ fn deleting_a_page_image_keeps_it_when_a_nested_resource_less_form_inherits_it()
let mut form = Dict::new();
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([0, 0, 300, 300].map(Object::Int).to_vec()));
form.set(b"Resources".to_vec(), Object::Null);
let inner = doc.add(Object::Stream(Stream::flate(form.clone(), b"/Im0 Do")));
let outer = doc.add(Object::Stream(Stream::flate(form, b"/Inner Do")));
let page = pdfcraft_model::pages(&doc)[0].obj;
@@ -2220,7 +2221,567 @@ fn deleting_a_page_image_keeps_it_when_a_nested_resource_less_form_inherits_it()
}
#[test]
fn deleting_one_page_image_detaches_inherited_resources_without_changing_sibling_pages() {
fn deleting_an_image_with_too_many_aliases_skips_optional_pruning() {
const PRIVATE: &[u8] = b"PRIVATE_HIGH_ALIAS_COUNT_IMAGE";
let mut doc = image_page();
let image = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(image, Object::Stream(identifiable_test_image(PRIVATE)));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
let mut resources = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
let mut xobjects = resources.get(b"XObject").and_then(Object::as_dict).cloned().unwrap();
for i in 0..256 {
xobjects.set(format!("Alias{i}").into_bytes(), Object::Ref(image));
}
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE), "over-limit optional pruning unexpectedly removed the image");
let saved = Document::open(Arc::new(bytes)).unwrap();
assert!(images::page_images(&saved, 0).unwrap().is_empty());
assert!(xobject(&saved, 0, b"Alias0").is_some());
}
fn set_image_content(doc: &mut Document, page: usize, pieces: &[&[u8]]) {
let page = pdfcraft_model::pages(doc)[page].obj;
let contents = pieces.iter().map(|data| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), data))))).collect::<Vec<_>>();
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(contents))).unwrap();
}
#[test]
fn deleted_image_is_kept_when_reachable_through_each_non_page_consumer() {
const PRIVATE: &[u8] = b"PRIVATE_CONTEXT_IMAGE";
// Type 3 CharProc content uses the caller's resource scope when the font has no Resources.
{
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let glyph = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Im0 Do")));
let mut charprocs = Dict::new();
charprocs.set(b"A".to_vec(), Object::Ref(glyph));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type3"));
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
let font = doc.add(Object::Dict(font));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
let mut resources = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
let mut fonts = resources.get(b"Font").and_then(Object::as_dict).cloned().unwrap();
fonts.set(b"T3".to_vec(), Object::Ref(font));
resources.set(b"Font".to_vec(), Object::Dict(fonts));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
set_image_content(&mut doc, 0, &[b"q 200 0 0 100 100 100 cm /Im0 Do Q BT /T3 10 Tf (A) Tj ET"]);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
assert!(write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE));
}
// A selected soft mask's Form inherits the page's resources.
{
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let form = doc.add(Object::Stream(Stream::flate(
Dict::from_iter([(b"Subtype".to_vec(), Object::name("Form")), (b"BBox".to_vec(), Object::Array([0, 0, 1, 1].map(Object::Int).to_vec()))]),
b"/Im0 Do",
)));
let mut soft_mask = Dict::new();
soft_mask.set(b"G".to_vec(), Object::Ref(form));
let mut gs = Dict::new();
gs.set(b"SMask".to_vec(), Object::Dict(soft_mask));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
let mut resources = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
let mut states = Dict::new();
states.set(b"GS".to_vec(), Object::Dict(gs));
resources.set(b"ExtGState".to_vec(), Object::Dict(states));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
set_image_content(&mut doc, 0, &[b"q 200 0 0 100 100 100 cm /Im0 Do Q /GS gs"]);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
assert!(write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE));
}
// A used tiling Pattern can draw an image with its caller's resources.
{
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let mut pattern_dict = Dict::new();
for (key, value) in [
(&b"PatternType"[..], Object::Int(1)),
(b"PaintType", Object::Int(1)),
(b"TilingType", Object::Int(1)),
(b"BBox", Object::Array([0, 0, 1, 1].map(Object::Int).to_vec())),
(b"XStep", Object::Int(1)),
(b"YStep", Object::Int(1)),
] {
pattern_dict.set(key.to_vec(), value);
}
let pattern = doc.add(Object::Stream(Stream::flate(pattern_dict, b"/Im0 Do")));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
let mut resources = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
let mut patterns = Dict::new();
patterns.set(b"P".to_vec(), Object::Ref(pattern));
resources.set(b"Pattern".to_vec(), Object::Dict(patterns));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
set_image_content(&mut doc, 0, &[b"q 200 0 0 100 100 100 cm /Im0 Do Q 0 /P scn 0 0 10 10 re f"]);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
assert!(write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE));
}
// Every annotation appearance state is a consumer, even if it is not the normal state.
{
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let form = doc.add(Object::Stream(Stream::flate(
Dict::from_iter([
(b"Subtype".to_vec(), Object::name("Form")),
(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec())),
]),
b"/Im0 Do",
)));
let mut states = Dict::new();
states.set(b"Off".to_vec(), Object::Ref(form));
let mut ap = Dict::new();
ap.set(b"D".to_vec(), Object::Dict(states));
let mut annotation = Dict::new();
annotation.set(b"AP".to_vec(), Object::Dict(ap));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(annotation)]))).unwrap();
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
assert!(write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE));
}
}
#[test]
fn deleted_image_census_joins_content_pieces_and_checks_each_page_scope() {
const PRIVATE: &[u8] = b"PRIVATE_SCOPED_IMAGE";
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
set_image_content(&mut doc, 0, &[b"q 200 0 0 100 100 100 cm /Im0 Do Q /Im0", b" Do"]);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
assert!(
write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE),
"the retained second Do spans content pieces"
);
// A shared Form is evaluated in each caller's effective resource scope. The first page maps
// /Im0 to a different image; the second page maps it to the deleted target.
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let other = doc.add(Object::Stream(identifiable_test_image(b"OTHER_SCOPED_IMAGE")));
let shared = doc.add(Object::Stream(Stream::flate(
Dict::from_iter([
(b"Subtype".to_vec(), Object::name("Form")),
(b"BBox".to_vec(), Object::Array([0, 0, 1, 1].map(Object::Int).to_vec())),
(b"Resources".to_vec(), Object::Null),
]),
b"/Im0 Do",
)));
let page0 = pdfcraft_model::pages(&doc)[0].obj;
let mut res0 = doc.get(page0).as_dict().unwrap().get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
let mut xo0 = res0.get(b"XObject").and_then(Object::as_dict).cloned().unwrap();
xo0.set(b"Target".to_vec(), Object::Ref(target));
xo0.set(b"Other".to_vec(), Object::Ref(other));
xo0.set(b"Im0".to_vec(), Object::Ref(other));
xo0.set(b"Shared".to_vec(), Object::Ref(shared));
res0.set(b"XObject".to_vec(), Object::Dict(xo0));
doc.update_dict(page0, |d| {
d.set(b"Resources".to_vec(), Object::Dict(res0));
})
.unwrap();
set_image_content(&mut doc, 0, &[b"/Target Do /Shared Do"]);
let page1_contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Shared Do")));
let page1 = doc.add(Object::Dict(Dict::from_iter([
(b"Type".to_vec(), Object::name("Page")),
(b"Parent".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(2, 0))),
(b"MediaBox".to_vec(), Object::Array([0, 0, 600, 800].map(Object::Int).to_vec())),
(
b"Resources".to_vec(),
Object::Dict(Dict::from_iter([(
b"XObject".to_vec(),
Object::Dict(Dict::from_iter([(b"Im0".to_vec(), Object::Ref(target)), (b"Shared".to_vec(), Object::Ref(shared))])),
)])),
),
(b"Contents".to_vec(), Object::Ref(page1_contents)),
])));
doc.update_dict(pdfcraft_cos::ObjRef::new(2, 0), |d| {
let mut kids = d.get(b"Kids").and_then(Object::as_array).cloned().unwrap();
kids.push(Object::Ref(page1));
d.set(b"Kids".to_vec(), Object::Array(kids));
d.set(b"Count".to_vec(), Object::Int(2));
})
.unwrap();
assert_eq!(images::page_images(&doc, 0).unwrap().len(), 2);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
assert!(
write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE),
"the same Form must use page one's different inherited scope"
);
}
#[test]
fn extgstate_selected_type3_font_keeps_its_glyph_image_after_page_image_delete() {
const PRIVATE: &[u8] = b"PRIVATE_GSTATE_TYPE3_GLYPH_IMAGE";
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let glyph = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/GlyphImg Do")));
let mut charprocs = Dict::new();
charprocs.set(b"A".to_vec(), Object::Ref(glyph));
let glyph_resources = Dict::from_iter([(b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"GlyphImg".to_vec(), Object::Ref(target))])))]);
let mut font_dict = Dict::new();
for (key, value) in [
(&b"Type"[..], Object::name("Font")),
(b"Subtype", Object::name("Type3")),
(b"FontBBox", Object::Array([0, 0, 1000, 1000].map(Object::Int).to_vec())),
(b"FontMatrix", Object::Array(vec![Object::Int(1), Object::Int(0), Object::Int(0), Object::Int(1), Object::Int(0), Object::Int(0)])),
(b"FirstChar", Object::Int(65)),
(b"LastChar", Object::Int(65)),
(b"Widths", Object::Array(vec![Object::Int(1000)])),
(b"Encoding", Object::Dict(Dict::from_iter([(b"Differences".to_vec(), Object::Array(vec![Object::Int(65), Object::name("A")]))]))),
(b"CharProcs", Object::Dict(charprocs)),
(b"Resources", Object::Dict(glyph_resources)),
] {
font_dict.set(key.to_vec(), value);
}
let font = doc.add(Object::Dict(font_dict));
let ext_gstate = Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Array(vec![Object::Ref(font), Object::Int(10)]))]));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
let mut resources = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
resources.set(b"ExtGState".to_vec(), Object::Dict(Dict::from_iter([(b"GS".to_vec(), ext_gstate)])));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
set_image_content(&mut doc, 0, &[b"q 200 0 0 100 100 100 cm /Im0 Do Q /GS gs BT (A) Tj ET"]);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE));
let saved = Document::open(Arc::new(bytes)).unwrap();
let page = pdfcraft_model::pages(&saved)[0].clone();
let resources = page.dict.get(b"Resources").map(|o| saved.resolve(o)).unwrap();
let ext_gstates = resources.as_dict().unwrap().get(b"ExtGState").map(|o| saved.resolve(o)).unwrap();
let selected = ext_gstates.as_dict().unwrap().get(b"GS").map(|o| saved.resolve(o)).unwrap();
let font_ref = selected.as_dict().unwrap().get(b"Font").map(|o| saved.resolve(o)).unwrap().as_array().unwrap()[0].as_ref().unwrap();
let font = saved.get(font_ref);
let charproc_ref =
font.as_dict().unwrap().get(b"CharProcs").map(|o| saved.resolve(o)).unwrap().as_dict().unwrap().get(b"A").unwrap().as_ref().unwrap();
let charproc = saved.get(charproc_ref);
let glyph_content = match charproc.as_ref() {
Object::Stream(stream) => String::from_utf8_lossy(&stream.decoded().unwrap()).into_owned(),
_ => String::new(),
};
assert!(glyph_content.contains("/GlyphImg Do"));
let font_resources = font.as_dict().unwrap().get(b"Resources").map(|o| saved.resolve(o)).unwrap();
let glyph_image = font_resources.as_dict().unwrap().get(b"XObject").map(|o| saved.resolve(o)).unwrap();
assert_eq!(glyph_image.as_dict().unwrap().get(b"GlyphImg").and_then(Object::as_ref), Some(target));
}
#[test]
fn appearance_dictionary_dag_exhaustion_skips_optional_image_pruning() {
const PRIVATE: &[u8] = b"PRIVATE_APPEARANCE_DAG_IMAGE";
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let mut child = doc.add(Object::Dict(Dict::new()));
for _ in 0..30 {
child = doc.add(Object::Dict(Dict::from_iter([(b"Left".to_vec(), Object::Ref(child)), (b"Right".to_vec(), Object::Ref(child))])));
}
let appearance = Dict::from_iter([(b"N".to_vec(), Object::Ref(child))]);
let annotation = Dict::from_iter([(b"AP".to_vec(), Object::Dict(appearance))]);
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(annotation)]))).unwrap();
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE));
}
#[test]
fn unused_image_mask_dependency_is_removed_before_full_save() {
const PRIVATE: &[u8] = b"PRIVATE_UNUSED_IMAGE_MASK_DEPENDENCY";
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let mut unused_image = identifiable_test_image(b"INACTIVE_IMAGE_OWNER");
unused_image.dict.set(b"SMask".to_vec(), Object::Ref(target));
let unused = doc.add(Object::Stream(unused_image));
let mut mask_owner = identifiable_test_image(b"INACTIVE_MASK_OWNER");
mask_owner.dict.set(b"Mask".to_vec(), Object::Ref(target));
let mask_owner = doc.add(Object::Stream(mask_owner));
let mut alternate = Dict::new();
alternate.set(b"Image".to_vec(), Object::Ref(target));
let mut alternate_owner = identifiable_test_image(b"INACTIVE_ALTERNATE_OWNER");
alternate_owner.dict.set(b"Alternates".to_vec(), Object::Array(vec![Object::Dict(alternate)]));
let alternate_owner = doc.add(Object::Stream(alternate_owner));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
let mut resources = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
let mut xobjects = resources.get(b"XObject").and_then(Object::as_dict).cloned().unwrap();
xobjects.set(b"Unused".to_vec(), Object::Ref(unused));
xobjects.set(b"MaskOwner".to_vec(), Object::Ref(mask_owner));
xobjects.set(b"AlternateOwner".to_vec(), Object::Ref(alternate_owner));
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(!bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE));
let saved = Document::open(Arc::new(bytes)).unwrap();
let unused = saved.get(xobject(&saved, 0, b"Unused").unwrap());
assert!(matches!(unused.as_ref(), Object::Stream(stream) if stream.dict.get(b"SMask").is_none()));
let mask_owner = saved.get(xobject(&saved, 0, b"MaskOwner").unwrap());
assert!(matches!(mask_owner.as_ref(), Object::Stream(stream) if stream.dict.get(b"Mask").is_none()));
let alternate_owner = saved.get(xobject(&saved, 0, b"AlternateOwner").unwrap());
assert!(
matches!(alternate_owner.as_ref(), Object::Stream(stream) if stream.dict.get(b"Alternates").and_then(Object::as_array).is_some_and(Vec::is_empty))
);
}
#[test]
fn deleting_image_prunes_unused_shared_resources_across_all_pages() {
const PRIVATE: &[u8] = b"PRIVATE_UNUSED_SHARED_IMAGE";
let mut doc = fixture();
let target = doc.add(Object::Stream(identifiable_test_image(PRIVATE)));
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
let mut xobjects = Dict::new();
xobjects.set(b"Im0".to_vec(), Object::Ref(target));
d.set(b"XObject".to_vec(), Object::Dict(xobjects));
})
.unwrap();
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Im0 Do")));
doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| d.set(b"Contents".to_vec(), Object::Ref(content))).unwrap();
assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(!bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE));
let saved = Document::open(Arc::new(bytes)).unwrap();
assert!(xobject(&saved, 0, b"Im0").is_none());
assert!(xobject(&saved, 1, b"Im0").is_none());
}
#[test]
fn image_mask_dependency_keeps_a_deleted_image_live() {
const PRIVATE: &[u8] = b"PRIVATE_IMAGE_MASK_DEPENDENCY";
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let mut dependent = Dict::new();
dependent.set(b"Type".to_vec(), Object::name("XObject"));
dependent.set(b"Subtype".to_vec(), Object::name("Image"));
dependent.set(b"Width".to_vec(), Object::Int(1));
dependent.set(b"Height".to_vec(), Object::Int(1));
dependent.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
dependent.set(b"BitsPerComponent".to_vec(), Object::Int(8));
dependent.set(b"SMask".to_vec(), Object::Ref(target));
let dependent = doc.add(Object::Stream(Stream::from_raw(dependent, vec![1])));
let mut chain_owner = Dict::new();
chain_owner.set(b"Type".to_vec(), Object::name("XObject"));
chain_owner.set(b"Subtype".to_vec(), Object::name("Image"));
chain_owner.set(b"Width".to_vec(), Object::Int(1));
chain_owner.set(b"Height".to_vec(), Object::Int(1));
chain_owner.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
chain_owner.set(b"BitsPerComponent".to_vec(), Object::Int(8));
chain_owner.set(b"SMask".to_vec(), Object::Ref(dependent));
let chain_owner = doc.add(Object::Stream(Stream::from_raw(chain_owner, vec![1])));
set_image_content(&mut doc, 0, &[b"q 200 0 0 100 100 100 cm /Im0 Do Q /Source Do"]);
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
let mut resources = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
let mut xobjects = resources.get(b"XObject").and_then(Object::as_dict).cloned().unwrap();
xobjects.set(b"Source".to_vec(), Object::Ref(chain_owner));
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
assert!(write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE));
}
#[test]
fn malformed_extgstate_soft_masks_skip_optional_image_pruning() {
const PRIVATE: &[u8] = b"PRIVATE_EXTGSTATE_IMAGE";
let cases = [
("unresolved reference", Object::Ref(pdfcraft_cos::ObjRef::new(999_999, 0))),
("non-dictionary", Object::String(pdfcraft_cos::PdfString::text("bad mask"))),
("missing group", Object::Dict(Dict::new())),
("malformed group", Object::Dict(Dict::from_iter([(b"G".to_vec(), Object::Int(7))]))),
];
for (case, mask) in cases {
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let mut gs = Dict::new();
gs.set(b"SMask".to_vec(), mask);
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
let mut resources = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
resources.set(b"ExtGState".to_vec(), Object::Dict(Dict::from_iter([(b"GS".to_vec(), Object::Dict(gs))])));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
set_image_content(&mut doc, 0, &[b"q 200 0 0 100 100 100 cm /Im0 Do Q /GS gs"]);
assert!(
write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE),
"{case} fixture lacked payload before edit"
);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let saved = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(saved.windows(PRIVATE.len()).any(|w| w == PRIVATE), "{case} unexpectedly allowed pruning");
}
// `/None` is the valid ExtGState spelling for no soft mask and does not block pruning.
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let mut gs = Dict::new();
gs.set(b"SMask".to_vec(), Object::name("None"));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
let mut resources = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
resources.set(b"ExtGState".to_vec(), Object::Dict(Dict::from_iter([(b"GS".to_vec(), Object::Dict(gs))])));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
set_image_content(&mut doc, 0, &[b"q 200 0 0 100 100 100 cm /Im0 Do Q /GS gs"]);
assert!(
write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE),
"valid /None fixture lacked payload before edit"
);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let saved = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(!saved.windows(PRIVATE.len()).any(|w| w == PRIVATE), "valid /None prevented pruning");
}
#[test]
fn image_typed_soft_mask_group_skips_optional_image_pruning() {
const PRIVATE: &[u8] = b"PRIVATE_IMAGE_TYPED_SOFT_MASK_GROUP";
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
// The renderer decodes `/G` as a Form regardless of its declared subtype. Its content
// inherits the page resources, so the image remains a live consumer despite the bad type.
let group = doc.add(Object::Stream(Stream::flate(
Dict::from_iter([(b"Subtype".to_vec(), Object::name("Image")), (b"BBox".to_vec(), Object::Array([0, 0, 1, 1].map(Object::Int).to_vec()))]),
b"/Im0 Do",
)));
let mut soft_mask = Dict::new();
soft_mask.set(b"G".to_vec(), Object::Ref(group));
let mut gs = Dict::new();
gs.set(b"SMask".to_vec(), Object::Dict(soft_mask));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
let mut resources = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
resources.set(b"ExtGState".to_vec(), Object::Dict(Dict::from_iter([(b"GS".to_vec(), Object::Dict(gs))])));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
set_image_content(&mut doc, 0, &[b"q 200 0 0 100 100 100 cm /Im0 Do Q /GS gs"]);
assert!(write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE), "fixture lacked payload before edit");
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let saved = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(saved.windows(PRIVATE.len()).any(|w| w == PRIVATE), "malformed image-typed SMask group unexpectedly allowed pruning");
}
#[test]
fn late_image_prune_staging_failure_does_not_partially_rewrite_resources() {
const PRIVATE: &[u8] = b"PRIVATE_LATE_PRUNE_FAILURE";
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
assert!(write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE), "fixture lacked payload before edit");
// This valid candidate is staged for removal before the later deeply nested object
// exceeds the rewrite walk budget. Nothing staged may be committed after that failure.
let holder =
doc.add(Object::Dict(Dict::from_iter([(b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Alias".to_vec(), Object::Ref(target))])))])));
let mut deep = Object::Null;
for _ in 0..=65 {
deep = Object::Array(vec![deep]);
}
doc.add(deep);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let holder = doc.resolve(&Object::Ref(holder));
assert_eq!(
holder.as_dict().and_then(|d| d.get(b"XObject")).and_then(Object::as_dict).and_then(|d| d.get(b"Alias")).and_then(Object::as_ref),
Some(target)
);
let saved = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(saved.windows(PRIVATE.len()).any(|w| w == PRIVATE), "failed optional prune partially removed the image payload");
}
#[test]
fn image_alternate_dependency_keeps_a_deleted_image_live() {
const PRIVATE: &[u8] = b"PRIVATE_IMAGE_ALTERNATE_DEPENDENCY";
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let mut alternate = Dict::new();
alternate.set(b"Image".to_vec(), Object::Ref(target));
let mut primary = identifiable_test_image(b"PRIMARY_IMAGE");
primary.dict.set(b"Alternates".to_vec(), Object::Array(vec![Object::Dict(alternate)]));
let primary = doc.add(Object::Stream(primary));
set_image_content(&mut doc, 0, &[b"q 200 0 0 100 100 100 cm /Im0 Do Q /Source Do"]);
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
let mut resources = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
let mut xobjects = resources.get(b"XObject").and_then(Object::as_dict).cloned().unwrap();
xobjects.set(b"Source".to_vec(), Object::Ref(primary));
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
assert!(write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE));
}
#[test]
fn unsupported_resource_consumer_skips_optional_pruning_but_keeps_edit_successful() {
const PRIVATE: &[u8] = b"PRIVATE_UNSUPPORTED_CONTEXT_IMAGE";
let mut doc = image_page();
let target = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
doc.set(target, Object::Stream(identifiable_test_image(PRIVATE)));
let mut unknown = Dict::new();
unknown.set(b"Subtype".to_vec(), Object::name("UnknownContent"));
unknown.set(
b"Resources".to_vec(),
Object::Dict(Dict::from_iter([(b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Im0".to_vec(), Object::Ref(target))])))])),
);
doc.add(Object::Stream(Stream::flate(unknown, b"/Im0 Do")));
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE));
}
#[test]
fn deleting_one_page_image_preserves_shared_inherited_resources_used_by_sibling_pages() {
const SHARED: &[u8] = b"SHARED_INHERITED_IMAGE";
let mut doc = fixture();
let image = doc.add(Object::Stream(identifiable_test_image(SHARED)));
@@ -2238,7 +2799,9 @@ fn deleting_one_page_image_detaches_inherited_resources_without_changing_sibling
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(images::page_images(&saved, 0).unwrap().is_empty());
assert_eq!(images::page_images(&saved, 1).unwrap().len(), 1);
assert!(xobject(&saved, 0, b"Im0").is_none());
// Both pages inherit the same resource dictionary. Keep its mapping while the sibling page
// still draws the image; removing it would break that live use.
assert!(xobject(&saved, 0, b"Im0").is_some());
assert!(xobject(&saved, 1, b"Im0").is_some());
}
@@ -2257,7 +2820,7 @@ fn replacing_an_image_with_the_same_object_keeps_the_replacement_reachable() {
}
#[test]
fn deleting_an_image_prunes_unused_aliases_but_keeps_aliases_still_drawn() {
fn deleting_an_image_prunes_unused_aliases_when_no_alias_is_drawn_and_keeps_live_aliases() {
const ALIASED: &[u8] = b"ALIASED_IMAGE_BYTES";
let mut doc = image_page();
let image = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
@@ -2293,7 +2856,7 @@ fn deleting_an_image_prunes_unused_aliases_but_keeps_aliases_still_drawn() {
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(images::page_images(&saved, 0).unwrap().len(), 1);
assert!(xobject(&saved, 0, b"Im0").is_none());
assert!(xobject(&saved, 0, b"Im0").is_some());
assert!(xobject(&saved, 0, b"Alias").is_some());
}