mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
fix(edit): fail closed on malformed image consumers
This commit is contained in:
@@ -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
@@ -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, ¤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);
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user