fix(preflight): find colour operators after any PDF whitespace (#1334) (#1373)

The PDF/A device-colour scan split content streams only on spaces and
line feeds, so `0 0 0 1 k` written with carriage returns, tabs, form
feeds or NULs between the tokens went unseen: verify reported no
DeviceCMYK, and convert gave the CMYK page an sRGB output intent.

Split on all six whitespace characters of ISO 32000-2 §7.2.3, using the
lexer's own `is_whitespace` (now exported from pdfcraft-cos) instead of
a second list.

Adds tests::device_colour_is_found_after_any_whitespace, which fails
before this change; its gray and RGB controls check that those pages
still get the sRGB output intent.
This commit is contained in:
Marco Arnulfo
2026-10-12 06:25:42 -04:00
committed by GitHub
parent 27ce6f7584
commit 0108a5b07b
4 changed files with 42 additions and 5 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ mod writer;
pub use bytes::Bytes;
pub use document::{Document, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
pub use parser::{Lexer, parse_indirect};
pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use pdfcraft_crypt::{
Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char,
};
+2 -1
View File
@@ -11,7 +11,8 @@ use crate::{Bytes, CosError};
const MAX_DEPTH: usize = 128;
pub(crate) fn is_whitespace(b: u8) -> bool {
/// One of the six PDF whitespace characters (ISO 32000-2 §7.2.3, Table 1).
pub fn is_whitespace(b: u8) -> bool {
matches!(b, b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ')
}
+3 -3
View File
@@ -24,7 +24,7 @@
pub mod icc;
mod xmp;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, is_whitespace};
/// The PDF/A part and conformance level.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -287,8 +287,8 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)])
&& let Ok(data) = s.decoded()
{
// Content streams: rg/RG/g/G/k/K operators.
for line in data.split(|b| *b == b'\n' || *b == b' ') {
match line {
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,
+36
View File
@@ -214,3 +214,39 @@ fn verify_reports_an_output_intent_that_does_not_match_device_colour() {
let headed = format!("<< /Length 128 >>\nstream\n{header}\nendstream");
assert!(!messages(&verify(&cmyk_page(intent, "/Im0 Do", &[&headed]), Level::A2b)).contains(&cmyk_msg));
}
/// Issue #1334: a colour operator is found whichever of the six PDF whitespace characters
/// separates the tokens, not only after a space or a line feed.
#[test]
fn device_colour_is_found_after_any_whitespace() {
let page = |content: &[u8]| {
let content = String::from_utf8(content.to_vec()).unwrap();
let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
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 /Contents 4 0 R /Resources << >> >>",
stream.as_str(),
],
"",
)
};
let cmyk_msg = "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)";
for separator in [b" ".as_slice(), b"\n", b"\r", b"\t", b"\x0C", b"\0"] {
let cmyk: &[&[u8]] = &[b"0", b"0", b"0", b"1", b"k", b"10", b"10", b"50", b"50", b"re", b"f"];
let mut doc = page(&cmyk.join(separator));
assert!(messages(&verify(&doc, Level::A2b)).contains(&cmyk_msg), "separator {separator:?}");
let r = convert(&mut doc, Level::A2b).unwrap();
assert!(declared(&doc).output_intents.is_empty(), "separator {separator:?}");
assert!(messages(&r.remaining).contains(&cmyk_msg), "separator {separator:?}");
// Gray and RGB still get the sRGB output intent.
for colour in [[b"0".as_slice(), b"g"].as_slice(), &[b"1", b"0", b"0", b"rg"]] {
let mut doc = page(&colour.join(separator));
let r = convert(&mut doc, Level::A2b).unwrap();
assert!(r.fixed.iter().any(|f| f.contains("output intent")), "separator {separator:?}: {:?}", r.fixed);
assert_eq!(declared(&doc).output_intents, ["sRGB IEC61966-2.1"], "separator {separator:?}");
}
}
}