Skip to main content

zpdf_document/
output_intents.rs

1//! Output intents (`/OutputIntents`), document-level (catalog, ISO 32000-1
2//! §14.11.5) and page-level (ISO 32000-2 / PDF 2.0).
3//!
4//! An output intent declares the *characterized printing condition* a document
5//! was prepared for. Its `/DestOutputProfile` — an embedded ICC profile stream
6//! — lets a DeviceCMYK document specify exactly how its CMYK is meant to be
7//! interpreted (the PDF/X model). When that profile is 4-channel (CMYK), the
8//! renderer colour-manages DeviceCMYK through it instead of the generic Adobe
9//! SWOP approximation.
10//!
11//! This module only *parses and exposes* the metadata (including the profile
12//! stream's object id and channel count); compiling the profile into a colour
13//! transform and substituting it for DeviceCMYK happens in the render pipeline
14//! (`zpdf-content`), which owns the `IccCache`. Keeping the compile out of the
15//! document model leaves this crate free of any colour-management dependency.
16
17use zpdf_core::{ObjectId, PdfDict, PdfObject};
18use zpdf_parser::PdfFile;
19
20use crate::forms::pdf_string_to_unicode;
21
22/// Defensive cap on the number of entries parsed from one `/OutputIntents`
23/// array — real documents carry one or two; this only bounds adversarial input.
24const MAX_OUTPUT_INTENTS: usize = 64;
25
26/// One `/OutputIntents` entry.
27#[derive(Debug, Clone)]
28pub struct OutputIntent {
29    /// `/S` subtype name, e.g. `"GTS_PDFX"`, `"GTS_PDFA1"`, `"ISO_PDFE1"`.
30    /// Empty when the entry omits `/S`.
31    pub subtype: String,
32    /// `/OutputConditionIdentifier` — the characterized printing condition,
33    /// usually a registry key such as `"CGATS TR 001"`. Decoded as a text
34    /// string (UTF-16BE with BOM, else PDFDocEncoding bytes).
35    pub output_condition_identifier: Option<String>,
36    /// `/OutputCondition` — human-readable condition name, if present.
37    pub output_condition: Option<String>,
38    /// `/Info` — additional human-readable description, if present.
39    pub info: Option<String>,
40    /// `/DestOutputProfile` — the embedded ICC profile stream's object id.
41    /// `None` when the intent only names an external (registry) profile.
42    pub dest_output_profile: Option<ObjectId>,
43    /// `/N` (component count) read off the `/DestOutputProfile` stream dict
44    /// *without* decoding the profile. `None` when the key is absent/unreadable.
45    pub dest_profile_components: Option<i64>,
46}
47
48impl OutputIntent {
49    /// Cheap heuristic — usable without decoding the profile — for whether this
50    /// intent *looks like* it can colour-manage DeviceCMYK: it has an embedded
51    /// profile whose `/N` is 4 or absent. It is advisory (e.g. for `zpdf info`):
52    /// the render path is authoritative, accepting a profile only on its
53    /// compiled channel count, so a profile with a mistyped `/N` is still
54    /// honoured there even though this returns `false`.
55    pub fn has_cmyk_profile(&self) -> bool {
56        self.dest_output_profile.is_some() && self.dest_profile_components.is_none_or(|n| n == 4)
57    }
58}
59
60/// Document-level `/OutputIntents` from the catalog (`/Root`). Empty when the
61/// document declares none.
62pub fn parse_output_intents(file: &PdfFile) -> Vec<OutputIntent> {
63    let root = file
64        .trailer
65        .get_ref("Root")
66        .ok()
67        .and_then(|r| file.resolve(r).ok())
68        .and_then(|o| o.as_dict().ok().cloned());
69    match root {
70        Some(dict) => parse_intents_array(file, dict.get("OutputIntents")),
71        None => Vec::new(),
72    }
73}
74
75/// PDF 2.0 page-level `/OutputIntents`, read off an already-resolved leaf page
76/// dictionary. Empty for pre-2.0 / most documents. Page-level intents override
77/// the document-level ones for that page.
78pub fn parse_page_output_intents(file: &PdfFile, page_dict: &PdfDict) -> Vec<OutputIntent> {
79    parse_intents_array(file, page_dict.get("OutputIntents"))
80}
81
82/// Parse an `/OutputIntents` array value (which itself may be given indirectly).
83fn parse_intents_array(file: &PdfFile, obj: Option<&PdfObject>) -> Vec<OutputIntent> {
84    let arr = match obj {
85        Some(PdfObject::Array(a)) => a.clone(),
86        Some(PdfObject::Ref(r)) => match file.resolve(*r) {
87            Ok(PdfObject::Array(a)) => a,
88            _ => return Vec::new(),
89        },
90        _ => return Vec::new(),
91    };
92    let mut out = Vec::new();
93    for elem in arr.iter().take(MAX_OUTPUT_INTENTS) {
94        let dict = match elem {
95            PdfObject::Dict(d) => Some(d.clone()),
96            PdfObject::Ref(r) => file
97                .resolve(*r)
98                .ok()
99                .and_then(|o| o.as_dict().ok().cloned()),
100            _ => None,
101        };
102        match dict {
103            Some(d) => out.push(parse_one_intent(file, &d)),
104            None => tracing::warn!("/OutputIntents entry is not a dictionary; skipping"),
105        }
106    }
107    out
108}
109
110fn parse_one_intent(file: &PdfFile, dict: &PdfDict) -> OutputIntent {
111    let text = |key: &str| match dict.get(key) {
112        Some(PdfObject::String(s)) => Some(pdf_string_to_unicode(s.as_bytes())),
113        _ => None,
114    };
115    let dest_output_profile = dict.get_ref("DestOutputProfile").ok();
116    // Read the profile stream's /N without decoding the (potentially large)
117    // ICC payload — enough to tell a CMYK characterization from an RGB one.
118    let dest_profile_components = dest_output_profile.and_then(|id| {
119        file.resolve(id)
120            .ok()
121            .and_then(|o| o.as_stream().ok().and_then(|s| s.dict.get_i64("N").ok()))
122    });
123    OutputIntent {
124        subtype: dict.get_name("S").unwrap_or("").to_string(),
125        output_condition_identifier: text("OutputConditionIdentifier"),
126        output_condition: text("OutputCondition"),
127        info: text("Info"),
128        dest_output_profile,
129        dest_profile_components,
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::test_util::build_pdf;
137    use zpdf_parser::PdfFile;
138
139    fn parse(objects: &[&str]) -> Vec<OutputIntent> {
140        let file = PdfFile::parse(build_pdf(objects)).expect("parse pdf");
141        parse_output_intents(&file)
142    }
143
144    #[test]
145    fn document_output_intent_with_cmyk_profile() {
146        // obj1 catalog, obj2 pages, obj3 page, obj4 intent, obj5 ICC stream /N 4.
147        let ois = parse(&[
148            "<< /Type /Catalog /Pages 2 0 R /OutputIntents [4 0 R] >>",
149            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
150            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
151            "<< /Type /OutputIntent /S /GTS_PDFX \
152             /OutputConditionIdentifier (CGATS TR 001) \
153             /OutputCondition (SWOP) /Info (U.S. Web Coated) /DestOutputProfile 5 0 R >>",
154            "<< /N 4 /Length 0 >>\nstream\n\nendstream",
155        ]);
156        assert_eq!(ois.len(), 1);
157        let oi = &ois[0];
158        assert_eq!(oi.subtype, "GTS_PDFX");
159        assert_eq!(
160            oi.output_condition_identifier.as_deref(),
161            Some("CGATS TR 001")
162        );
163        assert_eq!(oi.output_condition.as_deref(), Some("SWOP"));
164        assert_eq!(oi.info.as_deref(), Some("U.S. Web Coated"));
165        assert_eq!(oi.dest_output_profile, Some(ObjectId(5, 0)));
166        assert_eq!(oi.dest_profile_components, Some(4));
167        assert!(oi.has_cmyk_profile());
168    }
169
170    #[test]
171    fn absent_output_intents_is_empty() {
172        let ois = parse(&[
173            "<< /Type /Catalog /Pages 2 0 R >>",
174            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
175            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
176        ]);
177        assert!(ois.is_empty());
178    }
179
180    #[test]
181    fn external_profile_intent_has_no_object_id() {
182        // An intent with a condition identifier but no embedded /DestOutputProfile
183        // is still parsed (reportable), and is not a CMYK-management candidate.
184        let ois = parse(&[
185            "<< /Type /Catalog /Pages 2 0 R /OutputIntents [4 0 R] >>",
186            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
187            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
188            "<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (FOGRA39) >>",
189        ]);
190        assert_eq!(ois.len(), 1);
191        assert_eq!(ois[0].dest_output_profile, None);
192        assert_eq!(ois[0].dest_profile_components, None);
193        assert!(!ois[0].has_cmyk_profile());
194    }
195
196    #[test]
197    fn rgb_profile_is_not_a_cmyk_candidate() {
198        let ois = parse(&[
199            "<< /Type /Catalog /Pages 2 0 R /OutputIntents [4 0 R] >>",
200            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
201            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
202            "<< /Type /OutputIntent /S /GTS_PDFA1 /DestOutputProfile 5 0 R >>",
203            "<< /N 3 /Length 0 >>\nstream\n\nendstream",
204        ]);
205        assert_eq!(ois[0].dest_profile_components, Some(3));
206        assert!(!ois[0].has_cmyk_profile());
207    }
208
209    #[test]
210    fn utf16be_condition_identifier_decodes() {
211        // <FEFF 0053 0057 004F 0050> = "SWOP" in UTF-16BE with a BOM.
212        let ois = parse(&[
213            "<< /Type /Catalog /Pages 2 0 R /OutputIntents [4 0 R] >>",
214            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
215            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
216            "<< /Type /OutputIntent /S /GTS_PDFX \
217             /OutputConditionIdentifier <FEFF00530057004F0050> >>",
218        ]);
219        assert_eq!(ois[0].output_condition_identifier.as_deref(), Some("SWOP"));
220    }
221
222    #[test]
223    fn non_dict_entries_are_skipped_without_panic() {
224        let ois = parse(&[
225            "<< /Type /Catalog /Pages 2 0 R /OutputIntents [4 0 R 99 0 R] >>",
226            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
227            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
228            "<< /Type /OutputIntent /S /GTS_PDFX >>",
229            // obj 5 exists but is not referenced; 99 0 R above is dangling.
230            "null",
231        ]);
232        // The dangling 99 0 R entry is dropped; the valid one survives.
233        assert_eq!(ois.len(), 1);
234        assert_eq!(ois[0].subtype, "GTS_PDFX");
235    }
236}