mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
M11: 6.2.10 accepts only blending colour spaces and real soft masks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
1 parent
52131ba093
commit
798dfa44c1
2 files changed
+54
-6
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user