mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
Generated
+1
@@ -4195,6 +4195,7 @@ dependencies = [
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
]
|
||||
|
||||
@@ -11,5 +11,6 @@ repository.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
|
||||
+120
-14
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user