fix(preflight): read device colour from content operators, not whitespace-split bytes (#2260)

uses_device_colour found content streams by their dictionaries and split
their bytes on whitespace. It skipped form XObjects with /Type /XObject and
tiling patterns, missed `cs`/`CS` with a device space, inline images and
operators before a delimiter, and took a `k` in a string, a comment, inline
image data, a font program or an ICC profile for DeviceCMYK. A CMYK logo in a
form therefore got an sRGB output intent and passed pdfa_verify.

Content streams are now found by role (page /Contents, Type 3 glyphs, forms,
appearance streams, tiling patterns) and read with pdfcraft_content::parse.
Reading stops once all three device spaces are seen.
This commit is contained in:
Guillem Hermida
2026-10-15 02:52:28 -04:00
committed by GitHub
parent 2a130abf82
commit c56c0160b9
4 changed files with 243 additions and 14 deletions
Generated
+1
View File
@@ -4195,6 +4195,7 @@ dependencies = [
name = "pdfcraft-preflight"
version = "0.8.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
]
+1
View File
@@ -11,5 +11,6 @@ repository.workspace = true
workspace = true
[dependencies]
pdfcraft-content = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
+120 -14
View File
@@ -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<ObjRef>) {
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<ObjRef>) {
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<Object>)]) -> std::collections::HashSet<ObjRef> {
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<Object>)]) -> 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<Object>)])
_ => {}
}
}
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
+121
View File
@@ -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);
}
}