mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
fix(edit): unlink unused image resources on delete
Fixes #3511 by pruning unreferenced resource names after deleting or replacing images, including aliases. Keep resources used by other placements, sibling pages, or nested Forms that inherit page resources.
This commit is contained in:
@@ -108,6 +108,67 @@ 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;
|
||||
}
|
||||
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 intersection(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> {
|
||||
let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])];
|
||||
(r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r)
|
||||
@@ -461,6 +522,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 mut resources = None;
|
||||
let replacement: Vec<Op> = match change {
|
||||
ImageChange::Delete => Vec::new(),
|
||||
@@ -486,6 +549,47 @@ 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, ¤t_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);
|
||||
|
||||
@@ -2133,6 +2133,170 @@ fn page_images_move_turn_replace_and_delete() {
|
||||
assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err());
|
||||
}
|
||||
|
||||
fn identifiable_test_image(bytes: &[u8]) -> Stream {
|
||||
let mut d = Dict::new();
|
||||
for (k, v) in [
|
||||
(&b"Type"[..], Object::name("XObject")),
|
||||
(b"Subtype", Object::name("Image")),
|
||||
(b"Width", Object::Int(4)),
|
||||
(b"Height", Object::Int(2)),
|
||||
(b"ColorSpace", Object::name("DeviceGray")),
|
||||
(b"BitsPerComponent", Object::Int(8)),
|
||||
] {
|
||||
d.set(k.to_vec(), v);
|
||||
}
|
||||
Stream::from_raw(d, bytes.to_vec())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_or_replacing_an_image_unlinks_its_unreferenced_resource_on_full_save() {
|
||||
const OLD: &[u8] = b"PRIVATE_IMAGE_BYTES_3499";
|
||||
const NEW: &[u8] = b"PUBLIC_IMAGE_BYTES_3511";
|
||||
|
||||
let mut deleted = image_page();
|
||||
let old = images::page_images(&deleted, 0).unwrap()[0].object.unwrap();
|
||||
deleted.set(old, Object::Stream(identifiable_test_image(OLD)));
|
||||
images::change_image(&mut deleted, 0, 0, &images::ImageChange::Delete).unwrap();
|
||||
let bytes = write_full(&deleted, &SaveOptions::default()).unwrap();
|
||||
assert!(!bytes.windows(OLD.len()).any(|w| w == OLD), "deleted image data survived the full save");
|
||||
let saved = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert_eq!(xobject(&saved, 0, b"Im0"), None, "deleted resource remains reachable from the page");
|
||||
|
||||
let mut replaced = image_page();
|
||||
let old = images::page_images(&replaced, 0).unwrap()[0].object.unwrap();
|
||||
replaced.set(old, Object::Stream(identifiable_test_image(OLD)));
|
||||
let new = replaced.add(Object::Stream(identifiable_test_image(NEW)));
|
||||
images::change_image(&mut replaced, 0, 0, &images::ImageChange::Replace(new)).unwrap();
|
||||
let bytes = write_full(&replaced, &SaveOptions::default()).unwrap();
|
||||
assert!(!bytes.windows(OLD.len()).any(|w| w == OLD), "replaced image data survived the full save");
|
||||
assert!(bytes.windows(NEW.len()).any(|w| w == NEW), "replacement image data is missing");
|
||||
let saved = Document::open(Arc::new(bytes)).unwrap();
|
||||
let images = images::page_images(&saved, 0).unwrap();
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!(images[0].name, format!("PCImg{}", new.num));
|
||||
assert!(images[0].object.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_of_two_placements_keeps_the_resource_still_in_use() {
|
||||
let mut doc = image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q q 200 0 0 100 300 100 cm /Im0 Do Q");
|
||||
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_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_page_image_keeps_it_when_a_nested_resource_less_form_inherits_it() {
|
||||
const PRIVATE: &[u8] = b"PRIVATE_NESTED_FORM_IMAGE";
|
||||
let mut doc = image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q /Bare Do");
|
||||
let image = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
|
||||
doc.set(image, Object::Stream(identifiable_test_image(PRIVATE)));
|
||||
|
||||
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()));
|
||||
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;
|
||||
let mut resources = doc.get(page).as_dict().unwrap().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"Inner".to_vec(), Object::Ref(inner));
|
||||
xobjects.set(b"Bare".to_vec(), Object::Ref(outer));
|
||||
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 200 0 0 100 100 100 cm /Im0 Do Q /Bare Do")));
|
||||
doc.update_dict(page, |d| {
|
||||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
d.set(b"Contents".to_vec(), Object::Ref(contents));
|
||||
})
|
||||
.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), "the nested form's image was lost");
|
||||
let saved = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert!(xobject(&saved, 0, b"Im0").is_some(), "the page resource inherited by the Form was removed");
|
||||
assert_eq!(images::reading_images(&saved, 0).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_page_image_detaches_inherited_resources_without_changing_sibling_pages() {
|
||||
const SHARED: &[u8] = b"SHARED_INHERITED_IMAGE";
|
||||
let mut doc = fixture();
|
||||
let image = doc.add(Object::Stream(identifiable_test_image(SHARED)));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
|
||||
let mut xobjects = Dict::new();
|
||||
xobjects.set(b"Im0".to_vec(), Object::Ref(image));
|
||||
d.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
})
|
||||
.unwrap();
|
||||
doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"/Im0 Do")));
|
||||
assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1);
|
||||
assert_eq!(images::page_images(&doc, 1).unwrap().len(), 1);
|
||||
|
||||
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!(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());
|
||||
assert!(xobject(&saved, 1, b"Im0").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_an_image_with_the_same_object_keeps_the_replacement_reachable() {
|
||||
const SAME: &[u8] = b"SAME_IMAGE_OBJECT";
|
||||
let mut doc = image_page();
|
||||
let image = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
|
||||
doc.set(image, Object::Stream(identifiable_test_image(SAME)));
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Replace(image)).unwrap();
|
||||
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
assert!(bytes.windows(SAME.len()).any(|w| w == SAME));
|
||||
let saved = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert_eq!(images::page_images(&saved, 0).unwrap().len(), 1);
|
||||
assert_eq!(images::page_images(&saved, 0).unwrap()[0].name, format!("PCImg{}", image.num));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_an_image_prunes_unused_aliases_but_keeps_aliases_still_drawn() {
|
||||
const ALIASED: &[u8] = b"ALIASED_IMAGE_BYTES";
|
||||
let mut doc = image_page();
|
||||
let image = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
|
||||
doc.set(image, Object::Stream(identifiable_test_image(ALIASED)));
|
||||
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"UnusedAlias".to_vec(), 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(ALIASED.len()).any(|w| w == ALIASED), "an unused alias kept the deleted image reachable");
|
||||
let saved = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert!(xobject(&saved, 0, b"Im0").is_none());
|
||||
assert!(xobject(&saved, 0, b"UnusedAlias").is_none());
|
||||
|
||||
let mut doc = image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q /Alias Do");
|
||||
let image = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
|
||||
doc.set(image, Object::Stream(identifiable_test_image(ALIASED)));
|
||||
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"Alias".to_vec(), 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 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"Alias").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_artwork_edits_only_the_selected_placement() {
|
||||
let mut doc = fixture(); // three pages share resources; pages 0 and 1 also share contents
|
||||
|
||||
Reference in New Issue
Block a user