diff --git a/Cargo.lock b/Cargo.lock index 07ee42e7d..678a07ffa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4195,6 +4195,7 @@ dependencies = [ name = "pdfcraft-preflight" version = "0.8.0" dependencies = [ + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", ] diff --git a/crates/preflight/Cargo.toml b/crates/preflight/Cargo.toml index b0eef0054..b79575fc0 100644 --- a/crates/preflight/Cargo.toml +++ b/crates/preflight/Cargo.toml @@ -11,5 +11,6 @@ repository.workspace = true workspace = true [dependencies] +pdfcraft-content = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-model = { workspace = true } diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index d0e69525d..a4d90d874 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -24,7 +24,7 @@ pub mod icc; mod xmp; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, is_whitespace}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; /// The PDF/A part and conformance level. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -255,12 +255,124 @@ impl DeviceColour { fn any(self) -> bool { self.gray || self.rgb || self.cmyk } + + /// Note the device colour space `name`, if it is one. Inline images may also use the + /// abbreviations (`G`, `RGB`, `CMYK`); anywhere else those are resource names. + fn mark(&mut self, name: &[u8], inline: bool) { + match name { + b"DeviceGray" => self.gray = true, + b"DeviceRGB" => self.rgb = true, + b"DeviceCMYK" => self.cmyk = true, + b"G" if inline => self.gray = true, + b"RGB" if inline => self.rgb = true, + b"CMYK" if inline => self.cmyk = true, + _ => {} + } + } +} + +/// How deep [`type3_procs`] looks into an object's direct dictionaries and arrays. +const MAX_DIRECT_DEPTH: u8 = 8; + +/// The glyph procedures of every Type 3 font in `d`: `d` itself, or a font held directly inside +/// it (a font can sit inline in a resource dictionary). +fn type3_procs(doc: &Document, d: &Dict, depth: u8, out: &mut std::collections::HashSet) { + if d.name(b"Subtype") == Some(b"Type3") + && let Some(procs) = d.get(b"CharProcs").map(|c| doc.resolve(c)) + && let Some(procs) = procs.as_dict() + { + out.extend(procs.iter().filter_map(|(_, p)| p.as_ref())); + } + for (_, v) in d.iter() { + type3_procs_within(doc, v, depth.saturating_add(1), out); + } +} + +fn type3_procs_within(doc: &Document, o: &Object, depth: u8, out: &mut std::collections::HashSet) { + if depth > MAX_DIRECT_DEPTH { + return; + } + match o { + Object::Dict(d) => type3_procs(doc, d, depth, out), + Object::Array(a) => { + for v in a { + type3_procs_within(doc, v, depth.saturating_add(1), out); + } + } + _ => {} + } +} + +/// The content streams that only a reference identifies, their dictionaries saying nothing: the +/// pages' `/Contents` and Type 3 glyph procedures. +fn referenced_content(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + for p in pdfcraft_model::pages(doc) { + let contents = match p.dict.get(b"Contents") { + Some(Object::Ref(r)) => match &*doc.get(*r) { + Object::Array(a) => a.clone(), + _ => vec![Object::Ref(*r)], + }, + Some(Object::Array(a)) => a.clone(), + _ => Vec::new(), + }; + out.extend(contents.iter().filter_map(Object::as_ref)); + } + for (_, o) in objs { + if let Some(d) = dict_of(o) { + type3_procs(doc, d, 0, &mut out); + } + } + out +} + +/// Whether a stream's own dictionary says it holds content: a form XObject, or a stream with a +/// `/BBox` and no `/Subtype` (a tiling pattern, or an appearance stream that leaves out +/// `/Subtype /Form`). Mesh shadings may carry a `/BBox` too, but their data isn't content. +fn is_content(d: &Dict) -> bool { + match d.name(b"Subtype") { + Some(t) => t == b"Form", + None => d.get(b"BBox").is_some() && d.get(b"ShadingType").is_none(), + } +} + +/// The device colour a content stream sets. It is read operator by operator, so the bytes of a +/// string, a comment or an inline image are never taken for an operator, and an operator right +/// before a delimiter (`1 k/GS0 gs`) is still found. +fn content_colour(data: &[u8], used: &mut DeviceColour) { + for op in pdfcraft_content::parse(data).ops { + match op.op.as_slice() { + b"g" | b"G" => used.gray = true, + b"rg" | b"RG" => used.rgb = true, + b"k" | b"K" => used.cmyk = true, + b"cs" | b"CS" => { + if let Some(name) = op.name(0) { + used.mark(name, false); + } + } + b"BI" => { + let Some((image, _)) = &op.inline else { continue }; + match image.get(b"CS").or_else(|| image.get(b"ColorSpace")) { + Some(Object::Name(n)) => used.mark(n, true), + // An indexed space draws in its base: `[/I /DeviceRGB 1 <…>]`. + Some(Object::Array(a)) if a.first().and_then(Object::as_name).is_some_and(|n| n == b"I" || n == b"Indexed") => { + if let Some(base) = a.get(1).and_then(Object::as_name) { + used.mark(base, true); + } + } + _ => {} + } + } + _ => {} + } + } } /// Whether any content or object uses a device colour space. fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> DeviceColour { let mut used = DeviceColour::default(); - for (_, o) in objs { + let referenced = referenced_content(doc, objs); + for (r, o) in objs { let Some(d) = dict_of(o) else { continue }; // A resource dictionary's /ColorSpace, whether it is this object or the page's inline // /Resources. @@ -281,20 +393,14 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) _ => {} } } - if let Object::Stream(s) = &**o - && d.get(b"Type").is_none() - && d.get(b"Subtype").is_none_or(|t| t.as_name() == Some(b"Form")) + // Content streams only: a font program or an ICC profile is data, whatever bytes it holds. + // Parsing is the expensive part, so once content has used all three spaces no more is read. + if !(used.gray && used.rgb && used.cmyk) + && let Object::Stream(s) = &**o + && (referenced.contains(r) || is_content(&s.dict)) && let Ok(data) = s.decoded() { - // Content streams: rg/RG/g/G/k/K operators. - for token in data.split(|b| is_whitespace(*b)) { - match token { - b"g" | b"G" => used.gray = true, - b"rg" | b"RG" => used.rgb = true, - b"k" | b"K" => used.cmyk = true, - _ => {} - } - } + content_colour(&data, &mut used); } } used diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index dd06b6b72..25d06669e 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -250,3 +250,124 @@ fn device_colour_is_found_after_any_whitespace() { } } } + +/// A page that draws `content` with `resources`, followed by `extra` objects (from object 5 on). +fn drawing(content: &str, resources: &str, extra: &[&str]) -> Document { + let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()); + let page = format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << {resources} >> >>"); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + page.as_str(), + stream.as_str(), + ]; + objs.extend_from_slice(extra); + build(&objs, "") +} + +fn uses_cmyk(doc: &Document) -> bool { + messages(&verify(doc, Level::A2b)).contains(&"DeviceCMYK is used: it needs a CMYK output intent (not added automatically)") +} + +/// Issue #2259: a `k` is DeviceCMYK only when it is an operator. Inside a string, a comment, an inline image's +/// data, a font program, an ICC profile or a mesh shading's data it is just a byte, and the +/// document still gets its sRGB output intent. +#[test] +fn device_colour_is_read_from_operators_not_bytes() { + let rgb = "1 0 0 rg 0 0 10 10 re f"; + let cases: &[(&str, String, &str, Vec<&str>)] = &[ + ( + "a string", + format!("{rgb} BT /F1 12 Tf 10 10 Td (press k now) Tj ET"), + "/Font << /F1 5 0 R >>", + vec![ + "<< /Type /Font /Subtype /TrueType /BaseFont /Sans /FontDescriptor 6 0 R >>", + "<< /Type /FontDescriptor /FontName /Sans /FontFile2 7 0 R >>", + // A font program whose bytes read " k ", as Chromium and krilla write them. + "<< /Length1 3 /Length 3 >>\nstream\n k \nendstream", + ], + ), + ("a comment", format!("{rgb} % 0 0 0 1 k"), "", vec![]), + // A 3×1 gray image whose three bytes read " k ". + ("inline image data", format!("{rgb} q 10 0 0 10 0 0 cm BI /W 3 /H 1 /CS /G /BPC 8 ID k EI Q"), "", vec![]), + ("an ICC profile", rgb.to_owned(), "/ColorSpace << /CS0 [/ICCBased 5 0 R] >>", vec!["<< /N 3 /Length 3 >>\nstream\n k \nendstream"]), + ( + "a mesh shading", + format!("{rgb} /Sh0 sh"), + "/Shading << /Sh0 5 0 R >>", + vec![ + "<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 1 0 1 0 1 0 1 0 1] /BBox [0 0 10 10] /Length 3 >>\nstream\n k \nendstream", + ], + ), + ]; + for (what, content, resources, extra) in cases { + let mut doc = drawing(content, resources, extra); + assert!(!uses_cmyk(&doc), "a k in {what} was read as DeviceCMYK"); + let r = convert(&mut doc, Level::A2b).unwrap(); + assert_eq!(declared(&doc).output_intents, ["sRGB IEC61966-2.1"], "{what}: {:?}", r.fixed); + } +} + +/// Issue #2259: DeviceCMYK is found however the content sets it: `k`/`K` right before a delimiter, `cs`/`CS` +/// with the space's name, or an inline image in `/CMYK` or indexed over it. +#[test] +fn device_colour_is_found_however_content_sets_it() { + for content in [ + "0 0 0 1 k/GS0 gs 10 10 50 50 re f", + "0 0 0 1 K[]0 d 10 10 m 50 50 l S", + "/DeviceCMYK cs 0 0 0 1 sc 10 10 50 50 re f", + "/DeviceCMYK CS 0 0 0 1 SC 10 10 m 50 50 l S", + "q 10 0 0 10 0 0 cm BI /W 1 /H 1 /CS /CMYK /BPC 8 ID \u{0}\u{0}\u{0}\u{0} EI Q", + "q 10 0 0 10 0 0 cm BI /W 1 /H 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8 ID \u{0}\u{0}\u{0}\u{0} EI Q", + "q 10 0 0 10 0 0 cm BI /W 1 /H 1 /CS [/I /DeviceCMYK 0 <00000000>] /BPC 8 ID \u{0} EI Q", + ] { + assert!(uses_cmyk(&drawing(content, "", &[])), "{content:?}"); + } + // A resource named like a device space's abbreviation is a resource, not that space. + let named = + drawing("/CMYK cs 1 sc 10 10 50 50 re f", "/ColorSpace << /CMYK [/ICCBased 5 0 R] >>", &["<< /N 1 /Length 0 >>\nstream\n\nendstream"]); + assert!(!uses_cmyk(&named)); +} + +/// Issue #2259: content is not only the page's own stream: form XObjects (with `/Type /XObject`, as almost +/// every writer gives them), appearance streams, tiling patterns and Type 3 glyphs draw too. A +/// CMYK logo placed as a form must not get the document an sRGB output intent. +#[test] +fn device_colour_in_forms_patterns_and_glyphs_is_found() { + let cmyk = "0.75 0 1 0 k 0 0 10 10 re f"; + let stream = |dict: &str| format!("<< {dict} /Length {} >>\nstream\n{cmyk}\nendstream", cmyk.len()); + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]"); + let appearance = stream("/BBox [0 0 10 10]"); + let pattern = stream("/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>"); + let glyph = format!("<< /Length {} >>\nstream\n1 0 d0 {cmyk}\nendstream", cmyk.len() + 7); + let cases: [(&str, Document); 4] = [ + ("a form XObject", drawing("/Fm0 Do", "/XObject << /Fm0 5 0 R >>", &[&form])), + ( + "an appearance stream without /Subtype", + 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 /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 10 10] /F 4 /P 3 0 R /AP << /N 5 0 R >> >>", + &appearance, + ], + "", + ), + ), + ("a tiling pattern", drawing("/Pattern cs /P0 scn 0 0 100 100 re f", "/Pattern << /P0 5 0 R >>", &[&pattern])), + ( + "a Type 3 glyph in an inline font", + drawing( + "BT /T3 1 Tf (a) Tj ET", + "/Font << /T3 << /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [1 0 0 1 0 0] /CharProcs << /a 5 0 R >> /Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] >> >>", + &[&glyph], + ), + ), + ]; + for (what, mut doc) in cases { + assert!(uses_cmyk(&doc), "DeviceCMYK in {what} wasn't found"); + let r = convert(&mut doc, Level::A2b).unwrap(); + assert!(declared(&doc).output_intents.is_empty(), "{what}: an sRGB output intent was added to a CMYK document: {:?}", r.fixed); + } +}