diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 07237c169e..0bfe747c4e 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -637,6 +637,27 @@ fn a_blending_space_is_device_independent_and_an_intent_has_a_profile() { let defaults = format!("/ColorSpace << /{default} [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >>"); assert!(check("", &page(&format!("/CS /{device}"), &defaults), &[]).is_empty(), "{device} with {default}"); } + // Only CalGray, CalRGB and gray, RGB or CMYK ICCBased spaces blend (ISO 32000-1 11.3.4). + assert!(check("", &page("/CS [/CalGray << /WhitePoint [0.9505 1 1.089] >>]", ""), &[]).is_empty(), "CalGray"); + let icc = |n: u8, data: &str| format!("<< /N {n} /Length {} >>\nstream\n{data}\nendstream", data.len()); + assert!(check("", &page("/CS [/ICCBased 4 0 R]", ""), &[&icc(3, "")]).is_empty(), "an ICC profile of three components"); + for (what, cs, extra) in [ + ("Indexed", "/CS [/Indexed /DeviceRGB 1 <000000FFFFFF>]".to_owned(), vec![]), + ("Lab", "/CS [/Lab << /WhitePoint [0.9505 1 1.089] >>]".to_owned(), vec![]), + ( + "Separation", + "/CS [/Separation /Spot /DeviceCMYK 4 0 R]".to_owned(), + vec!["<< /FunctionType 2 /Domain [0 1] /C1 [0 0 0 1] /N 1 >>".to_owned()], + ), + ("an ICC profile of two components", "/CS [/ICCBased 4 0 R]".to_owned(), vec![icc(2, "")]), + ("a Lab ICC profile", "/CS [/ICCBased 4 0 R]".to_owned(), vec![icc(3, &format!("{}Lab ", "\0".repeat(16)))]), + ("a pattern space", "/CS /Pattern".to_owned(), vec![]), + ] { + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + assert_eq!(check("", &page(&cs, ""), &extra), page_said, "{what}"); + } + // A null soft mask is none: an otherwise opaque graphics state is not transparency. + assert!(check("", "/Resources << /ExtGState << /G << /SMask null >> >> >>", &[]).is_empty(), "/SMask null"); let intent = |profile: &str| format!("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB) {profile} >>]"); let opaque_group = format!("/Resources << {alpha} >>"); assert_eq!(check(&intent("/DestOutputProfile null"), &opaque_group, &[]), page_said, "a null profile"); diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs index 7b3e83784c..eccde37352 100644 --- a/crates/preflight/src/transparency.rs +++ b/crates/preflight/src/transparency.rs @@ -62,24 +62,50 @@ pub(crate) fn page_painting(doc: &Document, page: &Dict) -> Painting { } /// Whether `page` names a blending colour space PDF/A accepts without an output intent: its -/// `/Group` has a `/CS` that is not null and not a device colour space, unless the page's -/// resources give that device space a default (`DefaultGray`, `DefaultRGB`, `DefaultCMYK`), as -/// PDF/A-2 6.2.4.3 asks of any device colour. +/// `/Group` has a `/CS` that is a blending colour space (ISO 32000-1 and 32000-2 11.3.4: +/// `CalGray`, `CalRGB`, or an `ICCBased` space of 1, 3 or 4 components whose profile describes +/// gray, RGB or CMYK; not Lab, Indexed, Pattern, Separation or DeviceN), and not a device colour +/// space unless the page's resources give that device space a default (`DefaultGray`, +/// `DefaultRGB`, `DefaultCMYK`), as PDF/A-2 6.2.4.3 asks of any device colour. pub(crate) fn page_names_blending_space(doc: &Document, page: &Dict) -> bool { let group = page.get(b"Group").map(|g| doc.resolve(g)); let Some(cs) = group.as_deref().and_then(Object::as_dict).and_then(|g| g.get(b"CS")).map(|c| doc.resolve(c)) else { return false }; let default: &[u8] = match &*cs { - Object::Null => return false, Object::Name(n) if n == b"DeviceGray" => b"DefaultGray", Object::Name(n) if n == b"DeviceRGB" => b"DefaultRGB", Object::Name(n) if n == b"DeviceCMYK" => b"DefaultCMYK", - _ => return true, + Object::Array(a) => return independent_blending_space(doc, a), + _ => return false, }; let resources = page.get(b"Resources").map(|r| doc.resolve(r)); let spaces = resources.as_deref().and_then(Object::as_dict).and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c)); spaces.as_deref().and_then(Object::as_dict).is_some_and(|s| s.get(default).is_some()) } +/// Whether the colour space array `a` is a device-independent blending colour space: `CalGray`, +/// `CalRGB`, or `ICCBased` with 1, 3 or 4 components and, where the profile header can be read +/// (within the output intent's profile cap), a gray, RGB or CMYK data colour space. +fn independent_blending_space(doc: &Document, a: &[Object]) -> bool { + let family = a.first().map(|f| doc.resolve(f)); + match family.as_deref().and_then(Object::as_name) { + Some(b"CalGray" | b"CalRGB") => true, + Some(b"ICCBased") => { + let profile = a.get(1).map(|p| doc.resolve(p)); + let Some(Object::Stream(s)) = profile.as_deref() else { return false }; + let components = s.dict.get(b"N").and_then(|n| doc.resolve(n).as_int()); + if !matches!(components, Some(1 | 3 | 4)) { + return false; + } + // ICC.1 header: the data colour space is bytes 16–19. + match s.decoded_within(crate::MAX_ICC_PROFILE).ok().and_then(|data| data.get(16..20).map(<[u8]>::to_vec)) { + Some(space) => matches!(space.as_slice(), b"GRAY" | b"RGB " | b"CMYK"), + None => true, + } + } + _ => false, + } +} + struct Walk<'a> { doc: &'a Document, /// The indirect objects already read, each with the shallowest depth it was read at: an @@ -146,7 +172,8 @@ impl Walk<'_> { let below_one = |key: &[u8]| g.get(key).and_then(|v| doc.resolve(v).as_f64()).is_some_and(|a| a < 1.0); let bm = g.get(b"BM").map(|b| doc.resolve(b)); let blend = bm.as_deref().is_some_and(|b| blends(doc, b)); - let mask = g.get(b"SMask").map(|m| doc.resolve(m)).is_some_and(|m| !matches!(&*m, Object::Name(n) if n == b"None")); + // A soft mask is a dictionary; `/None`, null or anything else is none (ISO 32000-1 Table 58). + let mask = g.get(b"SMask").map(|m| doc.resolve(m)).is_some_and(|m| matches!(&*m, Object::Dict(_))); self.found.transparency |= below_one(b"CA") || below_one(b"ca") || blend || mask; for name in bm.as_deref().map(|b| unknown_blend_modes(doc, b)).unwrap_or_default() { if !self.found.unknown_blend_modes.contains(&name) {