diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index bb855cc0c..213ff341b 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -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, }; diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index dcb1c58ac..8ac8a195b 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -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' ') } diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index c2457c474..d0e69525d 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}; +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)]) && 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, diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index dadbd5831..dd06b6b72 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -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:?}"); + } + } +}