mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 04:51:37 +00:00
fix(preflight): resolve indirect spot families, null dict entries, and numeric equality (#6679)
This commit is contained in:
1 parent
9e29af7849
commit
4d2aa29ee7
1 file changed
+158
-9
@@ -64,7 +64,7 @@ impl Scan<'_> {
|
||||
}
|
||||
match o {
|
||||
Object::Array(a) => {
|
||||
match a.first().and_then(Object::as_name) {
|
||||
match a.first().map(|v| self.doc.resolve(v)).as_deref().and_then(Object::as_name) {
|
||||
Some(b"Separation") => self.separation(a),
|
||||
Some(b"DeviceN") => self.devicen(a),
|
||||
_ => {}
|
||||
@@ -138,14 +138,23 @@ impl Scan<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `o` is `[/Separation name ...]` for the colorant `name`.
|
||||
/// Whether `o` is `[/Separation name alternateSpace tintTransform]` for the colorant `name`,
|
||||
/// with a non-null alternate space and tint transform (ISO 32000-1 8.6.6.4 and 7.3.9).
|
||||
fn separation_named(&self, o: &Object, name: &[u8]) -> bool {
|
||||
let o = self.doc.resolve(o);
|
||||
let Some([kind, n, ..]) = o.as_array().map(Vec::as_slice) else { return false };
|
||||
kind.as_name() == Some(b"Separation") && self.doc.resolve(n).as_name() == Some(name)
|
||||
let Some([kind, n, alt, tint, ..]) = o.as_array().map(Vec::as_slice) else { return false };
|
||||
self.doc.resolve(kind).as_name() == Some(b"Separation")
|
||||
&& self.doc.resolve(n).as_name() == Some(name)
|
||||
&& !matches!(&*self.doc.resolve(alt), Object::Null)
|
||||
&& !matches!(&*self.doc.resolve(tint), Object::Null)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether integer `i` and real `r` have the same numeric value (ISO 32000-1 7.3.3).
|
||||
fn same_number(i: i64, r: f64) -> bool {
|
||||
r.is_finite() && r.fract() == 0.0 && (i as f64) == r && (r as i64) == i && (i as f64 as i128) == i128::from(i)
|
||||
}
|
||||
|
||||
/// Compares definitions, remembering which reference pairs it found equal, so a structure shared
|
||||
/// by many definitions is compared once, within [`MAX_COMPARE_STEPS`] and [`MAX_COMPARE_BYTES`].
|
||||
struct Compare<'a> {
|
||||
@@ -158,13 +167,18 @@ struct Compare<'a> {
|
||||
}
|
||||
|
||||
impl Compare<'_> {
|
||||
/// Whether `v` is present (not `null` or a reference to no object, ISO 32000-1 7.3.7 and 7.3.9).
|
||||
fn is_present(&self, v: &Object) -> bool {
|
||||
!matches!(&*self.doc.resolve(v), Object::Null)
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` are the same object, wherever their parts are stored: references are
|
||||
/// followed (to [`MAX_COMPARE_DEPTH`]), so two identical streams under different object
|
||||
/// numbers are the same, and streams compare by their decoded data. Once the steps or the
|
||||
/// decode allowance run out, nothing more is called different.
|
||||
fn same(&mut self, a: &Object, b: &Object, depth: u8) -> bool {
|
||||
let pair = match (a, b) {
|
||||
(Object::Ref(x), Object::Ref(y)) if x == y || self.equal.contains(&(*x, *y)) => return true,
|
||||
(Object::Ref(x), Object::Ref(y)) if x == y || self.equal.contains(&(*x, *y)) || self.equal.contains(&(*y, *x)) => return true,
|
||||
(Object::Ref(x), Object::Ref(y)) => Some((*x, *y)),
|
||||
_ => None,
|
||||
};
|
||||
@@ -179,6 +193,7 @@ impl Compare<'_> {
|
||||
(Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| self.same(p, q, deeper)),
|
||||
(Object::Dict(x), Object::Dict(y)) => self.same_dict(x, y, &[], deeper),
|
||||
(Object::Stream(x), Object::Stream(y)) => self.same_data(x, y, deeper) && self.same_dict(&x.dict, &y.dict, &ENCODING, deeper),
|
||||
(Object::Int(x), Object::Real(y)) | (Object::Real(y), Object::Int(x)) => same_number(*x, *y),
|
||||
(x, y) => x == y,
|
||||
};
|
||||
if same && let Some(pair) = pair {
|
||||
@@ -187,16 +202,24 @@ impl Compare<'_> {
|
||||
same
|
||||
}
|
||||
|
||||
/// Whether two dictionaries hold the same entries, those keyed in `skip` aside.
|
||||
/// Whether two dictionaries hold the same non-null entries (ISO 32000-1 7.3.7), those keyed
|
||||
/// in `skip` aside.
|
||||
fn same_dict(&mut self, a: &Dict, b: &Dict, skip: &[&[u8]], depth: u8) -> bool {
|
||||
let kept = |d: &Dict| d.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).count();
|
||||
kept(a) == kept(b) && a.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).all(|(k, v)| b.get(k).is_some_and(|w| self.same(v, w, depth)))
|
||||
let kept = |c: &Self, d: &Dict| d.iter().filter(|(k, v)| !skip.contains(&k.as_slice()) && c.is_present(v)).count();
|
||||
if kept(self, a) != kept(self, b) {
|
||||
return false;
|
||||
}
|
||||
a.iter()
|
||||
.filter(|(k, v)| !skip.contains(&k.as_slice()) && self.is_present(v))
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.all(|(k, v)| b.get(k).filter(|w| self.is_present(w)).is_some_and(|w| self.same(v, w, depth)))
|
||||
}
|
||||
|
||||
/// Whether two streams hold the same data, however each is encoded. Data that can't be
|
||||
/// decoded within the allowance is not called different.
|
||||
fn same_data(&mut self, a: &Stream, b: &Stream, depth: u8) -> bool {
|
||||
let stored_alike = |c: &mut Self, k: &[u8]| match (a.dict.get(k), b.dict.get(k)) {
|
||||
let stored_alike = |c: &mut Self, k: &[u8]| match (a.dict.get(k).filter(|v| c.is_present(v)), b.dict.get(k).filter(|v| c.is_present(v))) {
|
||||
(None, None) => true,
|
||||
(Some(x), Some(y)) => c.same(x, y, depth),
|
||||
_ => false,
|
||||
@@ -210,3 +233,129 @@ impl Compare<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
|
||||
use super::{Findings, check, same_number};
|
||||
use crate::{Level, objects, verify};
|
||||
|
||||
fn build(objs: &[&str]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
let id = "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]";
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {id} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
fn spot_findings(spaces: &str, extra: &[&str]) -> Findings {
|
||||
let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>");
|
||||
let mut objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||||
&page,
|
||||
"<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>",
|
||||
"<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 1 0] /N 1 >>",
|
||||
"/Separation",
|
||||
"/DeviceN",
|
||||
];
|
||||
objs.extend_from_slice(extra);
|
||||
let doc = build(&objs);
|
||||
let verified: Vec<String> = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect();
|
||||
let found = check(&doc, &objects(&doc));
|
||||
assert_eq!(verified.len(), found.differing.len() + found.undefined.len());
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_indirect_separation_and_devicen_family_names() {
|
||||
// Object 6 is `/Separation` and object 7 is `/DeviceN`.
|
||||
let found = spot_findings("/A [6 0 R /Gold /DeviceRGB 4 0 R] /B [/Separation /Gold /DeviceRGB 5 0 R]", &[]);
|
||||
assert_eq!(found.differing, BTreeSet::from(["Gold".to_string()]));
|
||||
|
||||
let found = spot_findings("/A [7 0 R [/Foil] /DeviceRGB 4 0 R << /Colorants << >> >>]", &[]);
|
||||
assert_eq!(found.undefined, BTreeSet::from(["Foil".to_string()]));
|
||||
|
||||
// A `/Colorants` entry whose `/Separation` family name is indirect defines the colorant.
|
||||
let found = spot_findings("/A [7 0 R [/Foil] /DeviceRGB 4 0 R << /Colorants << /Foil [6 0 R /Foil /DeviceRGB 4 0 R] >> >>]", &[]);
|
||||
assert_eq!(found, Findings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_or_null_operand_colorant_separations() {
|
||||
for bad in [
|
||||
"[/Separation /Spot]",
|
||||
"[/Separation /Spot /DeviceRGB]",
|
||||
"[/Separation /Spot null 4 0 R]",
|
||||
"[/Separation /Spot /DeviceRGB null]",
|
||||
"[/Separation /Spot 99 0 R 4 0 R]",
|
||||
"[/Separation /Spot /DeviceRGB 99 0 R]",
|
||||
] {
|
||||
let spaces = format!("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot {bad} >> >>]");
|
||||
let found = spot_findings(&spaces, &[]);
|
||||
assert_eq!(found.undefined, BTreeSet::from(["Spot".to_string()]), "{bad}");
|
||||
}
|
||||
let valid = "/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot [/Separation /Spot /DeviceRGB 4 0 R] >> >>]";
|
||||
assert_eq!(spot_findings(valid, &[]), Findings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_null_dictionary_entries_and_matches_int_real_numbers() {
|
||||
let extra = [
|
||||
"<< /FunctionType 2 /Domain [0.0 1.0] /C0 [1.0 1.0 1.0] /C1 [1.0 0.0 0.0] /N 1.0 /Range null /Encode 99 0 R >>",
|
||||
"<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length 15 >>\nstream\n{ dup dup pop }\nendstream",
|
||||
"<< /FunctionType 4 /Domain [0.0 1.0] /Range [0.0 1.0 0.0 1.0 0.0 1.0] /Filter null /DecodeParms null /Length 15 >>\nstream\n{ dup dup pop }\nendstream",
|
||||
"<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0.5 0] /N 1 >>",
|
||||
];
|
||||
// Object 8 has float equivalents of object 4's integers plus null/dangling optional keys.
|
||||
let found = spot_findings("/A [/Separation /Red /DeviceRGB 4 0 R] /B [/Separation /Red /DeviceRGB 8 0 R]", &extra);
|
||||
assert_eq!(found, Findings::default());
|
||||
|
||||
// Object 9 and object 10 are identical Type 4 streams where object 10 has `/Filter null /DecodeParms null` and float domain/range.
|
||||
let found = spot_findings("/A [/Separation /Red /DeviceRGB 9 0 R] /B [/Separation /Red /DeviceRGB 10 0 R]", &extra);
|
||||
assert_eq!(found, Findings::default());
|
||||
|
||||
// Genuinely different numeric values still report differing.
|
||||
let found = spot_findings("/A [/Separation /Red /DeviceRGB 8 0 R] /B [/Separation /Red /DeviceRGB 11 0 R]", &extra);
|
||||
assert_eq!(found.differing, BTreeSet::from(["Red".to_string()]));
|
||||
|
||||
// Precision-losing large integers do not falsely match rounded floats.
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
]);
|
||||
let mut fn_int = Dict::new();
|
||||
fn_int.set(b"N".to_vec(), Object::Int(i64::MAX));
|
||||
let mut fn_real = Dict::new();
|
||||
fn_real.set(b"N".to_vec(), Object::Real(i64::MAX as f64));
|
||||
let r_int = doc.add(Object::Dict(fn_int));
|
||||
let r_real = doc.add(Object::Dict(fn_real));
|
||||
let sep_a = Object::Array(vec![Object::name("Separation"), Object::name("Big"), Object::name("DeviceGray"), Object::Ref(r_int)]);
|
||||
let sep_b = Object::Array(vec![Object::name("Separation"), Object::name("Big"), Object::name("DeviceGray"), Object::Ref(r_real)]);
|
||||
doc.add(sep_a);
|
||||
doc.add(sep_b);
|
||||
let found = check(&doc, &objects(&doc));
|
||||
assert_eq!(found.differing, BTreeSet::from(["Big".to_string()]));
|
||||
|
||||
assert!(same_number(0, -0.0));
|
||||
assert!(same_number(i64::MIN, i64::MIN as f64));
|
||||
assert!(!same_number(i64::MIN + 1, (i64::MIN + 1) as f64));
|
||||
assert!(!same_number(0, f64::NAN));
|
||||
assert!(!same_number(0, f64::INFINITY));
|
||||
assert!(!same_number(0, f64::NEG_INFINITY));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user