use zpdf_core::{ObjectId, PdfDict, PdfObject};
use zpdf_parser::PdfFile;
use crate::forms::pdf_string_to_unicode;
const MAX_OUTPUT_INTENTS: usize = 64;
#[derive(Debug, Clone)]
pub struct OutputIntent {
pub subtype: String,
pub output_condition_identifier: Option<String>,
pub output_condition: Option<String>,
pub info: Option<String>,
pub dest_output_profile: Option<ObjectId>,
pub dest_profile_components: Option<i64>,
}
impl OutputIntent {
pub fn has_cmyk_profile(&self) -> bool {
self.dest_output_profile.is_some() && self.dest_profile_components.is_none_or(|n| n == 4)
}
}
pub fn parse_output_intents(file: &PdfFile) -> Vec<OutputIntent> {
let root = file
.trailer
.get_ref("Root")
.ok()
.and_then(|r| file.resolve(r).ok())
.and_then(|o| o.as_dict().ok().cloned());
match root {
Some(dict) => parse_intents_array(file, dict.get("OutputIntents")),
None => Vec::new(),
}
}
pub fn parse_page_output_intents(file: &PdfFile, page_dict: &PdfDict) -> Vec<OutputIntent> {
parse_intents_array(file, page_dict.get("OutputIntents"))
}
fn parse_intents_array(file: &PdfFile, obj: Option<&PdfObject>) -> Vec<OutputIntent> {
let arr = match obj {
Some(PdfObject::Array(a)) => a.clone(),
Some(PdfObject::Ref(r)) => match file.resolve(*r) {
Ok(PdfObject::Array(a)) => a,
_ => return Vec::new(),
},
_ => return Vec::new(),
};
let mut out = Vec::new();
for elem in arr.iter().take(MAX_OUTPUT_INTENTS) {
let dict = match elem {
PdfObject::Dict(d) => Some(d.clone()),
PdfObject::Ref(r) => file
.resolve(*r)
.ok()
.and_then(|o| o.as_dict().ok().cloned()),
_ => None,
};
match dict {
Some(d) => out.push(parse_one_intent(file, &d)),
None => tracing::warn!("/OutputIntents entry is not a dictionary; skipping"),
}
}
out
}
fn parse_one_intent(file: &PdfFile, dict: &PdfDict) -> OutputIntent {
let text = |key: &str| match dict.get(key) {
Some(PdfObject::String(s)) => Some(pdf_string_to_unicode(s.as_bytes())),
_ => None,
};
let dest_output_profile = dict.get_ref("DestOutputProfile").ok();
let dest_profile_components = dest_output_profile.and_then(|id| {
file.resolve(id)
.ok()
.and_then(|o| o.as_stream().ok().and_then(|s| s.dict.get_i64("N").ok()))
});
OutputIntent {
subtype: dict.get_name("S").unwrap_or("").to_string(),
output_condition_identifier: text("OutputConditionIdentifier"),
output_condition: text("OutputCondition"),
info: text("Info"),
dest_output_profile,
dest_profile_components,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::build_pdf;
use zpdf_parser::PdfFile;
fn parse(objects: &[&str]) -> Vec<OutputIntent> {
let file = PdfFile::parse(build_pdf(objects)).expect("parse pdf");
parse_output_intents(&file)
}
#[test]
fn document_output_intent_with_cmyk_profile() {
let ois = parse(&[
"<< /Type /Catalog /Pages 2 0 R /OutputIntents [4 0 R] >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
"<< /Type /OutputIntent /S /GTS_PDFX \
/OutputConditionIdentifier (CGATS TR 001) \
/OutputCondition (SWOP) /Info (U.S. Web Coated) /DestOutputProfile 5 0 R >>",
"<< /N 4 /Length 0 >>\nstream\n\nendstream",
]);
assert_eq!(ois.len(), 1);
let oi = &ois[0];
assert_eq!(oi.subtype, "GTS_PDFX");
assert_eq!(
oi.output_condition_identifier.as_deref(),
Some("CGATS TR 001")
);
assert_eq!(oi.output_condition.as_deref(), Some("SWOP"));
assert_eq!(oi.info.as_deref(), Some("U.S. Web Coated"));
assert_eq!(oi.dest_output_profile, Some(ObjectId(5, 0)));
assert_eq!(oi.dest_profile_components, Some(4));
assert!(oi.has_cmyk_profile());
}
#[test]
fn absent_output_intents_is_empty() {
let ois = parse(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
]);
assert!(ois.is_empty());
}
#[test]
fn external_profile_intent_has_no_object_id() {
let ois = parse(&[
"<< /Type /Catalog /Pages 2 0 R /OutputIntents [4 0 R] >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
"<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (FOGRA39) >>",
]);
assert_eq!(ois.len(), 1);
assert_eq!(ois[0].dest_output_profile, None);
assert_eq!(ois[0].dest_profile_components, None);
assert!(!ois[0].has_cmyk_profile());
}
#[test]
fn rgb_profile_is_not_a_cmyk_candidate() {
let ois = parse(&[
"<< /Type /Catalog /Pages 2 0 R /OutputIntents [4 0 R] >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
"<< /Type /OutputIntent /S /GTS_PDFA1 /DestOutputProfile 5 0 R >>",
"<< /N 3 /Length 0 >>\nstream\n\nendstream",
]);
assert_eq!(ois[0].dest_profile_components, Some(3));
assert!(!ois[0].has_cmyk_profile());
}
#[test]
fn utf16be_condition_identifier_decodes() {
let ois = parse(&[
"<< /Type /Catalog /Pages 2 0 R /OutputIntents [4 0 R] >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
"<< /Type /OutputIntent /S /GTS_PDFX \
/OutputConditionIdentifier <FEFF00530057004F0050> >>",
]);
assert_eq!(ois[0].output_condition_identifier.as_deref(), Some("SWOP"));
}
#[test]
fn non_dict_entries_are_skipped_without_panic() {
let ois = parse(&[
"<< /Type /Catalog /Pages 2 0 R /OutputIntents [4 0 R 99 0 R] >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
"<< /Type /OutputIntent /S /GTS_PDFX >>",
"null",
]);
assert_eq!(ois.len(), 1);
assert_eq!(ois[0].subtype, "GTS_PDFX");
}
}