zpdf_document/
output_intents.rs1use zpdf_core::{ObjectId, PdfDict, PdfObject};
18use zpdf_parser::PdfFile;
19
20use crate::forms::pdf_string_to_unicode;
21
22const MAX_OUTPUT_INTENTS: usize = 64;
25
26#[derive(Debug, Clone)]
28pub struct OutputIntent {
29 pub subtype: String,
32 pub output_condition_identifier: Option<String>,
36 pub output_condition: Option<String>,
38 pub info: Option<String>,
40 pub dest_output_profile: Option<ObjectId>,
43 pub dest_profile_components: Option<i64>,
46}
47
48impl OutputIntent {
49 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
60pub 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
75pub fn parse_page_output_intents(file: &PdfFile, page_dict: &PdfDict) -> Vec<OutputIntent> {
79 parse_intents_array(file, page_dict.get("OutputIntents"))
80}
81
82fn 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 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 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 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 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 "null",
231 ]);
232 assert_eq!(ois.len(), 1);
234 assert_eq!(ois[0].subtype, "GTS_PDFX");
235 }
236}