use crate::error::{ManipError, Result};
use lopdf::{dictionary, Document, Object, ObjectId, Stream};
#[derive(Debug, Clone)]
pub struct ColorSpaceReport {
pub had_output_intent: bool,
pub output_intent_added: bool,
pub device_colorspaces_found: Vec<String>,
pub pages_scanned: usize,
pub separations_unified: usize,
pub overprint_mode_fixed: usize,
pub icc_n_fixed: usize,
pub device_color_use: DeviceColorUse,
}
pub fn find_device_colorspaces(doc: &Document) -> Vec<(ObjectId, String)> {
let mut result = Vec::new();
for (id, obj) in &doc.objects {
let Object::Dictionary(dict) = obj else {
continue;
};
if get_name(dict, b"Type").as_deref() != Some("Page") {
continue;
}
let resources = match dict.get(b"Resources").ok() {
Some(Object::Dictionary(res)) => Some(res.clone()),
Some(Object::Reference(res_id)) => {
if let Some(Object::Dictionary(res)) = doc.objects.get(res_id) {
Some(res.clone())
} else {
None
}
}
_ => None,
};
if let Some(res) = &resources {
if let Ok(Object::Dictionary(cs_dict)) = res.get(b"ColorSpace") {
for (key, val) in cs_dict.iter() {
let cs_name = resolve_colorspace_name(doc, val);
if is_device_dependent(&cs_name) {
let key_str = String::from_utf8_lossy(key).to_string();
result.push((*id, format!("{key_str}={cs_name}")));
}
}
}
}
}
result
}
pub fn has_pdfa_output_intent(doc: &Document) -> bool {
let catalog = match get_catalog(doc) {
Some(c) => c,
None => return false,
};
let intents = match catalog.get(b"OutputIntents").ok() {
Some(Object::Array(arr)) => arr,
Some(Object::Reference(id)) => {
if let Some(Object::Array(arr)) = doc.objects.get(id) {
arr
} else {
return false;
}
}
_ => return false,
};
intents.iter().any(|item| {
let dict = match item {
Object::Reference(id) => {
if let Some(Object::Dictionary(d)) = doc.objects.get(id) {
d
} else {
return false;
}
}
Object::Dictionary(d) => d,
_ => return false,
};
get_name(dict, b"S").as_deref() == Some("GTS_PDFA1")
})
}
fn find_existing_dest_output_profile(doc: &Document) -> Option<lopdf::ObjectId> {
let catalog = get_catalog(doc);
catalog.and_then(|cat| {
let intents = match cat.get(b"OutputIntents").ok()? {
Object::Array(arr) => arr.clone(),
_ => return None,
};
for item in &intents {
let dict = match item {
Object::Reference(id) => match doc.objects.get(id) {
Some(Object::Dictionary(d)) => d,
_ => continue,
},
Object::Dictionary(d) => d,
_ => continue,
};
if let Ok(Object::Reference(icc_id)) = dict.get(b"DestOutputProfile") {
return Some(*icc_id);
}
}
None
})
}
pub fn add_srgb_output_intent(doc: &mut Document) -> Result<()> {
let existing_icc_ref = find_existing_dest_output_profile(doc);
let icc_id = match existing_icc_ref {
Some(id) => id,
None => {
let icc_bytes = srgb_icc_profile_bytes();
let icc_dict = dictionary! {
"N" => Object::Integer(3),
"Alternate" => Object::Name(b"DeviceRGB".to_vec()),
};
let icc_stream = Stream::new(icc_dict, icc_bytes);
doc.add_object(Object::Stream(icc_stream))
}
};
let intent = dictionary! {
"Type" => Object::Name(b"OutputIntent".to_vec()),
"S" => Object::Name(b"GTS_PDFA1".to_vec()),
"OutputConditionIdentifier" => Object::String(
b"sRGB IEC61966-2.1".to_vec(),
lopdf::StringFormat::Literal,
),
"RegistryName" => Object::String(
b"http://www.color.org".to_vec(),
lopdf::StringFormat::Literal,
),
"Info" => Object::String(
b"sRGB IEC61966-2.1".to_vec(),
lopdf::StringFormat::Literal,
),
"DestOutputProfile" => Object::Reference(icc_id),
};
let intent_id = doc.add_object(Object::Dictionary(intent));
let catalog_id = get_catalog_id(doc)?;
if let Some(Object::Dictionary(ref mut catalog)) = doc.objects.get_mut(&catalog_id) {
let mut existing = match catalog.get(b"OutputIntents") {
Ok(Object::Array(arr)) => arr.clone(),
_ => Vec::new(),
};
existing.push(Object::Reference(intent_id));
catalog.set("OutputIntents", Object::Array(existing));
}
Ok(())
}
fn only_calrgb_registration_colors(doc: &Document) -> bool {
fn visit(doc: &Document, obj: &Object, calrgb: &mut bool, other: &mut bool) {
match obj {
Object::Array(a) => {
if a.len() >= 4
&& a[0].as_name().ok() == Some(b"Separation")
&& a[1].as_name().ok() == Some(b"All")
{
let calibrated_rgb = doc
.dereference(&a[2])
.ok()
.and_then(|(_, o)| o.as_array().ok())
.and_then(|a| a.first())
.and_then(|o| o.as_name().ok())
== Some(b"CalRGB");
*calrgb |= calibrated_rgb;
*other |= !calibrated_rgb;
}
for o in a {
visit(doc, o, calrgb, other);
}
}
Object::Dictionary(d) => {
for (_, o) in d.iter() {
visit(doc, o, calrgb, other);
}
}
Object::Stream(s) => {
for (_, o) in s.dict.iter() {
visit(doc, o, calrgb, other);
}
}
_ => {}
}
}
let (mut calrgb, mut other) = (false, false);
for obj in doc.objects.values() {
visit(doc, obj, &mut calrgb, &mut other);
}
calrgb && !other
}
fn needs_explicit_output_device(doc: &Document) -> bool {
for obj in doc.objects.values() {
let d = match obj {
Object::Dictionary(d) => d,
Object::Stream(s) => &s.dict,
_ => continue,
};
if d.has(b"SMask") || d.has(b"BM") || d.has(b"ca") || d.has(b"CA") || d.has(b"Group") {
return true;
}
if let Object::Stream(s) = obj {
if s.dict.has(b"Length1")
|| matches!(
get_name(&s.dict, b"Subtype").as_deref(),
Some("Image" | "Type1C" | "CIDFontType0C" | "OpenType")
)
{
continue;
}
if let Ok(bytes) = s.get_plain_content() {
match crate::inline_image::decode_strict(&bytes) {
Ok(content) if content.operations.iter().any(|o| o.operator == "BI") => {
return true
}
Err(_) if bytes.windows(2).any(|w| w == b"BI") => return true,
_ => {}
}
}
}
}
false
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DeviceColorUse {
pub gray: bool,
pub rgb: bool,
pub cmyk: bool,
pub undecodable_stream: bool,
}
impl DeviceColorUse {
fn everything() -> Self {
Self {
gray: true,
rgb: true,
cmyk: true,
undecodable_stream: false,
}
}
fn saturated(&self) -> bool {
self.gray && self.rgb && self.cmyk
}
fn note(&mut self, family: &[u8]) {
match family {
b"DeviceGray" | b"G" => self.gray = true,
b"DeviceRGB" | b"RGB" => self.rgb = true,
b"DeviceCMYK" | b"CMYK" => self.cmyk = true,
_ => {}
}
}
}
fn is_opaque_payload(stream: &Stream) -> bool {
let dict = &stream.dict;
if dict.has(b"Length1")
|| matches!(
get_name(dict, b"Subtype").as_deref(),
Some("Image" | "Type1C" | "CIDFontType0C" | "OpenType")
)
{
return true;
}
if matches!(
get_name(dict, b"Type").as_deref(),
Some("Metadata" | "XRef" | "ObjStm" | "EmbeddedFile" | "CMap")
) {
return true;
}
if dict.has(b"N") || dict.has(b"FunctionType") || dict.has(b"ShadingType") {
return true;
}
if dict.has(b"Width") && dict.has(b"Height") {
return true;
}
if let Ok(filters) = stream.filters() {
if filters.iter().any(|f| {
matches!(
AsRef::<[u8]>::as_ref(f),
b"DCTDecode" | b"DCT" | b"JPXDecode" | b"CCITTFaxDecode" | b"CCF" | b"JBIG2Decode"
)
}) {
return true;
}
}
false
}
fn note_colorspace_names(obj: &Object, in_icc_profile: bool, seen: &mut DeviceColorUse) {
match obj {
Object::Name(name) => {
if matches!(
name.as_slice(),
b"DeviceGray" | b"DeviceRGB" | b"DeviceCMYK"
) {
seen.note(name);
}
}
Object::Array(items) => {
for item in items {
note_colorspace_names(item, false, seen);
}
}
Object::Dictionary(dict) => {
for (key, value) in dict.iter() {
if in_icc_profile && key == b"Alternate" {
continue;
}
note_colorspace_names(value, false, seen);
}
}
Object::Stream(stream) => {
let is_icc = stream.dict.has(b"N") && !stream.dict.has(b"Width");
for (key, value) in stream.dict.iter() {
if is_icc && key == b"Alternate" {
continue;
}
note_colorspace_names(value, false, seen);
}
}
_ => {}
}
}
fn note_content_bytes(content: &[u8], seen: &mut DeviceColorUse) {
seen.gray = true;
for (operator, operands) in [
(b"k".as_slice(), 4usize),
(b"K".as_slice(), 4),
(b"rg".as_slice(), 3),
(b"RG".as_slice(), 3),
(b"g".as_slice(), 1),
(b"G".as_slice(), 1),
] {
if operator_with_operands(content, operator, operands) {
seen.note(match operator {
b"k" | b"K" => b"DeviceCMYK",
b"rg" | b"RG" => b"DeviceRGB",
_ => b"DeviceGray",
});
}
}
for name in [
b"/DeviceCMYK".as_slice(),
b"/DeviceRGB",
b"/DeviceGray",
b"/CMYK",
b"/RGB",
] {
if content.windows(name.len()).any(|w| w == name) {
seen.note(&name[1..]);
}
}
}
fn operator_with_operands(content: &[u8], operator: &[u8], operands: usize) -> bool {
let is_space = |b: u8| matches!(b, b' ' | b'\t' | b'\r' | b'\n' | 0x0c | 0);
let is_number = |b: u8| b.is_ascii_digit() || b == b'.' || b == b'-' || b == b'+';
for start in 0..content.len().saturating_sub(operator.len() - 1) {
if &content[start..start + operator.len()] != operator {
continue;
}
if start == 0 || !is_space(content[start - 1]) {
continue;
}
let after = content.get(start + operator.len());
let ends = match after {
None => true,
Some(&b) => is_space(b) || matches!(b, b'/' | b'[' | b']' | b'<' | b'>' | b'(' | b')'),
};
if !ends {
continue;
}
let mut at = start;
let mut found = 0;
while found < operands {
while at > 0 && is_space(content[at - 1]) {
at -= 1;
}
let end = at;
while at > 0 && is_number(content[at - 1]) {
at -= 1;
}
if at == end {
break;
}
found += 1;
}
if found == operands {
return true;
}
}
false
}
fn note_content_operators(operations: &[lopdf::content::Operation], seen: &mut DeviceColorUse) {
let numeric = |operands: &[Object], count: usize| {
operands.len() >= count
&& operands[operands.len() - count..]
.iter()
.all(|o| o.as_float().is_ok())
};
for op in operations {
match op.operator.as_str() {
"g" | "G" if numeric(&op.operands, 1) => seen.gray = true,
"rg" | "RG" if numeric(&op.operands, 3) => seen.rgb = true,
"k" | "K" if numeric(&op.operands, 4) => seen.cmyk = true,
"cs" | "CS" => {
if let Some(Ok(name)) = op.operands.first().map(Object::as_name) {
seen.note(name);
}
}
"BI" => {
if let Some(Ok(image)) = op.operands.first().map(Object::as_stream) {
for key in [b"CS".as_slice(), b"ColorSpace"] {
match image.dict.get(key) {
Ok(Object::Name(name)) => seen.note(name),
Ok(other) => note_colorspace_names(other, false, seen),
Err(_) => {}
}
}
}
}
"S" | "s" | "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" | "Tj" | "TJ" | "'" | "\"" => {
seen.gray = true
}
_ => {}
}
}
}
pub fn census_device_colors(doc: &Document) -> DeviceColorUse {
let mut seen = DeviceColorUse::default();
for obj in doc.objects.values() {
note_colorspace_names(obj, false, &mut seen);
}
for obj in doc.objects.values() {
if seen.saturated() {
break;
}
let Object::Stream(stream) = obj else {
continue;
};
if is_opaque_payload(stream) {
continue;
}
let bytes = match stream.get_plain_content() {
Ok(bytes) if !bytes.is_empty() || stream.content.is_empty() => bytes,
_ => {
let mut all = DeviceColorUse::everything();
all.undecodable_stream = true;
return all;
}
};
match crate::inline_image::decode_strict(&bytes) {
Ok(content) => note_content_operators(&content.operations, &mut seen),
Err(_) => note_content_bytes(&bytes, &mut seen),
}
}
seen
}
fn materialize_all_objects(doc: &mut Document) {
let numbers: Vec<u32> = doc.reference_table.entries.keys().copied().collect();
for number in numbers {
let id = (number, 0);
if doc.objects.contains_key(&id) {
continue;
}
if let Some(obj) = doc.get_object(id).ok().cloned() {
doc.objects.insert(id, obj);
}
}
}
fn ensure_explicit_resources(doc: &mut Document) {
let ids: Vec<ObjectId> = doc
.objects
.iter()
.filter_map(|(id, obj)| {
let Object::Stream(stream) = obj else {
return None;
};
let is_container = get_name(&stream.dict, b"Subtype").as_deref() == Some("Form")
|| stream.dict.has(b"PatternType");
let explicit = match stream.dict.get(b"Resources") {
Ok(Object::Dictionary(_)) => true,
Ok(Object::Reference(id)) => {
matches!(doc.objects.get(id), Some(Object::Dictionary(_)))
}
_ => false,
};
(is_container && !explicit).then_some(*id)
})
.collect();
for id in ids {
if let Some(Object::Stream(stream)) = doc.objects.get_mut(&id) {
stream
.dict
.set("Resources", Object::Dictionary(lopdf::Dictionary::new()));
}
}
}
pub fn normalize_colorspaces(doc: &mut Document) -> Result<ColorSpaceReport> {
materialize_all_objects(doc);
let census = census_device_colors(doc);
let had_output_intent = has_pdfa_output_intent(doc);
let device_cs = find_device_colorspaces(doc);
let pages_scanned = count_pages(doc);
let device_names: Vec<String> = device_cs.into_iter().map(|(_, name)| name).collect();
let unique_names: Vec<String> = {
let mut seen = std::collections::HashSet::new();
device_names
.into_iter()
.filter(|n| seen.insert(n.clone()))
.collect()
};
let registration = only_calrgb_registration_colors(doc);
let needs_output_device = registration && needs_explicit_output_device(doc);
let output_intent_added = if !had_output_intent && (!registration || needs_output_device) {
add_srgb_output_intent(doc)?;
true
} else {
false
};
ensure_explicit_resources(doc);
let cmyk_cs_id = census.cmyk.then(|| add_default_cmyk_colorspace(doc));
let rgb_cs_id = census.rgb.then(|| add_default_rgb_colorspace(doc));
if census.gray {
add_default_gray_colorspace(doc);
}
fix_device_colorspaces_in_deep_structures(doc, cmyk_cs_id, rgb_cs_id);
let separations_unified = normalize_separation_colorspaces(doc);
fix_separation_device_alternates(doc, cmyk_cs_id, rgb_cs_id);
let overprint_mode_fixed = fix_overprint_mode(doc);
let icc_n_fixed = fix_iccbased_n_value(doc);
Ok(ColorSpaceReport {
had_output_intent,
output_intent_added,
device_colorspaces_found: unique_names,
pages_scanned,
separations_unified,
overprint_mode_fixed,
icc_n_fixed,
device_color_use: census,
})
}
#[cfg(test)]
fn has_device_rgb_in_streams(doc: &Document) -> bool {
doc.objects.values().any(|obj| {
let Object::Stream(stream) = obj else {
return false;
};
if let Ok(Object::Integer(3)) = stream.dict.get(b"N") {
return true;
}
let content = match stream.decompressed_content() {
Ok(c) => c,
Err(_) => stream.content.clone(),
};
content
.windows(b"DeviceRGB".len())
.any(|w| w == b"DeviceRGB")
})
}
fn cmyk_icc_profile_bytes() -> Vec<u8> {
include_bytes!("../assets/CGATS001Compat-v2-micro.icc").to_vec()
}
fn srgb_icc_profile_bytes() -> Vec<u8> {
let total_size: u32 = 8632;
let mut p = Vec::with_capacity(total_size as usize);
p.extend_from_slice(&total_size.to_be_bytes()); p.extend_from_slice(b"\0\0\0\0"); p.extend_from_slice(&[2, 0x10, 0, 0]); p.extend_from_slice(b"mntr"); p.extend_from_slice(b"RGB "); p.extend_from_slice(b"XYZ "); p.extend_from_slice(&[0u8; 12]); p.extend_from_slice(b"acsp"); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 8]); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&0x0000F6D6_u32.to_be_bytes()); p.extend_from_slice(&0x00010000_u32.to_be_bytes()); p.extend_from_slice(&0x0000D32D_u32.to_be_bytes()); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 16]); p.extend_from_slice(&[0u8; 128 - 100]); debug_assert_eq!(p.len(), 128);
p.extend_from_slice(&9_u32.to_be_bytes());
let tags: &[(&[u8; 4], u32, u32)] = &[
(b"desc", 240, 95),
(b"cprt", 336, 12),
(b"wtpt", 348, 20),
(b"rXYZ", 368, 20),
(b"gXYZ", 388, 20),
(b"bXYZ", 408, 20),
(b"rTRC", 428, 8204),
(b"gTRC", 428, 8204), (b"bTRC", 428, 8204), ];
for (sig, offset, size) in tags {
p.extend_from_slice(*sig);
p.extend_from_slice(&offset.to_be_bytes());
p.extend_from_slice(&size.to_be_bytes());
}
debug_assert_eq!(p.len(), 240);
p.extend_from_slice(b"desc"); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&5_u32.to_be_bytes()); p.extend_from_slice(b"sRGB\0"); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 2]); p.push(0); p.extend_from_slice(&[0u8; 67]); p.push(0); debug_assert_eq!(p.len(), 336);
p.extend_from_slice(b"text"); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(b"CC0\0"); debug_assert_eq!(p.len(), 348);
fn write_xyz(p: &mut Vec<u8>, x: i32, y: i32, z: i32) {
p.extend_from_slice(b"XYZ "); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&x.to_be_bytes());
p.extend_from_slice(&y.to_be_bytes());
p.extend_from_slice(&z.to_be_bytes());
}
write_xyz(&mut p, 0x0000F6D6, 0x00010000, 0x0000D32D);
debug_assert_eq!(p.len(), 368);
write_xyz(&mut p, 0x00006FA3, 0x000038F6, 0x00000391);
debug_assert_eq!(p.len(), 388);
write_xyz(&mut p, 0x00006294, 0x0000B785, 0x000018DC);
debug_assert_eq!(p.len(), 408);
write_xyz(&mut p, 0x000024A1, 0x00000F85, 0x0000B6D4);
debug_assert_eq!(p.len(), 428);
p.extend_from_slice(b"curv");
p.extend_from_slice(&[0u8; 4]);
p.extend_from_slice(&4096_u32.to_be_bytes());
for sample in 0..4096 {
let encoded = f64::from(sample) / 4095.0;
let linear = if encoded <= 0.04045 {
encoded / 12.92
} else {
((encoded + 0.055) / 1.055).powf(2.4)
};
p.extend_from_slice(&((linear * 65535.0).round() as u16).to_be_bytes());
}
debug_assert_eq!(p.len(), total_size as usize);
p
}
fn add_default_cmyk_colorspace(doc: &mut Document) -> ObjectId {
let icc_bytes = cmyk_icc_profile_bytes();
let icc_dict = dictionary! {
"N" => Object::Integer(4),
"Alternate" => Object::Name(b"DeviceCMYK".to_vec()),
};
let icc_stream = Stream::new(icc_dict, icc_bytes);
let icc_id = doc.add_object(Object::Stream(icc_stream));
let cs_array = Object::Array(vec![
Object::Name(b"ICCBased".to_vec()),
Object::Reference(icc_id),
]);
let cs_id = doc.add_object(cs_array);
let page_ids: Vec<ObjectId> = doc
.objects
.iter()
.filter_map(|(id, obj)| {
if let Object::Dictionary(dict) = obj {
if get_name(dict, b"Type").as_deref() == Some("Page") {
return Some(*id);
}
}
None
})
.collect();
for page_id in page_ids {
let res_id = {
let Some(Object::Dictionary(dict)) = doc.objects.get(&page_id) else {
continue;
};
match dict.get(b"Resources").ok() {
Some(Object::Reference(id)) => Some(*id),
Some(Object::Dictionary(_)) => None, _ => None,
}
};
if let Some(res_id) = res_id {
let cs_ref_id = {
if let Some(Object::Dictionary(res)) = doc.objects.get(&res_id) {
match res.get(b"ColorSpace").ok() {
Some(Object::Reference(id)) => Some(*id),
_ => None,
}
} else {
None
}
};
if let Some(cs_ref_id) = cs_ref_id {
if let Some(Object::Dictionary(ref mut cs_dict)) = doc.objects.get_mut(&cs_ref_id) {
if !cs_dict.has(b"DefaultCMYK") {
cs_dict.set("DefaultCMYK", Object::Reference(cs_id));
}
}
} else if let Some(Object::Dictionary(ref mut res)) = doc.objects.get_mut(&res_id) {
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultCMYK") {
cs_dict.set("DefaultCMYK", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
}
}
} else {
if let Some(Object::Dictionary(ref mut dict)) = doc.objects.get_mut(&page_id) {
let mut res = match dict.get(b"Resources") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultCMYK") {
cs_dict.set("DefaultCMYK", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
dict.set("Resources", Object::Dictionary(res));
}
}
}
}
let form_ids: Vec<ObjectId> = doc
.objects
.iter()
.filter_map(|(id, obj)| {
if let Object::Stream(stream) = obj {
if get_name(&stream.dict, b"Subtype").as_deref() == Some("Form") {
return Some(*id);
}
}
None
})
.collect();
for form_id in form_ids {
let res_ref_id = {
if let Some(Object::Stream(stream)) = doc.objects.get(&form_id) {
match stream.dict.get(b"Resources").ok() {
Some(Object::Reference(id)) => Some(*id),
_ => None,
}
} else {
None
}
};
if let Some(res_ref_id) = res_ref_id {
if let Some(Object::Dictionary(ref mut res)) = doc.objects.get_mut(&res_ref_id) {
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultCMYK") {
cs_dict.set("DefaultCMYK", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
}
}
} else if let Some(Object::Stream(ref mut stream)) = doc.objects.get_mut(&form_id) {
let mut res = match stream.dict.get(b"Resources") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultCMYK") {
cs_dict.set("DefaultCMYK", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
stream.dict.set("Resources", Object::Dictionary(res));
}
}
}
let pattern_ids: Vec<ObjectId> = doc
.objects
.iter()
.filter_map(|(id, obj)| {
if let Object::Stream(stream) = obj {
let pt = stream
.dict
.get(b"PatternType")
.ok()
.and_then(|o| o.as_i64().ok());
if pt == Some(1) {
return Some(*id);
}
}
None
})
.collect();
for pat_id in pattern_ids {
let res_ref_id = {
if let Some(Object::Stream(stream)) = doc.objects.get(&pat_id) {
match stream.dict.get(b"Resources").ok() {
Some(Object::Reference(id)) => Some(*id),
_ => None,
}
} else {
None
}
};
if let Some(res_ref_id) = res_ref_id {
if let Some(Object::Dictionary(ref mut res)) = doc.objects.get_mut(&res_ref_id) {
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultCMYK") {
cs_dict.set("DefaultCMYK", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
}
}
} else if let Some(Object::Stream(ref mut stream)) = doc.objects.get_mut(&pat_id) {
let mut res = match stream.dict.get(b"Resources") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultCMYK") {
cs_dict.set("DefaultCMYK", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
stream.dict.set("Resources", Object::Dictionary(res));
}
}
}
add_default_cs_to_annotation_appearances(doc, b"DefaultCMYK", cs_id);
cs_id
}
fn add_default_rgb_colorspace(doc: &mut Document) -> ObjectId {
let icc_bytes = srgb_icc_profile_bytes();
let icc_dict = dictionary! {
"N" => Object::Integer(3),
"Alternate" => Object::Name(b"DeviceRGB".to_vec()),
};
let icc_stream = Stream::new(icc_dict, icc_bytes);
let icc_id = doc.add_object(Object::Stream(icc_stream));
let cs_array = Object::Array(vec![
Object::Name(b"ICCBased".to_vec()),
Object::Reference(icc_id),
]);
let cs_id = doc.add_object(cs_array);
let page_ids: Vec<ObjectId> = doc
.objects
.iter()
.filter_map(|(id, obj)| {
if let Object::Dictionary(dict) = obj {
if get_name(dict, b"Type").as_deref() == Some("Page") {
return Some(*id);
}
}
None
})
.collect();
for page_id in page_ids {
let res_id = {
let Some(Object::Dictionary(dict)) = doc.objects.get(&page_id) else {
continue;
};
match dict.get(b"Resources").ok() {
Some(Object::Reference(id)) => Some(*id),
_ => None,
}
};
if let Some(res_id) = res_id {
let cs_ref_id = {
if let Some(Object::Dictionary(res)) = doc.objects.get(&res_id) {
match res.get(b"ColorSpace").ok() {
Some(Object::Reference(id)) => Some(*id),
_ => None,
}
} else {
None
}
};
if let Some(cs_ref_id) = cs_ref_id {
if let Some(Object::Dictionary(ref mut cs_dict)) = doc.objects.get_mut(&cs_ref_id) {
if !cs_dict.has(b"DefaultRGB") {
cs_dict.set("DefaultRGB", Object::Reference(cs_id));
}
}
} else if let Some(Object::Dictionary(ref mut res)) = doc.objects.get_mut(&res_id) {
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultRGB") {
cs_dict.set("DefaultRGB", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
}
}
} else if let Some(Object::Dictionary(ref mut dict)) = doc.objects.get_mut(&page_id) {
let mut res = match dict.get(b"Resources") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultRGB") {
cs_dict.set("DefaultRGB", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
dict.set("Resources", Object::Dictionary(res));
}
}
}
let form_ids: Vec<ObjectId> = doc
.objects
.iter()
.filter_map(|(id, obj)| {
if let Object::Stream(stream) = obj {
if get_name(&stream.dict, b"Subtype").as_deref() == Some("Form") {
return Some(*id);
}
}
None
})
.collect();
for form_id in form_ids {
let res_ref_id = {
if let Some(Object::Stream(stream)) = doc.objects.get(&form_id) {
match stream.dict.get(b"Resources").ok() {
Some(Object::Reference(id)) => Some(*id),
_ => None,
}
} else {
None
}
};
if let Some(res_ref_id) = res_ref_id {
if let Some(Object::Dictionary(ref mut res)) = doc.objects.get_mut(&res_ref_id) {
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultRGB") {
cs_dict.set("DefaultRGB", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
}
}
} else if let Some(Object::Stream(ref mut stream)) = doc.objects.get_mut(&form_id) {
let mut res = match stream.dict.get(b"Resources") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultRGB") {
cs_dict.set("DefaultRGB", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
stream.dict.set("Resources", Object::Dictionary(res));
}
}
}
let pattern_ids: Vec<ObjectId> = doc
.objects
.iter()
.filter_map(|(id, obj)| {
if let Object::Stream(stream) = obj {
let pt = stream
.dict
.get(b"PatternType")
.ok()
.and_then(|o| o.as_i64().ok());
if pt == Some(1) {
return Some(*id);
}
}
None
})
.collect();
for pat_id in pattern_ids {
let res_ref_id = {
if let Some(Object::Stream(stream)) = doc.objects.get(&pat_id) {
match stream.dict.get(b"Resources").ok() {
Some(Object::Reference(id)) => Some(*id),
_ => None,
}
} else {
None
}
};
if let Some(res_ref_id) = res_ref_id {
if let Some(Object::Dictionary(ref mut res)) = doc.objects.get_mut(&res_ref_id) {
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultRGB") {
cs_dict.set("DefaultRGB", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
}
}
} else if let Some(Object::Stream(ref mut stream)) = doc.objects.get_mut(&pat_id) {
let mut res = match stream.dict.get(b"Resources") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultRGB") {
cs_dict.set("DefaultRGB", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
stream.dict.set("Resources", Object::Dictionary(res));
}
}
}
add_default_cs_to_annotation_appearances(doc, b"DefaultRGB", cs_id);
cs_id
}
fn add_default_gray_colorspace(doc: &mut Document) -> ObjectId {
let icc_bytes = gray_icc_profile_bytes();
let icc_dict = dictionary! {
"N" => Object::Integer(1),
"Alternate" => Object::Name(b"DeviceGray".to_vec()),
};
let icc_stream = Stream::new(icc_dict, icc_bytes);
let icc_id = doc.add_object(Object::Stream(icc_stream));
let cs_array = Object::Array(vec![
Object::Name(b"ICCBased".to_vec()),
Object::Reference(icc_id),
]);
let cs_id = doc.add_object(cs_array);
let page_ids: Vec<ObjectId> = doc
.objects
.iter()
.filter_map(|(id, obj)| {
if let Object::Dictionary(dict) = obj {
if get_name(dict, b"Type").as_deref() == Some("Page") {
return Some(*id);
}
}
None
})
.collect();
for page_id in page_ids {
let res_id = {
let Some(Object::Dictionary(dict)) = doc.objects.get(&page_id) else {
continue;
};
match dict.get(b"Resources").ok() {
Some(Object::Reference(id)) => Some(*id),
_ => None,
}
};
if let Some(res_id) = res_id {
let cs_ref_id = {
if let Some(Object::Dictionary(res)) = doc.objects.get(&res_id) {
match res.get(b"ColorSpace").ok() {
Some(Object::Reference(id)) => Some(*id),
_ => None,
}
} else {
None
}
};
if let Some(cs_ref_id) = cs_ref_id {
if let Some(Object::Dictionary(ref mut cs_dict)) = doc.objects.get_mut(&cs_ref_id) {
if !cs_dict.has(b"DefaultGray") {
cs_dict.set("DefaultGray", Object::Reference(cs_id));
}
}
} else if let Some(Object::Dictionary(ref mut res)) = doc.objects.get_mut(&res_id) {
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultGray") {
cs_dict.set("DefaultGray", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
}
}
} else if let Some(Object::Dictionary(ref mut dict)) = doc.objects.get_mut(&page_id) {
let mut res = match dict.get(b"Resources") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultGray") {
cs_dict.set("DefaultGray", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
dict.set("Resources", Object::Dictionary(res));
}
}
}
let form_ids: Vec<ObjectId> = doc
.objects
.iter()
.filter_map(|(id, obj)| {
if let Object::Stream(stream) = obj {
if get_name(&stream.dict, b"Subtype").as_deref() == Some("Form") {
return Some(*id);
}
}
None
})
.collect();
for form_id in form_ids {
let res_ref_id = {
if let Some(Object::Stream(stream)) = doc.objects.get(&form_id) {
match stream.dict.get(b"Resources").ok() {
Some(Object::Reference(id)) => Some(*id),
_ => None,
}
} else {
None
}
};
if let Some(res_ref_id) = res_ref_id {
if let Some(Object::Dictionary(ref mut res)) = doc.objects.get_mut(&res_ref_id) {
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultGray") {
cs_dict.set("DefaultGray", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
}
}
} else if let Some(Object::Stream(ref mut stream)) = doc.objects.get_mut(&form_id) {
let mut res = match stream.dict.get(b"Resources") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultGray") {
cs_dict.set("DefaultGray", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
stream.dict.set("Resources", Object::Dictionary(res));
}
}
}
let pattern_ids: Vec<ObjectId> = doc
.objects
.iter()
.filter_map(|(id, obj)| {
if let Object::Stream(stream) = obj {
let pt = stream
.dict
.get(b"PatternType")
.ok()
.and_then(|o| o.as_i64().ok());
if pt == Some(1) {
return Some(*id);
}
}
None
})
.collect();
for pat_id in pattern_ids {
let res_ref_id = {
if let Some(Object::Stream(stream)) = doc.objects.get(&pat_id) {
match stream.dict.get(b"Resources").ok() {
Some(Object::Reference(id)) => Some(*id),
_ => None,
}
} else {
None
}
};
if let Some(res_ref_id) = res_ref_id {
if let Some(Object::Dictionary(ref mut res)) = doc.objects.get_mut(&res_ref_id) {
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultGray") {
cs_dict.set("DefaultGray", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
}
}
} else if let Some(Object::Stream(ref mut stream)) = doc.objects.get_mut(&pat_id) {
let mut res = match stream.dict.get(b"Resources") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(b"DefaultGray") {
cs_dict.set("DefaultGray", Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
stream.dict.set("Resources", Object::Dictionary(res));
}
}
}
add_default_cs_to_annotation_appearances(doc, b"DefaultGray", cs_id);
cs_id
}
fn add_default_cs_to_annotation_appearances(doc: &mut Document, name: &[u8], cs_id: ObjectId) {
let mut ap_stream_ids: Vec<ObjectId> = Vec::new();
let ids: Vec<ObjectId> = doc.objects.keys().copied().collect();
for id in &ids {
let ap_refs = match doc.objects.get(id) {
Some(Object::Dictionary(dict)) => {
let is_annot = dict.has(b"Rect") && (dict.has(b"Subtype") || dict.has(b"Type"));
if !is_annot {
continue;
}
let ap = match dict.get(b"AP").ok() {
Some(Object::Dictionary(ap)) => Some(ap.clone()),
Some(Object::Reference(r)) => {
if let Some(Object::Dictionary(ap)) = doc.objects.get(r) {
Some(ap.clone())
} else {
None
}
}
_ => None,
};
let Some(ap) = ap else { continue };
let mut refs = Vec::new();
for key in &[b"N".as_slice(), b"R", b"D"] {
match ap.get(key).ok() {
Some(Object::Reference(r)) => refs.push(*r),
Some(Object::Dictionary(sub)) => {
for (_, v) in sub.iter() {
if let Object::Reference(r) = v {
refs.push(*r);
}
}
}
_ => {}
}
}
refs
}
_ => continue,
};
ap_stream_ids.extend(ap_refs);
}
ap_stream_ids.sort();
ap_stream_ids.dedup();
let name_str = String::from_utf8_lossy(name).to_string();
for ap_id in ap_stream_ids {
let is_stream = matches!(doc.objects.get(&ap_id), Some(Object::Stream(_)));
if !is_stream {
continue;
}
let res_ref_id = {
if let Some(Object::Stream(stream)) = doc.objects.get(&ap_id) {
match stream.dict.get(b"Resources").ok() {
Some(Object::Reference(id)) => Some(*id),
_ => None,
}
} else {
None
}
};
if let Some(res_ref_id) = res_ref_id {
if let Some(Object::Dictionary(ref mut res)) = doc.objects.get_mut(&res_ref_id) {
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(name) {
cs_dict.set(name_str.clone(), Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
}
}
} else if let Some(Object::Stream(ref mut stream)) = doc.objects.get_mut(&ap_id) {
let mut res = match stream.dict.get(b"Resources") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
let mut cs_dict = match res.get(b"ColorSpace") {
Ok(Object::Dictionary(d)) => d.clone(),
_ => lopdf::Dictionary::new(),
};
if !cs_dict.has(name) {
cs_dict.set(name_str.clone(), Object::Reference(cs_id));
res.set("ColorSpace", Object::Dictionary(cs_dict));
stream.dict.set("Resources", Object::Dictionary(res));
}
}
}
}
fn fix_device_colorspaces_in_deep_structures(
doc: &mut Document,
cmyk_cs_id: Option<ObjectId>,
rgb_cs_id: Option<ObjectId>,
) {
let replacement = |cs_name: &str| -> Option<ObjectId> {
match cs_name {
"DeviceCMYK" => cmyk_cs_id,
"DeviceRGB" => rgb_cs_id,
_ => None,
}
};
let resolve_device_cs =
|dict: &lopdf::Dictionary, key: &[u8], doc: &Document| -> Option<String> {
match dict.get(key).ok() {
Some(Object::Name(n)) => {
let name = String::from_utf8_lossy(n).to_string();
if name == "DeviceCMYK" || name == "DeviceRGB" {
Some(name)
} else {
None
}
}
Some(Object::Reference(ref_id)) => {
match doc.objects.get(ref_id) {
Some(Object::Name(n)) => {
let name = String::from_utf8_lossy(n).to_string();
if name == "DeviceCMYK" || name == "DeviceRGB" {
Some(name)
} else {
None
}
}
_ => None,
}
}
_ => None,
}
};
let ids: Vec<ObjectId> = doc.objects.keys().copied().collect();
for id in ids {
let obj = match doc.objects.get(&id) {
Some(o) => o,
None => continue,
};
match obj {
Object::Dictionary(dict) => {
if dict.get(b"ShadingType").is_ok() {
if let Some(repl) =
resolve_device_cs(dict, b"ColorSpace", doc).and_then(|n| replacement(&n))
{
if let Some(Object::Dictionary(ref mut d)) = doc.objects.get_mut(&id) {
d.set("ColorSpace", Object::Reference(repl));
}
continue;
}
}
if dict.get(b"PatternType").ok().and_then(|o| o.as_i64().ok()) == Some(2) {
let inline_repl = match dict.get(b"Shading").ok() {
Some(Object::Dictionary(shading)) => {
resolve_device_cs(shading, b"ColorSpace", doc)
.and_then(|n| replacement(&n))
}
_ => None,
};
if let Some(repl) = inline_repl {
if let Some(Object::Dictionary(ref mut d)) = doc.objects.get_mut(&id) {
if let Ok(Object::Dictionary(ref mut shading)) = d.get_mut(b"Shading") {
shading.set("ColorSpace", Object::Reference(repl));
}
}
}
}
}
Object::Stream(stream) => {
let dict = &stream.dict;
if dict.get(b"ShadingType").is_ok() {
if let Some(repl) =
resolve_device_cs(dict, b"ColorSpace", doc).and_then(|n| replacement(&n))
{
if let Some(Object::Stream(ref mut s)) = doc.objects.get_mut(&id) {
s.dict.set("ColorSpace", Object::Reference(repl));
}
continue;
}
}
let group_repl = if let Ok(Object::Dictionary(group)) = dict.get(b"Group") {
resolve_device_cs(group, b"CS", doc).and_then(|n| replacement(&n))
} else {
None
};
if let Some(repl) = group_repl {
if let Some(Object::Stream(ref mut s)) = doc.objects.get_mut(&id) {
if let Ok(Object::Dictionary(ref mut group)) = s.dict.get_mut(b"Group") {
group.set("CS", Object::Reference(repl));
}
}
continue;
}
let is_pattern = get_name(dict, b"Type").as_deref() == Some("Pattern")
|| dict.get(b"PatternType").is_ok();
if is_pattern {
if let Some(repl) = get_name(dict, b"ColorSpace").and_then(|n| replacement(&n))
{
if let Some(Object::Stream(ref mut s)) = doc.objects.get_mut(&id) {
s.dict.set("ColorSpace", Object::Reference(repl));
}
continue;
}
}
let is_image = get_name(dict, b"Subtype").as_deref() == Some("Image");
if is_image {
if let Some(repl) = get_name(dict, b"ColorSpace").and_then(|n| replacement(&n))
{
if let Some(Object::Stream(ref mut s)) = doc.objects.get_mut(&id) {
s.dict.set("ColorSpace", Object::Reference(repl));
}
}
}
}
_ => {}
}
}
let ids2: Vec<ObjectId> = doc.objects.keys().copied().collect();
for id in ids2 {
let group_ref_id = {
let obj = match doc.objects.get(&id) {
Some(o) => o,
None => continue,
};
match obj {
Object::Stream(stream) => match stream.dict.get(b"Group").ok() {
Some(Object::Reference(gid)) => Some(*gid),
_ => None,
},
_ => None,
}
};
if let Some(gid) = group_ref_id {
let repl = if let Some(Object::Dictionary(group)) = doc.objects.get(&gid) {
resolve_device_cs(group, b"CS", doc).and_then(|n| replacement(&n))
} else {
None
};
if let Some(repl) = repl {
if let Some(Object::Dictionary(ref mut group)) = doc.objects.get_mut(&gid) {
group.set("CS", Object::Reference(repl));
}
}
}
}
let ids3: Vec<ObjectId> = doc.objects.keys().copied().collect();
for id in ids3 {
let repl = if let Some(Object::Dictionary(dict)) = doc.objects.get(&id) {
let is_pattern = get_name(dict, b"Type").as_deref() == Some("Pattern")
|| dict.get(b"PatternType").is_ok();
if is_pattern {
get_name(dict, b"ColorSpace").and_then(|n| replacement(&n))
} else {
None
}
} else {
None
};
if let Some(repl) = repl {
if let Some(Object::Dictionary(ref mut dict)) = doc.objects.get_mut(&id) {
dict.set("ColorSpace", Object::Reference(repl));
}
}
}
fix_devicen_process_colors(doc, cmyk_cs_id, rgb_cs_id);
ensure_devicen_colorants(doc);
}
fn ensure_devicen_colorants(doc: &mut Document) {
let process_names: &[&[u8]] = &[b"Cyan", b"Magenta", b"Yellow", b"Black", b"None", b"All"];
let ids: Vec<ObjectId> = doc.objects.keys().copied().collect();
for id in ids {
let info = {
let Some(Object::Array(arr)) = doc.objects.get(&id) else {
continue;
};
if arr.len() < 4 {
continue;
}
let is_dn = matches!(&arr[0], Object::Name(n) if n == b"DeviceN" || n == b"NChannel");
if !is_dn {
continue;
}
let names: Vec<Vec<u8>> = match &arr[1] {
Object::Array(na) => na
.iter()
.filter_map(|o| {
if let Object::Name(n) = o {
Some(n.clone())
} else {
None
}
})
.collect(),
_ => continue,
};
let spots: Vec<Vec<u8>> = names
.into_iter()
.filter(|n| !process_names.contains(&n.as_slice()))
.collect();
if spots.is_empty() {
continue;
}
let alt = arr.get(2).cloned();
let tint = arr.get(3).cloned();
let attrs_ref = if arr.len() > 4 {
match &arr[4] {
Object::Reference(r) => Some(*r),
_ => None,
}
} else {
None
};
(spots, alt, tint, attrs_ref)
};
let (spots, alt, tint, attrs_ref) = info;
let (Some(alt_cs), Some(tint_fn)) = (alt, tint) else {
continue;
};
if let Some(attrs_id) = attrs_ref {
let existing_colorants: Option<lopdf::Dictionary> = {
if let Some(Object::Dictionary(d)) = doc.objects.get(&attrs_id) {
match d.get(b"Colorants").ok() {
Some(Object::Dictionary(cd)) => Some(cd.clone()),
Some(Object::Reference(r)) => {
if let Some(Object::Dictionary(cd)) = doc.objects.get(r) {
Some(cd.clone())
} else {
None
}
}
_ => None,
}
} else {
None
}
};
let mut cd = existing_colorants.unwrap_or_default();
let mut added = false;
for name in &spots {
if !cd.has(name.as_slice()) {
let sep = Object::Array(vec![
Object::Name(b"Separation".to_vec()),
Object::Name(name.clone()),
alt_cs.clone(),
tint_fn.clone(),
]);
let key = String::from_utf8_lossy(name).to_string();
cd.set(key, sep);
added = true;
}
}
if added {
if let Some(Object::Dictionary(ref mut attrs)) = doc.objects.get_mut(&attrs_id) {
attrs.set("Colorants", Object::Dictionary(cd));
}
}
}
else {
let mut cd = lopdf::Dictionary::new();
for name in &spots {
let sep = Object::Array(vec![
Object::Name(b"Separation".to_vec()),
Object::Name(name.clone()),
alt_cs.clone(),
tint_fn.clone(),
]);
let key = String::from_utf8_lossy(name).to_string();
cd.set(key, sep);
}
let attrs = lopdf::dictionary! {
"Colorants" => Object::Dictionary(cd),
};
if let Some(Object::Array(ref mut arr)) = doc.objects.get_mut(&id) {
if arr.len() == 4 {
arr.push(Object::Dictionary(attrs));
}
}
}
}
}
fn fix_devicen_process_colors(
doc: &mut Document,
cmyk_cs_id: Option<ObjectId>,
rgb_cs_id: Option<ObjectId>,
) {
let replacement = |name: &[u8]| -> Option<ObjectId> {
if name == b"DeviceCMYK" {
cmyk_cs_id
} else if name == b"DeviceRGB" {
rgb_cs_id
} else {
None
}
};
let ids: Vec<ObjectId> = doc.objects.keys().copied().collect();
for id in ids {
let fix = {
let Some(Object::Array(arr)) = doc.objects.get(&id) else {
continue;
};
if arr.len() < 4 {
continue;
}
let is_devicen =
matches!(&arr[0], Object::Name(n) if n == b"DeviceN" || n == b"NChannel");
if !is_devicen {
continue;
}
let alt_repl = match &arr[2] {
Object::Name(n) => replacement(n),
Object::Reference(ref_id) => match doc.objects.get(ref_id) {
Some(Object::Name(n)) => replacement(n),
_ => None,
},
_ => None,
};
let process_repl = if arr.len() > 4 {
let attrs_dict = match &arr[4] {
Object::Dictionary(d) => Some(d),
Object::Reference(ref_id) => {
if let Some(Object::Dictionary(d)) = doc.objects.get(ref_id) {
Some(d)
} else {
None
}
}
_ => None,
};
if let Some(attrs) = attrs_dict {
let process_dict = match attrs.get(b"Process").ok() {
Some(Object::Dictionary(d)) => Some(d),
Some(Object::Reference(ref_id)) => {
if let Some(Object::Dictionary(d)) = doc.objects.get(ref_id) {
Some(d)
} else {
None
}
}
_ => None,
};
if let Some(process) = process_dict {
match process.get(b"ColorSpace").ok() {
Some(Object::Name(n)) => replacement(n),
_ => None,
}
} else {
None
}
} else {
None
}
} else {
None
};
(alt_repl, process_repl)
};
let (alt_repl, process_repl) = fix;
if let Some(repl) = alt_repl {
if let Some(Object::Array(ref mut arr)) = doc.objects.get_mut(&id) {
arr[2] = Object::Reference(repl);
}
}
if let Some(repl) = process_repl {
if let Some(Object::Array(ref mut arr)) = doc.objects.get_mut(&id) {
if arr.len() > 4 {
if let Object::Dictionary(ref mut attrs) = arr[4] {
if let Ok(Object::Dictionary(ref mut process)) = attrs.get_mut(b"Process") {
process.set("ColorSpace", Object::Reference(repl));
}
}
}
}
}
}
let ids2: Vec<ObjectId> = doc.objects.keys().copied().collect();
for id in ids2 {
let repl = {
let Some(Object::Dictionary(dict)) = doc.objects.get(&id) else {
continue;
};
if let Ok(Object::Dictionary(process)) = dict.get(b"Process") {
match process.get(b"ColorSpace").ok() {
Some(Object::Name(n)) => replacement(n),
_ => None,
}
} else {
None
}
};
if let Some(repl) = repl {
if let Some(Object::Dictionary(ref mut dict)) = doc.objects.get_mut(&id) {
if let Ok(Object::Dictionary(ref mut process)) = dict.get_mut(b"Process") {
process.set("ColorSpace", Object::Reference(repl));
}
}
}
}
let ids3: Vec<ObjectId> = doc.objects.keys().copied().collect();
let mut process_ref_ids: std::collections::HashSet<ObjectId> = std::collections::HashSet::new();
for id in &ids3 {
if let Some(Object::Dictionary(dict)) = doc.objects.get(id) {
if let Ok(Object::Reference(process_id)) = dict.get(b"Process") {
process_ref_ids.insert(*process_id);
}
}
}
for process_id in process_ref_ids {
let repl = if let Some(Object::Dictionary(process)) = doc.objects.get(&process_id) {
match process.get(b"ColorSpace").ok() {
Some(Object::Name(n)) => replacement(n),
_ => None,
}
} else {
None
};
if let Some(repl) = repl {
if let Some(Object::Dictionary(ref mut process)) = doc.objects.get_mut(&process_id) {
process.set("ColorSpace", Object::Reference(repl));
}
}
}
}
fn fix_separation_device_alternates(
doc: &mut Document,
cmyk_cs_id: Option<ObjectId>,
rgb_cs_id: Option<ObjectId>,
) {
let replacement = |name: &[u8]| -> Option<ObjectId> {
if name == b"DeviceCMYK" {
cmyk_cs_id
} else if name == b"DeviceRGB" {
rgb_cs_id
} else {
None
}
};
let ids: Vec<ObjectId> = doc.objects.keys().copied().collect();
let mut to_fix: Vec<(ObjectId, ObjectId)> = Vec::new();
for id in ids {
let repl = if let Some(Object::Array(arr)) = doc.objects.get(&id) {
if arr.len() >= 4 {
if let Object::Name(cs_type) = &arr[0] {
if cs_type == b"Separation" {
match &arr[2] {
Object::Name(n) => replacement(n),
Object::Reference(ref_id) => match doc.objects.get(ref_id) {
Some(Object::Name(n)) => replacement(n),
_ => None,
},
_ => None,
}
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
};
if let Some(repl) = repl {
to_fix.push((id, repl));
}
}
for (id, repl) in to_fix {
if let Some(Object::Array(ref mut arr)) = doc.objects.get_mut(&id) {
arr[2] = Object::Reference(repl);
}
}
}
fn normalize_separation_colorspaces(doc: &mut Document) -> usize {
use std::collections::{HashMap, HashSet};
materialize_all_objects(doc);
let mut by_name: HashMap<Vec<u8>, Vec<(ObjectId, Object, Object)>> = HashMap::new();
let mut visited_refs: HashSet<ObjectId> = HashSet::new();
for (&id, obj) in &doc.objects {
collect_separations_recursive(doc, id, obj, &mut by_name, &mut visited_refs, 0);
}
let mut redirects: Vec<(ObjectId, ObjectId)> = Vec::new(); let mut content_fixes: Vec<(ObjectId, Vec<u8>, Object, Object)> = Vec::new();
for (name, entries) in &by_name {
if entries.len() <= 1 {
continue;
}
let mut seen_ids: HashSet<ObjectId> = HashSet::new();
let mut unique_entries: Vec<&(ObjectId, Object, Object)> = entries
.iter()
.filter(|(id, _, _)| seen_ids.insert(*id))
.collect();
if unique_entries.len() <= 1 {
continue;
}
unique_entries.sort_by_key(|(id, _, _)| *id);
let (canon_id, canon_alt, canon_tint) = unique_entries[0];
let canonical_tint_ref: Object = match canon_tint {
Object::Reference(_) => canon_tint.clone(),
other => {
let new_id = doc.add_object(other.clone());
Object::Reference(new_id)
}
};
for (obj_id, _, _) in &unique_entries {
content_fixes.push((
*obj_id,
name.clone(),
canon_alt.clone(),
canonical_tint_ref.clone(),
));
}
for (non_canon_id, _, _) in &unique_entries[1..] {
redirects.push((*non_canon_id, *canon_id));
}
}
let mut seen: HashSet<(ObjectId, Vec<u8>)> = HashSet::new();
let mut count = 0usize;
for (id, name, alt, tint) in content_fixes {
if seen.insert((id, name.clone())) {
if let Some(obj) = doc.objects.get_mut(&id) {
fix_separation_recursive(obj, &name, &alt, &tint, 0);
count += 1;
}
}
}
if !redirects.is_empty() {
let all_ids: Vec<ObjectId> = doc.objects.keys().copied().collect();
for obj_id in all_ids {
if let Some(obj) = doc.objects.get_mut(&obj_id) {
for (old_id, new_id) in &redirects {
redirect_references_recursive(obj, *old_id, *new_id, 0);
}
}
}
count += redirects.len();
}
for (old_id, _new_id) in &redirects {
doc.objects.remove(old_id);
}
count
}
const MAX_OBJECT_DEPTH: usize = 128;
fn collect_separations_recursive(
doc: &Document,
id: ObjectId,
obj: &Object,
map: &mut std::collections::HashMap<Vec<u8>, Vec<(ObjectId, Object, Object)>>,
visited_refs: &mut std::collections::HashSet<ObjectId>,
depth: usize,
) {
if depth > MAX_OBJECT_DEPTH {
return;
}
match obj {
Object::Array(arr) => {
if arr.len() >= 4 {
if let Object::Name(cs_type) = &arr[0] {
if cs_type == b"Separation" {
if let Object::Name(name) = &arr[1] {
map.entry(normalize_spot_name(name)).or_default().push((
id,
arr[2].clone(),
arr[3].clone(),
));
}
}
}
}
for item in arr {
collect_separations_recursive(doc, id, item, map, visited_refs, depth + 1);
}
}
Object::Dictionary(dict) => {
for (_, val) in dict.iter() {
collect_separations_recursive(doc, id, val, map, visited_refs, depth + 1);
}
}
Object::Stream(stream) => {
for (_, val) in stream.dict.iter() {
collect_separations_recursive(doc, id, val, map, visited_refs, depth + 1);
}
}
Object::Reference(ref_id) if visited_refs.insert(*ref_id) => {
if let Ok(resolved) = doc.get_object(*ref_id) {
collect_separations_recursive(doc, *ref_id, resolved, map, visited_refs, depth + 1);
}
}
_ => {}
}
}
fn redirect_references_recursive(
obj: &mut Object,
old_id: ObjectId,
new_id: ObjectId,
depth: usize,
) {
if depth > MAX_OBJECT_DEPTH {
return;
}
match obj {
Object::Reference(r) if *r == old_id => {
*r = new_id;
}
Object::Array(arr) => {
for item in arr.iter_mut() {
redirect_references_recursive(item, old_id, new_id, depth + 1);
}
}
Object::Dictionary(dict) => {
for (_, val) in dict.iter_mut() {
redirect_references_recursive(val, old_id, new_id, depth + 1);
}
}
Object::Stream(stream) => {
for (_, val) in stream.dict.iter_mut() {
redirect_references_recursive(val, old_id, new_id, depth + 1);
}
}
_ => {}
}
}
fn fix_separation_recursive(
obj: &mut Object,
name: &[u8],
alt: &Object,
tint: &Object,
depth: usize,
) {
if depth > MAX_OBJECT_DEPTH {
return;
}
match obj {
Object::Array(arr) => {
if arr.len() >= 4 {
if let Object::Name(cs_type) = &arr[0] {
if cs_type == b"Separation" {
if let Object::Name(n) = &arr[1] {
if normalize_spot_name(n) == name {
arr[2] = alt.clone();
arr[3] = tint.clone();
}
}
}
}
}
for item in arr.iter_mut() {
fix_separation_recursive(item, name, alt, tint, depth + 1);
}
}
Object::Dictionary(dict) => {
for (_, val) in dict.iter_mut() {
fix_separation_recursive(val, name, alt, tint, depth + 1);
}
}
Object::Stream(stream) => {
for (_, val) in stream.dict.iter_mut() {
fix_separation_recursive(val, name, alt, tint, depth + 1);
}
}
_ => {}
}
}
fn normalize_spot_name(name: &[u8]) -> Vec<u8> {
let mut decoded = Vec::with_capacity(name.len());
let mut i = 0usize;
while i < name.len() {
if name[i] == b'#' && i + 2 < name.len() {
let h1 = name[i + 1];
let h2 = name[i + 2];
let hi = (h1 as char).to_digit(16);
let lo = (h2 as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
decoded.push(((hi << 4) | lo) as u8);
i += 3;
continue;
}
}
decoded.push(name[i]);
i += 1;
}
let mut out = Vec::with_capacity(decoded.len());
let mut prev_space = true;
for b in decoded {
let is_space = matches!(b, b' ' | b'\t' | b'\r' | b'\n');
if is_space {
if !prev_space {
out.push(b' ');
}
prev_space = true;
continue;
}
prev_space = false;
out.push(b.to_ascii_uppercase());
}
if out.last() == Some(&b' ') {
out.pop();
}
out
}
fn fix_overprint_mode(doc: &mut Document) -> usize {
let mut count = 0;
let ids: Vec<ObjectId> = doc.objects.keys().copied().collect();
for id in ids {
let needs_fix = if let Some(Object::Dictionary(dict)) = doc.objects.get(&id) {
matches!(dict.get(b"OPM").ok(), Some(Object::Integer(1)))
} else {
false
};
if needs_fix {
if let Some(Object::Dictionary(ref mut dict)) = doc.objects.get_mut(&id) {
dict.set("OPM", Object::Integer(0));
count += 1;
}
}
}
count
}
fn fix_iccbased_n_value(doc: &mut Document) -> usize {
let srgb_profile = srgb_icc_profile_bytes();
let cmyk_profile = cmyk_icc_profile_bytes();
let gray_profile = gray_icc_profile_bytes();
let mut count = 0;
let ids: Vec<ObjectId> = doc.objects.keys().copied().collect();
for id in ids {
let action = if let Some(Object::Stream(stream)) = doc.objects.get(&id) {
let declared_n = match stream.dict.get(b"N").ok() {
Some(Object::Integer(n)) => *n,
_ => continue,
};
let icc_bytes = {
let decompressed = stream.decompressed_content().unwrap_or_default();
if decompressed.is_empty() && !stream.content.is_empty() {
stream.content.clone()
} else {
decompressed
}
};
if icc_bytes.len() < 128 {
IccAction::Replace(declared_n)
} else {
let cs_sig = &icc_bytes[16..20];
let device_class = &icc_bytes[12..16];
let acsp_sig = &icc_bytes[36..40];
let version_major = icc_bytes[8];
let cs_valid = matches!(
cs_sig,
b"GRAY"
| b"RGB "
| b"CMYK"
| b"Lab "
| b"XYZ "
| b"Luv "
| b"YCbr"
| b"Yxy "
| b"HSV "
| b"HLS "
| b"CMY "
);
let class_valid = matches!(
device_class,
b"scnr" | b"mntr" | b"prtr" | b"link" | b"spac" | b"abst" | b"nmcl"
);
let acsp_valid = acsp_sig == b"acsp";
let version_valid = version_major < 5;
if cs_valid && class_valid && acsp_valid && version_valid {
let expected_n: i64 = match cs_sig {
b"GRAY" => 1,
b"RGB " | b"Lab " => 3,
b"CMYK" => 4,
_ => declared_n,
};
if declared_n != expected_n {
IccAction::FixN(expected_n)
} else {
IccAction::None
}
} else {
IccAction::Replace(declared_n)
}
}
} else {
IccAction::None
};
match action {
IccAction::None => {}
IccAction::FixN(n) => {
if let Some(Object::Stream(ref mut stream)) = doc.objects.get_mut(&id) {
stream.dict.set("N", Object::Integer(n));
count += 1;
}
}
IccAction::Replace(declared_n) => {
if let Some(Object::Stream(ref mut stream)) = doc.objects.get_mut(&id) {
let replacement = match declared_n {
4 => cmyk_profile.clone(),
1 => gray_profile.clone(),
_ => srgb_profile.clone(),
};
stream.content = replacement;
stream.dict.remove(b"Filter");
stream.dict.remove(b"DecodeParms");
stream.dict.remove(b"F");
stream.dict.remove(b"FFilter");
stream.dict.remove(b"FDecodeParms");
let new_n = match declared_n {
4 => 4_i64,
1 => 1,
_ => 3,
};
stream.dict.set("N", Object::Integer(new_n));
stream.dict.set(
"Length",
Object::Integer(stream.content.len().min(i64::MAX as usize) as i64),
);
count += 1;
}
}
}
}
count
}
enum IccAction {
None,
FixN(i64),
Replace(i64),
}
fn gray_icc_profile_bytes() -> Vec<u8> {
let total_size: u32 = 324;
let mut p = Vec::with_capacity(total_size as usize);
p.extend_from_slice(&total_size.to_be_bytes());
p.extend_from_slice(b"\0\0\0\0"); p.extend_from_slice(&[2, 0x10, 0, 0]); p.extend_from_slice(b"mntr"); p.extend_from_slice(b"GRAY"); p.extend_from_slice(b"XYZ "); p.extend_from_slice(&[0u8; 12]); p.extend_from_slice(b"acsp"); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 8]); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&0x0000F6D6_u32.to_be_bytes());
p.extend_from_slice(&0x00010000_u32.to_be_bytes());
p.extend_from_slice(&0x0000D32D_u32.to_be_bytes());
p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 16]); p.extend_from_slice(&[0u8; 128 - 100]); debug_assert_eq!(p.len(), 128);
p.extend_from_slice(&4_u32.to_be_bytes());
let tags: &[(&[u8; 4], u32, u32)] = &[
(b"desc", 180, 95),
(b"wtpt", 276, 20),
(b"cprt", 296, 12),
(b"kTRC", 308, 14),
];
for (sig, offset, size) in tags {
p.extend_from_slice(*sig);
p.extend_from_slice(&offset.to_be_bytes());
p.extend_from_slice(&size.to_be_bytes());
}
debug_assert_eq!(p.len(), 180);
p.extend_from_slice(b"desc");
p.extend_from_slice(&[0u8; 4]);
p.extend_from_slice(&5_u32.to_be_bytes()); p.extend_from_slice(b"Gray\0");
p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 4]); p.extend_from_slice(&[0u8; 2]); p.push(0); p.extend_from_slice(&[0u8; 67]); debug_assert_eq!(p.len(), 275);
p.push(0);
debug_assert_eq!(p.len(), 276);
p.extend_from_slice(b"XYZ ");
p.extend_from_slice(&[0u8; 4]);
p.extend_from_slice(&0x0000F6D6_i32.to_be_bytes());
p.extend_from_slice(&0x00010000_i32.to_be_bytes());
p.extend_from_slice(&0x0000D32D_i32.to_be_bytes());
debug_assert_eq!(p.len(), 296);
p.extend_from_slice(b"text");
p.extend_from_slice(&[0u8; 4]);
p.extend_from_slice(b"CC0\0");
debug_assert_eq!(p.len(), 308);
p.extend_from_slice(b"curv");
p.extend_from_slice(&[0u8; 4]);
p.extend_from_slice(&1_u32.to_be_bytes()); p.extend_from_slice(&[0x02, 0x33]); debug_assert_eq!(p.len(), 322);
p.extend_from_slice(&[0u8; 2]); debug_assert_eq!(p.len(), 324);
p
}
fn is_device_dependent(name: &str) -> bool {
matches!(name, "DeviceRGB" | "DeviceCMYK" | "DeviceGray")
}
fn resolve_colorspace_name(doc: &Document, obj: &Object) -> String {
match obj {
Object::Name(n) => String::from_utf8_lossy(n).to_string(),
Object::Array(arr) if !arr.is_empty() => {
if let Object::Name(n) = &arr[0] {
String::from_utf8_lossy(n).to_string()
} else {
"Unknown".into()
}
}
Object::Reference(id) => {
if let Some(resolved) = doc.objects.get(id) {
resolve_colorspace_name(doc, resolved)
} else {
"Unknown".into()
}
}
_ => "Unknown".into(),
}
}
fn get_name(dict: &lopdf::Dictionary, key: &[u8]) -> Option<String> {
match dict.get(key).ok()? {
Object::Name(n) => String::from_utf8(n.clone()).ok(),
_ => None,
}
}
fn get_catalog(doc: &Document) -> Option<&lopdf::Dictionary> {
let root_ref = match doc.trailer.get(b"Root").ok()? {
Object::Reference(id) => *id,
_ => return None,
};
match doc.objects.get(&root_ref)? {
Object::Dictionary(d) => Some(d),
_ => None,
}
}
fn get_catalog_id(doc: &Document) -> Result<ObjectId> {
match doc.trailer.get(b"Root").ok() {
Some(Object::Reference(id)) => Ok(*id),
_ => Err(ManipError::Other("no Root in trailer".into())),
}
}
fn count_pages(doc: &Document) -> usize {
doc.objects
.values()
.filter(|obj| {
if let Object::Dictionary(dict) = obj {
get_name(dict, b"Type").as_deref() == Some("Page")
} else {
false
}
})
.count()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_doc_with_device_rgb() -> Document {
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let content = Stream::new(dictionary! {}, b"BT /F1 12 Tf (Hello) Tj ET".to_vec());
let content_id = doc.add_object(Object::Stream(content));
let mut cs_dict = lopdf::Dictionary::new();
cs_dict.set("CS1", Object::Name(b"DeviceRGB".to_vec()));
let mut res = lopdf::Dictionary::new();
res.set("ColorSpace", Object::Dictionary(cs_dict));
let page = dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"MediaBox" => Object::Array(vec![
Object::Integer(0), Object::Integer(0),
Object::Integer(612), Object::Integer(792),
]),
"Contents" => Object::Reference(content_id),
"Resources" => Object::Dictionary(res),
};
let page_id = doc.add_object(Object::Dictionary(page));
let pages = dictionary! {
"Type" => "Pages",
"Count" => Object::Integer(1),
"Kids" => Object::Array(vec![Object::Reference(page_id)]),
};
doc.objects.insert(pages_id, Object::Dictionary(pages));
let catalog = dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
let catalog_id = doc.add_object(Object::Dictionary(catalog));
doc.trailer.set("Root", Object::Reference(catalog_id));
doc
}
#[test]
fn test_no_output_intent() {
let doc = make_doc_with_device_rgb();
assert!(!has_pdfa_output_intent(&doc));
}
#[test]
fn test_add_output_intent() {
let mut doc = make_doc_with_device_rgb();
add_srgb_output_intent(&mut doc).unwrap();
assert!(has_pdfa_output_intent(&doc));
}
#[test]
fn test_find_device_colorspaces() {
let doc = make_doc_with_device_rgb();
let found = find_device_colorspaces(&doc);
assert!(!found.is_empty());
assert!(found[0].1.contains("DeviceRGB"));
}
#[test]
fn normalization_characterizes_device_spaces_with_output_intent() {
let mut doc = make_doc_with_device_rgb();
let report = normalize_colorspaces(&mut doc).unwrap();
assert!(!report.had_output_intent);
assert!(report.output_intent_added);
assert_eq!(report.pages_scanned, 1);
}
#[test]
fn opaque_registration_paper_keeps_its_tint_transform_without_rgb_output_device() {
let mut doc = make_doc_with_device_rgb();
let tint = doc.add_object(dictionary! {"FunctionType"=>2,"Domain"=>vec![0.into(),1.into()],"C0"=>vec![1.into(),1.into(),1.into()],"C1"=>vec![0.into(),0.into(),0.into()],"N"=>1});
let calrgb = doc.add_object(Object::Array(vec![
"CalRGB".into(),
dictionary! {"WhitePoint"=>vec![0.9505.into(),1.into(),1.089.into()]}.into(),
]));
let separation = doc.add_object(Object::Array(vec![
Object::Name(b"Separation".to_vec()),
Object::Name(b"All".to_vec()),
calrgb.into(),
tint.into(),
]));
let before = doc.objects[&tint].clone();
let report = normalize_colorspaces(&mut doc).unwrap();
assert!(!report.output_intent_added);
assert!(!has_pdfa_output_intent(&doc));
assert_eq!(doc.objects[&tint], before);
assert_eq!(
doc.get_object(separation).unwrap().as_array().unwrap()[1]
.as_name()
.unwrap(),
b"All"
);
doc.add_object(dictionary! {"Type"=>"ExtGState","ca"=>0.5});
assert!(normalize_colorspaces(&mut doc).unwrap().output_intent_added);
}
#[test]
fn cmyk_mixed_and_unresolved_registration_keep_the_output_condition() {
for alternate in ["DeviceCMYK", "DeviceRGB", "UnknownColorSpace"] {
let mut doc = make_doc_with_device_rgb();
let calrgb = doc.add_object(Object::Array(vec![
"CalRGB".into(),
dictionary! {"WhitePoint"=>vec![0.9505.into(),1.into(),1.089.into()]}.into(),
]));
let tint = doc.add_object(
dictionary! {"FunctionType"=>2,"Domain"=>vec![0.into(),1.into()],"N"=>1},
);
doc.add_object(Object::Array(vec![
"Separation".into(),
"All".into(),
calrgb.into(),
tint.into(),
]));
doc.add_object(Object::Array(vec![
"Separation".into(),
"All".into(),
Object::Name(alternate.as_bytes().to_vec()),
tint.into(),
]));
assert!(
normalize_colorspaces(&mut doc).unwrap().output_intent_added,
"{alternate}"
);
}
}
#[test]
fn gray_profile_white_point_matches_its_d50_pcs_illuminant() {
let gray = gray_icc_profile_bytes();
assert_eq!(&gray[284..296], &gray[68..80]);
}
#[test]
fn test_normalize_skips_existing() {
let mut doc = make_doc_with_device_rgb();
add_srgb_output_intent(&mut doc).unwrap();
let report = normalize_colorspaces(&mut doc).unwrap();
assert!(report.had_output_intent);
assert!(!report.output_intent_added);
}
#[test]
fn test_icc_profile_structure() {
let profile = srgb_icc_profile_bytes();
assert_eq!(profile.len(), 8632);
assert_eq!(&profile[36..40], b"acsp");
assert_eq!(&profile[16..20], b"RGB ");
assert_eq!(&profile[12..16], b"mntr");
let size = u32::from_be_bytes([profile[0], profile[1], profile[2], profile[3]]);
assert_eq!(size as usize, profile.len());
let tag_count =
u32::from_be_bytes([profile[128], profile[129], profile[130], profile[131]]);
assert_eq!(tag_count, 9);
assert_eq!(&profile[132..136], b"desc");
}
#[test]
fn srgb_profile_uses_linear_toe_and_d50_adapted_white() {
let p = srgb_icc_profile_bytes();
assert_eq!(&p[356..368], &p[68..80]);
assert_eq!(u32::from_be_bytes(p[436..440].try_into().unwrap()), 4096);
let sample = |i: usize| {
f64::from(u16::from_be_bytes(
p[440 + 2 * i..442 + 2 * i].try_into().unwrap(),
)) / 65535.0
};
assert_eq!(sample(0), 0.0);
assert_eq!(sample(4095), 1.0);
assert!((sample(100) - (100.0 / 4095.0 / 12.92)).abs() < 1.0 / 65535.0);
assert!((sample(2048) - 0.214154).abs() < 0.00002);
for i in 1..4096 {
assert!(sample(i) >= sample(i - 1));
}
}
#[test]
fn cmyk_profile_contains_complete_color_transform_tables() {
let profile = cmyk_icc_profile_bytes();
let u32_at = |p: usize| u32::from_be_bytes(profile[p..p + 4].try_into().unwrap()) as usize;
assert_eq!(u32_at(0), profile.len());
assert_eq!(&profile[16..20], b"CMYK");
let mut found = false;
for i in 0..u32_at(128) {
let at = 132 + i * 12;
if &profile[at..at + 4] != b"A2B0" {
continue;
}
found = true;
let offset = u32_at(at + 4);
let length = u32_at(at + 8);
assert!(offset + length <= profile.len());
let tag = &profile[offset..offset + length];
let inputs = tag[8] as usize;
let outputs = tag[9] as usize;
assert_eq!(inputs, 4);
assert_eq!(outputs, 3);
let grid = tag[10] as usize;
let required = match &tag[..4] {
b"mft1" => 48 + inputs * 256 + grid.pow(inputs as u32) * outputs + outputs * 256,
b"mft2" => {
let ni = u16::from_be_bytes(tag[48..50].try_into().unwrap()) as usize;
let no = u16::from_be_bytes(tag[50..52].try_into().unwrap()) as usize;
52 + 2 * (inputs * ni + grid.pow(inputs as u32) * outputs + outputs * no)
}
other => panic!("unexpected LUT signature {other:?}"),
};
assert!(
length >= required,
"a LUT header without its samples is not a usable transform"
);
}
assert!(found, "missing forward CMYK transform");
}
#[test]
fn test_empty_doc() {
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let pages = dictionary! {
"Type" => "Pages",
"Count" => Object::Integer(0),
"Kids" => Object::Array(vec![]),
};
doc.objects.insert(pages_id, Object::Dictionary(pages));
let catalog = dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
let catalog_id = doc.add_object(Object::Dictionary(catalog));
doc.trailer.set("Root", Object::Reference(catalog_id));
let report = normalize_colorspaces(&mut doc).unwrap();
assert!(!report.had_output_intent);
assert!(report.output_intent_added);
assert!(report.device_colorspaces_found.is_empty());
}
#[test]
fn test_separation_no_conflict() {
let mut doc = Document::with_version("1.7");
let tint_fn = dictionary! {
"FunctionType" => Object::Integer(2),
"N" => Object::Integer(1),
};
let tint_id = doc.add_object(Object::Dictionary(tint_fn));
let sep1 = Object::Array(vec![
Object::Name(b"Separation".to_vec()),
Object::Name(b"SpotRed".to_vec()),
Object::Name(b"DeviceRGB".to_vec()),
Object::Reference(tint_id),
]);
let sep2 = Object::Array(vec![
Object::Name(b"Separation".to_vec()),
Object::Name(b"SpotRed".to_vec()),
Object::Name(b"DeviceRGB".to_vec()),
Object::Reference(tint_id),
]);
doc.add_object(sep1);
doc.add_object(sep2);
let count = normalize_separation_colorspaces(&mut doc);
assert_eq!(count, 3);
}
#[test]
fn test_separation_conflict_unified() {
let mut doc = Document::with_version("1.7");
let tint1_id = doc.add_object(Object::Dictionary(dictionary! {
"FunctionType" => Object::Integer(2),
"N" => Object::Integer(1),
}));
let tint2_id = doc.add_object(Object::Dictionary(dictionary! {
"FunctionType" => Object::Integer(2),
"N" => Object::Integer(2),
}));
let sep1 = Object::Array(vec![
Object::Name(b"Separation".to_vec()),
Object::Name(b"SpotBlue".to_vec()),
Object::Name(b"DeviceRGB".to_vec()),
Object::Reference(tint1_id),
]);
let sep2 = Object::Array(vec![
Object::Name(b"Separation".to_vec()),
Object::Name(b"SpotBlue".to_vec()),
Object::Name(b"DeviceCMYK".to_vec()),
Object::Reference(tint2_id),
]);
let sep1_id = doc.add_object(sep1);
let sep2_id = doc.add_object(sep2);
let count = normalize_separation_colorspaces(&mut doc);
assert_eq!(count, 3);
assert!(
!doc.objects.contains_key(&sep2_id),
"non-canonical Separation should be removed"
);
if let Object::Array(arr) = &doc.objects[&sep1_id] {
assert_eq!(arr[2], Object::Name(b"DeviceRGB".to_vec()));
} else {
panic!("expected array");
}
}
#[test]
fn test_separation_in_dict_value() {
let mut doc = Document::with_version("1.7");
let tint1_id = doc.add_object(Object::Dictionary(dictionary! {
"FunctionType" => Object::Integer(2),
"N" => Object::Integer(1),
}));
let tint2_id = doc.add_object(Object::Dictionary(dictionary! {
"FunctionType" => Object::Integer(4),
"N" => Object::Integer(1),
}));
doc.add_object(Object::Array(vec![
Object::Name(b"Separation".to_vec()),
Object::Name(b"Cyan".to_vec()),
Object::Name(b"DeviceRGB".to_vec()),
Object::Reference(tint1_id),
]));
let mut colorants = lopdf::Dictionary::new();
colorants.set(
"Cyan",
Object::Array(vec![
Object::Name(b"Separation".to_vec()),
Object::Name(b"Cyan".to_vec()),
Object::Name(b"DeviceCMYK".to_vec()),
Object::Reference(tint2_id),
]),
);
let colorants_id = doc.add_object(Object::Dictionary(colorants));
let count = normalize_separation_colorspaces(&mut doc);
assert_eq!(count, 3);
assert!(
!doc.objects.contains_key(&colorants_id),
"container with non-canonical Separation should be removed"
);
}
#[test]
fn test_output_intent_survives_roundtrip() {
let mut doc = make_doc_with_device_rgb();
add_srgb_output_intent(&mut doc).unwrap();
let report = normalize_colorspaces(&mut doc).unwrap();
assert!(!report.output_intent_added);
assert!(has_pdfa_output_intent(&doc));
let mut buf = Vec::new();
doc.save_to(&mut buf).unwrap();
let doc2 = Document::load_mem(&buf).unwrap();
assert!(
has_pdfa_output_intent(&doc2),
"OutputIntent must survive save/load roundtrip"
);
let catalog = get_catalog(&doc2).unwrap();
let intents = match catalog.get(b"OutputIntents").unwrap() {
Object::Array(arr) => arr.clone(),
Object::Reference(id) => {
if let Object::Array(arr) = doc2.objects.get(id).unwrap() {
arr.clone()
} else {
panic!("expected array")
}
}
_ => panic!("expected array"),
};
assert_eq!(intents.len(), 1);
let intent = match &intents[0] {
Object::Reference(id) => {
if let Object::Dictionary(d) = doc2.objects.get(id).unwrap() {
d
} else {
panic!("expected dict")
}
}
Object::Dictionary(d) => d,
_ => panic!("expected dict"),
};
let profile_ref = intent.get(b"DestOutputProfile").unwrap();
match profile_ref {
Object::Reference(id) => {
let profile_obj = doc2.objects.get(id).expect("profile object must exist");
match profile_obj {
Object::Stream(stream) => {
let content = &stream.content;
assert!(
content.len() > 100,
"ICC profile must be non-trivial, got {} bytes",
content.len()
);
let n = stream.dict.get(b"N").unwrap();
assert_eq!(*n, Object::Integer(3));
}
_ => panic!("expected stream for ICC profile"),
}
}
_ => panic!("expected reference for DestOutputProfile"),
}
}
#[test]
fn test_overprint_mode_fixed() {
let mut doc = Document::with_version("1.7");
let gs = dictionary! {
"Type" => "ExtGState",
"OPM" => Object::Integer(1),
"OP" => Object::Boolean(true),
};
let gs_id = doc.add_object(Object::Dictionary(gs));
let count = fix_overprint_mode(&mut doc);
assert_eq!(count, 1);
if let Object::Dictionary(dict) = &doc.objects[&gs_id] {
assert_eq!(*dict.get(b"OPM").unwrap(), Object::Integer(0));
}
}
#[test]
fn test_overprint_mode_zero_untouched() {
let mut doc = Document::with_version("1.7");
let gs = dictionary! {
"Type" => "ExtGState",
"OPM" => Object::Integer(0),
};
doc.add_object(Object::Dictionary(gs));
let count = fix_overprint_mode(&mut doc);
assert_eq!(count, 0);
}
#[test]
fn test_icc_n_value_mismatch() {
let mut doc = Document::with_version("1.7");
let mut icc_data = vec![0u8; 128];
icc_data[12..16].copy_from_slice(b"mntr"); icc_data[16..20].copy_from_slice(b"RGB ");
icc_data[36..40].copy_from_slice(b"acsp");
icc_data[8] = 2;
let icc_dict = dictionary! {
"N" => Object::Integer(4), };
let stream = Stream::new(icc_dict, icc_data);
let stream_id = doc.add_object(Object::Stream(stream));
let count = fix_iccbased_n_value(&mut doc);
assert_eq!(count, 1);
if let Object::Stream(s) = &doc.objects[&stream_id] {
assert_eq!(*s.dict.get(b"N").unwrap(), Object::Integer(3));
}
}
#[test]
fn test_icc_n_value_correct() {
let mut doc = Document::with_version("1.7");
let mut icc_data = vec![0u8; 128];
icc_data[12..16].copy_from_slice(b"prtr"); icc_data[16..20].copy_from_slice(b"CMYK");
icc_data[36..40].copy_from_slice(b"acsp");
icc_data[8] = 2;
let icc_dict = dictionary! {
"N" => Object::Integer(4), };
let stream = Stream::new(icc_dict, icc_data);
doc.add_object(Object::Stream(stream));
let count = fix_iccbased_n_value(&mut doc);
assert_eq!(count, 0);
}
#[test]
fn normalize_separation_colorspaces_with_huge_max_id_completes_fast() {
let mut doc = Document::with_version("1.7");
doc.max_id = 1_000_000;
let pages_id = doc.new_object_id();
let pages = dictionary! {
"Type" => "Pages",
"Count" => Object::Integer(0),
"Kids" => Object::Array(vec![]),
};
doc.objects.insert(pages_id, Object::Dictionary(pages));
let catalog = dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
let catalog_id = doc.add_object(Object::Dictionary(catalog));
doc.trailer.set("Root", Object::Reference(catalog_id));
let count = normalize_separation_colorspaces(&mut doc);
assert_eq!(count, 0);
}
#[test]
fn fix_iccbased_n_value_sets_length_after_replace() {
let mut doc = Document::with_version("1.7");
let icc_dict = dictionary! {
"N" => Object::Integer(3),
};
let tiny_icc = vec![0u8; 10]; let stream = Stream::new(icc_dict, tiny_icc);
let sid = doc.add_object(Object::Stream(stream));
let count = fix_iccbased_n_value(&mut doc);
assert_eq!(count, 1);
if let Some(Object::Stream(s)) = doc.objects.get(&sid) {
let len_obj = s
.dict
.get(b"Length")
.expect("/Length must be set after replace");
if let Object::Integer(len) = len_obj {
assert!(*len > 0, "/Length must be positive");
assert_eq!(*len as usize, s.content.len(), "/Length must match content");
} else {
panic!("/Length is not an Integer");
}
} else {
panic!("stream object not found");
}
}
}
#[cfg(test)]
mod inline_rgb_output_intent_tests {
use super::*;
fn doc_with_stream(dict: lopdf::Dictionary, content: &[u8]) -> Document {
let mut doc = Document::with_version("1.7");
doc.add_object(Object::Stream(Stream::new(dict, content.to_vec())));
doc
}
#[test]
fn inline_image_device_rgb_is_found() {
let doc = doc_with_stream(
dictionary! {},
b"q 100 0 0 100 0 0 cm\nBI /W 2 /H 2 /CS /DeviceRGB /BPC 8 ID \x00\x01\x02 EI\nQ",
);
assert!(has_device_rgb_in_streams(&doc));
}
#[test]
fn three_component_iccbased_counts_as_rgb() {
let doc = doc_with_stream(dictionary! { "N" => Object::Integer(3) }, b"\x00\x01\x02");
assert!(has_device_rgb_in_streams(&doc));
}
#[test]
fn cmyk_only_document_has_no_rgb() {
let doc = doc_with_stream(
dictionary! { "N" => Object::Integer(4) },
b"q /DeviceCMYK cs 0 0 0 1 sc 0 0 10 10 re f Q",
);
assert!(!has_device_rgb_in_streams(&doc));
}
#[test]
fn normalization_characterizes_inline_rgb_alongside_named_cmyk() {
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let content = Stream::new(
dictionary! {},
b"q /CS1 cs 0 0 0 1 sc 0 0 10 10 re f\nBI /W 1 /H 1 /CS /DeviceRGB /BPC 8 ID \x00 EI\nQ".to_vec(),
);
let content_id = doc.add_object(Object::Stream(content));
let mut cs_dict = lopdf::Dictionary::new();
cs_dict.set("CS1", Object::Name(b"DeviceCMYK".to_vec()));
let mut res = lopdf::Dictionary::new();
res.set("ColorSpace", Object::Dictionary(cs_dict));
let page = dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"MediaBox" => Object::Array(vec![
Object::Integer(0), Object::Integer(0),
Object::Integer(612), Object::Integer(792),
]),
"Contents" => Object::Reference(content_id),
"Resources" => Object::Dictionary(res),
};
let page_id = doc.add_object(Object::Dictionary(page));
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Count" => Object::Integer(1),
"Kids" => Object::Array(vec![Object::Reference(page_id)]),
}),
);
let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
}));
doc.trailer.set("Root", Object::Reference(catalog_id));
normalize_colorspaces(&mut doc).expect("normalize");
let page = doc.get_dictionary(page_id).unwrap();
let resources = page.get(b"Resources").unwrap().as_dict().unwrap();
let cs = resources.get(b"ColorSpace").unwrap().as_dict().unwrap();
for (key, n) in [(b"DefaultRGB".as_slice(), 3), (b"DefaultCMYK", 4)] {
let id = cs.get(key).unwrap().as_reference().unwrap();
let array = doc.get_object(id).unwrap().as_array().unwrap();
assert_eq!(array[0].as_name().unwrap(), b"ICCBased");
let profile = doc
.get_object(array[1].as_reference().unwrap())
.unwrap()
.as_stream()
.unwrap();
assert_eq!(profile.dict.get(b"N").unwrap().as_i64().unwrap(), n);
}
assert!(doc.catalog().unwrap().has(b"OutputIntents"));
}
fn cmyk_operator_in_bytes(content: &[u8]) -> bool {
operator_with_operands(content, b"k", 4) || operator_with_operands(content, b"K", 4)
}
#[test]
fn cmyk_operator_needs_its_four_operands() {
assert!(cmyk_operator_in_bytes(b"q 0 0 0 1 k 10 10 100 100 re f Q"));
assert!(cmyk_operator_in_bytes(b"0.1 0.2 0.3 0.4 K\n"));
assert!(cmyk_operator_in_bytes(b"BT 1 1 1 1 k ET"));
}
#[test]
fn stray_k_bytes_are_not_cmyk_usage() {
assert!(!cmyk_operator_in_bytes(
b"BI /W 2 /H 2 ID \x01\x02 k\x99\x04 EI"
));
assert!(!cmyk_operator_in_bytes(b"q 0 0 0 1 kilo Q"));
assert!(!cmyk_operator_in_bytes(b"BT (a black cat) Tj ET"));
assert!(!cmyk_operator_in_bytes(b"0 0 1 k"[..6].as_ref()));
}
#[test]
fn compressed_stream_is_searched_decompressed() {
let mut content = b"q 1 0 0 1 0 0 cm ".repeat(64);
content.extend_from_slice(b"BI /W 1 /H 1 /CS /DeviceRGB /BPC 8 ID \x00 EI Q");
let mut stream = Stream::new(dictionary! {}, content);
stream.compress().expect("compress");
assert!(
!stream.content.windows(9).any(|w| w == b"DeviceRGB"),
"fixture must actually be compressed, or it proves nothing"
);
let mut doc = Document::with_version("1.7");
doc.add_object(Object::Stream(stream));
assert!(has_device_rgb_in_streams(&doc));
}
}
pub fn content_streams_the_parser_refuses(doc: &Document) -> usize {
doc.objects
.values()
.filter(|object| match object {
Object::Stream(stream) if !is_opaque_payload(stream) => stream
.get_plain_content()
.is_ok_and(|bytes| crate::inline_image::decode_strict(&bytes).is_err()),
_ => false,
})
.count()
}