diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index e90d8e4f1..2b55bd090 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -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, + ) -> 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 = 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::>()); + 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); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 879ebc6c6..4f717c83f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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