use std::collections::HashMap;
use std::sync::Arc;
use crate::document::PdfDocument;
use crate::object::Object;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BlendModeClass {
SeparableWhitePreserving,
SeparableNonWhitePreserving,
NonSeparable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessBlendDispatch {
UseRequested,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpotBlendDispatch {
UseRequested,
SubstituteNormal,
}
impl BlendModeClass {
pub fn from_name(name: &str) -> Self {
match name {
"Normal" | "Multiply" | "Screen" | "Overlay" | "Darken" | "Lighten" | "ColorDodge"
| "ColorBurn" | "HardLight" | "SoftLight" => Self::SeparableWhitePreserving,
"Difference" | "Exclusion" => Self::SeparableNonWhitePreserving,
"Hue" | "Saturation" | "Color" | "Luminosity" => Self::NonSeparable,
_ => Self::SeparableWhitePreserving,
}
}
pub fn process_dispatch(&self) -> ProcessBlendDispatch {
ProcessBlendDispatch::UseRequested
}
pub fn spot_dispatch(&self) -> SpotBlendDispatch {
match self {
Self::SeparableWhitePreserving => SpotBlendDispatch::UseRequested,
Self::SeparableNonWhitePreserving | Self::NonSeparable => {
SpotBlendDispatch::SubstituteNormal
},
}
}
}
#[allow(dead_code)]
#[derive(Debug)]
pub(crate) struct CmykSidecar {
dims: (u32, u32),
cmyk: Vec<u8>,
spot_names: Vec<String>,
spots: Vec<u8>,
}
#[allow(dead_code)]
impl CmykSidecar {
pub(crate) fn new(width: u32, height: u32, spot_names: Vec<String>) -> Self {
let pixels = (width as usize) * (height as usize);
let cmyk = vec![0u8; 4 * pixels];
let spots = vec![0u8; spot_names.len() * pixels];
Self {
dims: (width, height),
cmyk,
spot_names,
spots,
}
}
pub(crate) fn dims(&self) -> (u32, u32) {
self.dims
}
pub(crate) fn cmyk(&self) -> &[u8] {
&self.cmyk
}
pub(crate) fn cmyk_mut(&mut self) -> &mut [u8] {
&mut self.cmyk
}
pub(crate) fn spot_names(&self) -> &[String] {
&self.spot_names
}
pub(crate) fn spot_plane(&self, index: usize) -> Option<&[u8]> {
let (w, h) = self.dims;
let plane_size = (w as usize) * (h as usize);
let start = index.checked_mul(plane_size)?;
let end = start.checked_add(plane_size)?;
if end > self.spots.len() {
return None;
}
Some(&self.spots[start..end])
}
pub(crate) fn spot_plane_mut(&mut self, index: usize) -> Option<&mut [u8]> {
let (w, h) = self.dims;
let plane_size = (w as usize) * (h as usize);
let start = index.checked_mul(plane_size)?;
let end = start.checked_add(plane_size)?;
if end > self.spots.len() {
return None;
}
Some(&mut self.spots[start..end])
}
pub(crate) fn spot_index(&self, ink: &str) -> Option<usize> {
self.spot_names.iter().position(|n| n == ink)
}
pub(crate) fn spots_all(&self) -> &[u8] {
&self.spots
}
pub(crate) fn spots_all_mut(&mut self) -> &mut [u8] {
&mut self.spots
}
pub(crate) fn process_plate(&self, ink: &str) -> Option<Vec<u8>> {
let channel: usize = match ink {
"Cyan" => 0,
"Magenta" => 1,
"Yellow" => 2,
"Black" => 3,
_ => return None,
};
let (w, h) = self.dims;
let pixels = (w as usize) * (h as usize);
let mut out = Vec::with_capacity(pixels);
for px in 0..pixels {
out.push(self.cmyk[px * 4 + channel]);
}
Some(out)
}
pub(crate) fn spot_plate(&self, ink: &str) -> Option<&[u8]> {
let idx = self.spot_index(ink)?;
self.spot_plane(idx)
}
pub(crate) fn restore_cmyk(&mut self, data: &[u8]) {
debug_assert_eq!(data.len(), self.cmyk.len());
self.cmyk.copy_from_slice(data);
}
pub(crate) fn restore_spots(&mut self, data: &[u8]) {
debug_assert_eq!(data.len(), self.spots.len());
self.spots.copy_from_slice(data);
}
}
pub(crate) fn discover_page_spot_inks(doc: &PdfDocument, page_index: usize) -> Vec<String> {
match doc.get_page_inks_deep(page_index) {
Ok(inks) => inks,
Err(e) => {
log::warn!(
"sidecar: failed to discover spot inks for page {}: {}; the \
transparency composite will proceed with no spot lanes",
page_index,
e
);
Vec::new()
},
}
}
pub(crate) fn page_declares_transparency(doc: &PdfDocument, resources: &Object) -> bool {
let mut visited: std::collections::HashSet<crate::object::ObjectRef> =
std::collections::HashSet::new();
resources_declare_transparency_or_overprint(doc, resources, &mut visited, 0, false)
}
fn ext_g_states_signal_transparency_only(
doc: &PdfDocument,
ext_g_states: &HashMap<String, Object>,
) -> bool {
for state in ext_g_states.values() {
let state_resolved = match doc.resolve_object(state) {
Ok(o) => o,
Err(_) => continue,
};
let Some(state_dict) = state_resolved.as_dict() else {
continue;
};
for key in ["CA", "ca"] {
if let Some(v_raw) = state_dict.get(key) {
let v = doc.resolve_object(v_raw).unwrap_or_else(|_| v_raw.clone());
let alpha = match v {
Object::Real(r) => r as f32,
Object::Integer(i) => i as f32,
_ => 1.0,
};
if alpha < 1.0 {
return true;
}
}
}
if let Some(smask_raw) = state_dict.get("SMask") {
let smask = doc
.resolve_object(smask_raw)
.unwrap_or_else(|_| smask_raw.clone());
if !matches!(&smask, Object::Name(n) if n == "None") {
return true;
}
}
if let Some(bm_raw) = state_dict.get("BM") {
let bm = doc
.resolve_object(bm_raw)
.unwrap_or_else(|_| bm_raw.clone());
if bm_is_non_normal(&bm) {
return true;
}
}
}
false
}
pub(crate) fn page_declares_transparency_or_overprint(
doc: &PdfDocument,
resources: &Object,
) -> bool {
let mut visited: std::collections::HashSet<crate::object::ObjectRef> =
std::collections::HashSet::new();
resources_declare_transparency_or_overprint(doc, resources, &mut visited, 0, true)
}
const MAX_DETECTION_RECURSION: u32 = 32;
fn resources_declare_transparency_or_overprint(
doc: &PdfDocument,
resources: &Object,
visited: &mut std::collections::HashSet<crate::object::ObjectRef>,
depth: u32,
include_overprint: bool,
) -> bool {
if depth >= MAX_DETECTION_RECURSION {
return false;
}
let res_dict = match resources {
Object::Dictionary(d) => d,
_ => return false,
};
if let Some(ext_gs_obj) = res_dict.get("ExtGState") {
if let Ok(ext_gs_resolved) = doc.resolve_object(ext_gs_obj) {
if let Some(ext_g_states) = ext_gs_resolved.as_dict() {
let hit = if include_overprint {
ext_g_states_signal_transparency(doc, ext_g_states)
} else {
ext_g_states_signal_transparency_only(doc, ext_g_states)
};
if hit {
return true;
}
}
}
}
if let Some(xobj_obj) = res_dict.get("XObject") {
if let Ok(xobj_resolved) = doc.resolve_object(xobj_obj) {
if let Some(xobj_dict) = xobj_resolved.as_dict() {
for raw in xobj_dict.values() {
if let Some(r) = raw.as_reference() {
if !visited.insert(r) {
continue;
}
}
let resolved = match doc.resolve_object(raw) {
Ok(o) => o,
Err(_) => continue,
};
let dict = match &resolved {
Object::Stream { dict, .. } => Some(dict),
_ => None,
};
let Some(dict) = dict else { continue };
if dict.contains_key("Group") || dict.contains_key("SMask") {
return true;
}
let form_res = match dict.get("Resources").map(|r| doc.resolve_object(r)) {
Some(Ok(o)) => o,
_ => continue,
};
if resources_declare_transparency_or_overprint(
doc,
&form_res,
visited,
depth + 1,
include_overprint,
) {
return true;
}
}
}
}
}
false
}
fn ext_g_states_signal_transparency(
doc: &PdfDocument,
ext_g_states: &HashMap<String, Object>,
) -> bool {
for state in ext_g_states.values() {
let state_resolved = match doc.resolve_object(state) {
Ok(o) => o,
Err(_) => continue,
};
let Some(state_dict) = state_resolved.as_dict() else {
continue;
};
let op_true = state_dict
.get("OP")
.map(|o| {
let resolved = doc.resolve_object(o).unwrap_or_else(|_| o.clone());
matches!(resolved, Object::Boolean(true))
})
.unwrap_or(false);
let op_lower_true = state_dict
.get("op")
.map(|o| {
let resolved = doc.resolve_object(o).unwrap_or_else(|_| o.clone());
matches!(resolved, Object::Boolean(true))
})
.unwrap_or(false);
if op_true || op_lower_true {
return true;
}
for key in ["CA", "ca"] {
if let Some(v_raw) = state_dict.get(key) {
let v = doc.resolve_object(v_raw).unwrap_or_else(|_| v_raw.clone());
let alpha = match v {
Object::Real(r) => r as f32,
Object::Integer(i) => i as f32,
_ => 1.0,
};
if alpha < 1.0 {
return true;
}
}
}
if let Some(smask_raw) = state_dict.get("SMask") {
let smask = doc
.resolve_object(smask_raw)
.unwrap_or_else(|_| smask_raw.clone());
if !matches!(&smask, Object::Name(n) if n == "None") {
return true;
}
}
if let Some(bm_raw) = state_dict.get("BM") {
let bm = doc
.resolve_object(bm_raw)
.unwrap_or_else(|_| bm_raw.clone());
if bm_is_non_normal(&bm) {
return true;
}
}
}
false
}
fn bm_is_non_normal(bm: &Object) -> bool {
match bm {
Object::Name(name) => is_non_normal_mode(name),
Object::Array(arr) => arr
.iter()
.filter_map(Object::as_name)
.find(|name| is_recognised_mode(name))
.map(is_non_normal_mode)
.unwrap_or(false),
_ => false,
}
}
pub(crate) fn is_recognised_mode(name: &str) -> bool {
matches!(
name,
"Normal"
| "Multiply"
| "Screen"
| "Overlay"
| "Darken"
| "Lighten"
| "ColorDodge"
| "ColorBurn"
| "HardLight"
| "SoftLight"
| "Difference"
| "Exclusion"
| "Hue"
| "Saturation"
| "Color"
| "Luminosity"
)
}
fn is_non_normal_mode(name: &str) -> bool {
is_recognised_mode(name) && name != "Normal"
}
pub(crate) fn separable_blend(mode: &str, c_b: f32, c_s: f32) -> f32 {
let c_b = c_b.clamp(0.0, 1.0);
let c_s = c_s.clamp(0.0, 1.0);
match mode {
"Normal" => c_s,
"Multiply" => c_b * c_s,
"Screen" => c_b + c_s - c_b * c_s,
"Overlay" => {
hard_light_component(c_s, c_b)
},
"Darken" => c_b.min(c_s),
"Lighten" => c_b.max(c_s),
"ColorDodge" => {
if c_s >= 1.0 {
1.0
} else {
(c_b / (1.0 - c_s)).min(1.0)
}
},
"ColorBurn" => {
if c_s <= 0.0 {
0.0
} else {
1.0 - ((1.0 - c_b) / c_s).min(1.0)
}
},
"HardLight" => hard_light_component(c_b, c_s),
"SoftLight" => soft_light_component(c_b, c_s),
"Difference" => (c_b - c_s).abs(),
"Exclusion" => c_b + c_s - 2.0 * c_b * c_s,
_ => c_s,
}
}
fn hard_light_component(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 {
c_b * 2.0 * c_s
} else {
let twin = 2.0 * c_s - 1.0;
c_b + twin - c_b * twin
}
}
fn soft_light_component(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 {
c_b - (1.0 - 2.0 * c_s) * c_b * (1.0 - c_b)
} else {
let d = if c_b <= 0.25 {
((16.0 * c_b - 12.0) * c_b + 4.0) * c_b
} else {
c_b.sqrt()
};
c_b + (2.0 * c_s - 1.0) * (d - c_b)
}
}
pub(crate) fn extract_paint_spot_inks(
space: &Object,
components: &[f32],
doc: &PdfDocument,
) -> Vec<(String, f32)> {
let arr = match space.as_array() {
Some(a) => a,
None => return Vec::new(),
};
let type_name = match arr.first().and_then(Object::as_name) {
Some(n) => n,
None => return Vec::new(),
};
let deref =
|obj: &Object| -> Object { doc.resolve_object(obj).unwrap_or_else(|_| obj.clone()) };
match type_name {
"Separation" => {
if components.is_empty() {
return Vec::new();
}
let name_obj = match arr.get(1) {
Some(o) => deref(o),
None => return Vec::new(),
};
let Some(ink) = name_obj.as_name() else {
return Vec::new();
};
vec![(ink.to_string(), components[0])]
},
"Pattern" => {
let underlying = match arr.get(1) {
Some(o) => deref(o),
None => return Vec::new(),
};
extract_paint_spot_inks(&underlying, components, doc)
},
"DeviceN" => {
let names_obj = match arr.get(1) {
Some(o) => deref(o),
None => return Vec::new(),
};
let Some(names) = names_obj.as_array() else {
return Vec::new();
};
let process_names: std::collections::HashSet<String> =
process_names_if_valid_prefix(arr, names, &deref);
let mut out = Vec::with_capacity(names.len());
for (i, ink_obj) in names.iter().enumerate() {
let Some(ink) = ink_obj.as_name() else {
continue;
};
if ink == "All" || ink == "None" {
continue;
}
if process_names.contains(ink) {
continue;
}
let tint = components.get(i).copied().unwrap_or(0.0);
out.push((ink.to_string(), tint));
}
out
},
_ => Vec::new(),
}
}
fn process_names_if_valid_prefix(
cs_arr: &[Object],
names: &[Object],
deref: &impl Fn(&Object) -> Object,
) -> std::collections::HashSet<String> {
let proc_components = cs_arr
.get(4)
.map(deref)
.as_ref()
.and_then(Object::as_dict)
.and_then(|attrs| attrs.get("Process"))
.map(deref)
.as_ref()
.and_then(Object::as_dict)
.and_then(|proc_dict| proc_dict.get("Components"))
.map(deref)
.as_ref()
.and_then(Object::as_array)
.map(|comps| {
comps
.iter()
.filter_map(|o| o.as_name().map(str::to_string))
.collect::<Vec<String>>()
})
.unwrap_or_default();
if proc_components.is_empty() {
return std::collections::HashSet::new();
}
let names_set: std::collections::HashSet<String> = names
.iter()
.filter_map(|o| o.as_name().map(str::to_string))
.collect();
if proc_components.iter().all(|c| names_set.contains(c)) {
proc_components.into_iter().collect()
} else {
std::collections::HashSet::new()
}
}
pub(crate) fn extract_process_paint_cmyk(
space: &Object,
components: &[f32],
doc: &PdfDocument,
rendering_intent: crate::color::RenderingIntent,
retarget_cache: Option<&crate::rendering::resolution::context::IccTransformCache>,
) -> Option<(f32, f32, f32, f32)> {
let arr = space.as_array()?;
if arr.first().and_then(Object::as_name)? != "DeviceN" {
return None;
}
let deref =
|obj: &Object| -> Object { doc.resolve_object(obj).unwrap_or_else(|_| obj.clone()) };
let names_obj = deref(arr.get(1)?);
let names = names_obj.as_array()?;
let name_index = |target: &str| -> Option<usize> {
names
.iter()
.enumerate()
.find_map(|(i, o)| match o.as_name() {
Some(n) if n == target => Some(i),
_ => None,
})
};
let attrs_obj = deref(arr.get(4)?);
let attrs = attrs_obj.as_dict()?;
let process_obj = deref(attrs.get("Process")?);
let process = process_obj.as_dict()?;
let cs_obj = deref(process.get("ColorSpace")?);
let proc_components_obj = deref(process.get("Components")?);
let proc_components = proc_components_obj.as_array()?;
let mut proc_tints: Vec<f32> = Vec::with_capacity(proc_components.len());
for c in proc_components {
let name = c.as_name()?;
let Some(idx) = name_index(name) else {
log::warn!(
"DeviceN /Process /Components entry {:?} is not present in /Names; \
source violates ISO 32000-1 §8.6.6.5 ('leading prefix' requirement). \
Falling through to the §10.3.5 RGB-inverse path. See \
HONEST_GAP_DEVICEN_PROCESS_MISMATCHED_NAMES.",
name
);
return None;
};
proc_tints.push(components.get(idx).copied().unwrap_or(0.0));
}
if let Some(name) = cs_obj.as_name() {
return match name {
"DeviceCMYK" | "CMYK" => {
if proc_tints.len() < 4 {
return None;
}
Some((proc_tints[0], proc_tints[1], proc_tints[2], proc_tints[3]))
},
"DeviceRGB" | "RGB" => {
if proc_tints.len() < 3 {
return None;
}
let c = (1.0 - proc_tints[0]).clamp(0.0, 1.0);
let m = (1.0 - proc_tints[1]).clamp(0.0, 1.0);
let y = (1.0 - proc_tints[2]).clamp(0.0, 1.0);
Some((c, m, y, 0.0))
},
"DeviceGray" | "G" => {
if proc_tints.is_empty() {
return None;
}
let k = (1.0 - proc_tints[0]).clamp(0.0, 1.0);
Some((0.0, 0.0, 0.0, k))
},
_ => None,
};
}
if let Some(cs_arr) = cs_obj.as_array() {
if cs_arr.first().and_then(Object::as_name) == Some("ICCBased") {
let n_components = cs_arr
.get(1)
.map(deref)
.as_ref()
.and_then(Object::as_dict)
.and_then(|d| d.get("N"))
.and_then(Object::as_integer)
.unwrap_or(0);
return match n_components {
4 => {
if proc_tints.len() < 4 {
return None;
}
if let Some(retargeted) = try_retarget_cmyk_via_embedded_profile(
cs_arr,
&proc_tints,
doc,
rendering_intent,
retarget_cache,
) {
return Some(retargeted);
}
Some((proc_tints[0], proc_tints[1], proc_tints[2], proc_tints[3]))
},
3 => {
if proc_tints.len() < 3 {
return None;
}
let c = (1.0 - proc_tints[0]).clamp(0.0, 1.0);
let m = (1.0 - proc_tints[1]).clamp(0.0, 1.0);
let y = (1.0 - proc_tints[2]).clamp(0.0, 1.0);
Some((c, m, y, 0.0))
},
1 => {
if proc_tints.is_empty() {
return None;
}
let k = (1.0 - proc_tints[0]).clamp(0.0, 1.0);
Some((0.0, 0.0, 0.0, k))
},
_ => None,
};
}
}
None
}
fn try_retarget_cmyk_via_embedded_profile(
cs_arr: &[Object],
proc_tints: &[f32],
doc: &PdfDocument,
rendering_intent: crate::color::RenderingIntent,
retarget_cache: Option<&crate::rendering::resolution::context::IccTransformCache>,
) -> Option<(f32, f32, f32, f32)> {
if !crate::color::active_backend_supports_cmyk_retarget() {
return None;
}
if proc_tints.len() < 4 {
return None;
}
let dst_profile = doc.output_intent_cmyk_profile()?;
let stream_obj = cs_arr.get(1)?;
let resolved_stream = doc.resolve_object(stream_obj).ok()?;
let dict = resolved_stream.as_dict()?;
let declared_n: u8 = dict
.get("N")
.and_then(Object::as_integer)
.filter(|n| *n == 4)
.map(|n| n as u8)?;
let bytes = resolved_stream.decode_stream_data().ok()?;
let src_profile = std::sync::Arc::new(crate::color::IccProfile::parse(bytes, declared_n)?);
if src_profile.content_hash() == dst_profile.content_hash() {
return None;
}
let transform: Arc<crate::color::CmykRetargetTransform> = match retarget_cache {
Some(cache) => {
cache.get_or_build_cmyk_retarget(&src_profile, &dst_profile, rendering_intent)?
},
None => Arc::new(crate::color::CmykRetargetTransform::new(
src_profile,
dst_profile,
rendering_intent,
)?),
};
let out = transform.retarget_pixel([
proc_tints[0].clamp(0.0, 1.0),
proc_tints[1].clamp(0.0, 1.0),
proc_tints[2].clamp(0.0, 1.0),
proc_tints[3].clamp(0.0, 1.0),
]);
Some((
out[0].clamp(0.0, 1.0),
out[1].clamp(0.0, 1.0),
out[2].clamp(0.0, 1.0),
out[3].clamp(0.0, 1.0),
))
}
pub(crate) struct InitialColour {
pub components: Vec<f32>,
pub rgb: (f32, f32, f32),
pub cmyk: Option<(f32, f32, f32, f32)>,
pub spot_inks: Vec<(String, f32)>,
}
pub(crate) fn initial_colour_for_space(
space_name: &str,
resolved_space: Option<&Object>,
doc: &PdfDocument,
rendering_intent: crate::color::RenderingIntent,
retarget_cache: Option<&crate::rendering::resolution::context::IccTransformCache>,
) -> InitialColour {
let deref =
|obj: &Object| -> Object { doc.resolve_object(obj).unwrap_or_else(|_| obj.clone()) };
match space_name {
"DeviceGray" | "G" | "CalGray" => {
return InitialColour {
components: vec![0.0],
rgb: (0.0, 0.0, 0.0),
cmyk: None,
spot_inks: Vec::new(),
};
},
"DeviceRGB" | "RGB" | "CalRGB" => {
return InitialColour {
components: vec![0.0, 0.0, 0.0],
rgb: (0.0, 0.0, 0.0),
cmyk: None,
spot_inks: Vec::new(),
};
},
"DeviceCMYK" | "CMYK" => {
let (r, g, b) = (0.0_f32, 0.0_f32, 0.0_f32);
return InitialColour {
components: vec![0.0, 0.0, 0.0, 1.0],
rgb: (r, g, b),
cmyk: Some((0.0, 0.0, 0.0, 1.0)),
spot_inks: Vec::new(),
};
},
"Pattern" => {
return InitialColour {
components: Vec::new(),
rgb: (0.0, 0.0, 0.0),
cmyk: None,
spot_inks: Vec::new(),
};
},
_ => {},
}
let arr = match resolved_space.and_then(Object::as_array) {
Some(a) => a,
None => {
return InitialColour {
components: Vec::new(),
rgb: (0.0, 0.0, 0.0),
cmyk: None,
spot_inks: Vec::new(),
};
},
};
let type_name = arr.first().and_then(Object::as_name).unwrap_or("");
match type_name {
"CalGray" => InitialColour {
components: vec![0.0],
rgb: (0.0, 0.0, 0.0),
cmyk: None,
spot_inks: Vec::new(),
},
"CalRGB" | "Lab" => InitialColour {
components: vec![0.0, 0.0, 0.0],
rgb: (0.0, 0.0, 0.0),
cmyk: None,
spot_inks: Vec::new(),
},
"ICCBased" => {
let n = arr
.get(1)
.map(deref)
.as_ref()
.and_then(Object::as_dict)
.and_then(|d| d.get("N"))
.and_then(Object::as_integer)
.unwrap_or(3);
let components = vec![0.0_f32; n.max(1) as usize];
let cmyk = if n == 4 {
Some((0.0, 0.0, 0.0, 0.0))
} else {
None
};
InitialColour {
components,
rgb: (0.0, 0.0, 0.0),
cmyk,
spot_inks: Vec::new(),
}
},
"Indexed" => InitialColour {
components: vec![0.0],
rgb: (0.0, 0.0, 0.0),
cmyk: None,
spot_inks: Vec::new(),
},
"Separation" => {
let name_obj = arr.get(1).map(deref);
let ink = name_obj
.as_ref()
.and_then(Object::as_name)
.map(str::to_string)
.unwrap_or_default();
let spot_inks = if !ink.is_empty() && ink != "All" && ink != "None" {
vec![(ink, 1.0)]
} else {
Vec::new()
};
InitialColour {
components: vec![1.0],
rgb: (0.0, 0.0, 0.0),
cmyk: None,
spot_inks,
}
},
"DeviceN" => {
let names_obj = arr.get(1).map(deref);
let names = names_obj
.as_ref()
.and_then(Object::as_array)
.map(|names| {
names
.iter()
.filter_map(|o| o.as_name().map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let process_names: std::collections::HashSet<String> = arr
.get(4)
.map(deref)
.as_ref()
.and_then(Object::as_dict)
.and_then(|attrs| attrs.get("Process"))
.map(deref)
.as_ref()
.and_then(Object::as_dict)
.and_then(|proc_dict| proc_dict.get("Components"))
.map(deref)
.as_ref()
.and_then(Object::as_array)
.map(|comps| {
comps
.iter()
.filter_map(|o| o.as_name().map(str::to_string))
.collect()
})
.unwrap_or_default();
let spot_inks: Vec<(String, f32)> = names
.iter()
.filter(|n| n.as_str() != "All" && n.as_str() != "None")
.filter(|n| !process_names.contains(*n))
.map(|n| (n.clone(), 1.0_f32))
.collect();
let components = vec![1.0_f32; names.len().max(1)];
let cmyk = extract_process_paint_cmyk(
resolved_space.unwrap(),
&components,
doc,
rendering_intent,
retarget_cache,
);
InitialColour {
components,
rgb: (0.0, 0.0, 0.0),
cmyk,
spot_inks,
}
},
"Pattern" => InitialColour {
components: Vec::new(),
rgb: (0.0, 0.0, 0.0),
cmyk: None,
spot_inks: Vec::new(),
},
_ => InitialColour {
components: Vec::new(),
rgb: (0.0, 0.0, 0.0),
cmyk: None,
spot_inks: Vec::new(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_normal_is_separable_white_preserving() {
assert_eq!(BlendModeClass::from_name("Normal"), BlendModeClass::SeparableWhitePreserving);
}
#[test]
fn classify_luminosity_is_non_separable() {
assert_eq!(BlendModeClass::from_name("Luminosity"), BlendModeClass::NonSeparable);
}
#[test]
fn classify_difference_is_separable_non_white_preserving() {
assert_eq!(
BlendModeClass::from_name("Difference"),
BlendModeClass::SeparableNonWhitePreserving
);
}
#[test]
fn classify_unknown_falls_back_to_normal_class() {
assert_eq!(
BlendModeClass::from_name("MarketingInventedMode"),
BlendModeClass::SeparableWhitePreserving
);
}
#[test]
fn spot_dispatch_substitutes_normal_for_non_sep_and_non_wp() {
assert_eq!(
BlendModeClass::SeparableWhitePreserving.spot_dispatch(),
SpotBlendDispatch::UseRequested
);
assert_eq!(
BlendModeClass::SeparableNonWhitePreserving.spot_dispatch(),
SpotBlendDispatch::SubstituteNormal
);
assert_eq!(
BlendModeClass::NonSeparable.spot_dispatch(),
SpotBlendDispatch::SubstituteNormal
);
}
#[test]
fn process_dispatch_is_identity_for_every_class() {
for class in &[
BlendModeClass::SeparableWhitePreserving,
BlendModeClass::SeparableNonWhitePreserving,
BlendModeClass::NonSeparable,
] {
assert_eq!(class.process_dispatch(), ProcessBlendDispatch::UseRequested);
}
}
#[test]
fn sidecar_allocates_cmyk_and_spot_planes() {
let s = CmykSidecar::new(10, 5, vec!["PMS 185 C".into(), "Dieline".into()]);
assert_eq!(s.dims(), (10, 5));
assert_eq!(s.cmyk().len(), 4 * 10 * 5);
assert!(s.cmyk().iter().all(|&b| b == 0));
assert_eq!(s.spot_names(), &["PMS 185 C".to_string(), "Dieline".to_string()]);
let p0 = s.spot_plane(0).unwrap();
let p1 = s.spot_plane(1).unwrap();
assert_eq!(p0.len(), 10 * 5);
assert_eq!(p1.len(), 10 * 5);
assert!(p0.iter().all(|&b| b == 0) && p1.iter().all(|&b| b == 0));
assert!(s.spot_plane(2).is_none());
}
#[test]
fn sidecar_no_spots_has_zero_length_spot_stack() {
let s = CmykSidecar::new(7, 3, vec![]);
assert_eq!(s.dims(), (7, 3));
assert_eq!(s.cmyk().len(), 4 * 7 * 3);
assert!(s.spot_names().is_empty());
assert!(s.spot_plane(0).is_none());
}
#[test]
fn sidecar_process_plate_extracts_named_channel() {
let mut s = CmykSidecar::new(2, 2, vec![]);
let plane = s.cmyk_mut();
for (i, v) in plane.iter_mut().enumerate() {
*v = (i + 10) as u8;
}
assert_eq!(
s.process_plate("Cyan").unwrap(),
vec![10, 14, 18, 22],
"Cyan = byte 0 of every interleaved quad starting at 10, +4 per pixel"
);
assert_eq!(s.process_plate("Magenta").unwrap(), vec![11, 15, 19, 23]);
assert_eq!(s.process_plate("Yellow").unwrap(), vec![12, 16, 20, 24]);
assert_eq!(s.process_plate("Black").unwrap(), vec![13, 17, 21, 25]);
assert!(s.process_plate("PANTONE 185 C").is_none());
assert!(s.process_plate("cyan").is_none(), "case-sensitive");
}
#[test]
fn sidecar_spot_plate_returns_named_lane() {
let mut s = CmykSidecar::new(3, 1, vec!["InkA".into(), "InkB".into()]);
let plane_a = s.spot_plane_mut(0).unwrap();
plane_a.copy_from_slice(&[10, 20, 30]);
let plane_b = s.spot_plane_mut(1).unwrap();
plane_b.copy_from_slice(&[40, 50, 60]);
assert_eq!(s.spot_plate("InkA").unwrap(), &[10, 20, 30]);
assert_eq!(s.spot_plate("InkB").unwrap(), &[40, 50, 60]);
assert!(s.spot_plate("InkC").is_none());
}
#[test]
fn sidecar_restore_cmyk_and_spots_overwrites_buffers() {
let mut s = CmykSidecar::new(2, 1, vec!["InkA".into()]);
s.cmyk_mut().copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
s.spot_plane_mut(0).unwrap().copy_from_slice(&[9, 10]);
let backdrop_cmyk = vec![100u8; 8];
let backdrop_spots = vec![50u8; 2];
s.restore_cmyk(&backdrop_cmyk);
s.restore_spots(&backdrop_spots);
assert_eq!(s.cmyk(), backdrop_cmyk.as_slice());
assert_eq!(s.spots_all(), backdrop_spots.as_slice());
}
struct CapturingLogger {
buf: std::sync::Mutex<Vec<String>>,
}
impl log::Log for CapturingLogger {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= log::Level::Warn
}
fn log(&self, record: &log::Record) {
if self.enabled(record.metadata()) {
let mut g = self.buf.lock().unwrap();
g.push(format!("{}", record.args()));
}
}
fn flush(&self) {}
}
static CAPTURING_LOGGER: std::sync::OnceLock<&'static CapturingLogger> =
std::sync::OnceLock::new();
fn install_capturing_logger() -> &'static CapturingLogger {
CAPTURING_LOGGER.get_or_init(|| {
let leaked: &'static CapturingLogger = Box::leak(Box::new(CapturingLogger {
buf: std::sync::Mutex::new(Vec::new()),
}));
let _ = log::set_logger(leaked);
log::set_max_level(log::LevelFilter::Warn);
leaked
})
}
#[test]
fn discover_page_spot_inks_warns_on_deep_walk_error() {
let logger = install_capturing_logger();
let start_len = logger.buf.lock().unwrap().len();
let pdf = b"%PDF-1.4\n\
1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n\
3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>\nendobj\n\
xref\n0 4\n\
0000000000 65535 f \n\
0000000010 00000 n \n\
0000000059 00000 n \n\
0000000110 00000 n \n\
trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n175\n%%EOF\n"
.to_vec();
let doc = PdfDocument::from_bytes(pdf).expect("synthetic PDF parses");
let spots = discover_page_spot_inks(&doc, 42);
assert!(
spots.is_empty(),
"discover_page_spot_inks must return an empty vec on \
deep-walk error (not panic, not propagate); got {:?}",
spots
);
let new_records: Vec<String> = {
let guard = logger.buf.lock().unwrap();
guard[start_len..].to_vec()
};
let saw_warning = new_records
.iter()
.any(|m| m.contains("page 42") && m.contains("spot inks"));
assert!(
saw_warning,
"expected log::warn! naming page 42 and 'spot inks' on the \
deep-walk error path; captured records since start: {:?}",
new_records
);
}
#[test]
fn extract_paint_spot_inks_pattern_with_separation_underlying() {
let tint_fn = Object::Dictionary(
[
("FunctionType".to_string(), Object::Integer(2)),
(
"Domain".to_string(),
Object::Array(vec![Object::Integer(0), Object::Integer(1)]),
),
("C0".to_string(), Object::Array(vec![Object::Integer(0); 4])),
("C1".to_string(), Object::Array(vec![Object::Integer(1); 4])),
("N".to_string(), Object::Integer(1)),
]
.into_iter()
.collect(),
);
let underlying = Object::Array(vec![
Object::Name("Separation".to_string()),
Object::Name("PMS185".to_string()),
Object::Name("DeviceCMYK".to_string()),
tint_fn,
]);
let pattern_cs = Object::Array(vec![Object::Name("Pattern".to_string()), underlying]);
let pdf: Vec<u8> = b"%PDF-1.4\n\
1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n\
3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>\nendobj\n\
xref\n0 4\n\
0000000000 65535 f \n\
0000000010 00000 n \n\
0000000059 00000 n \n\
0000000110 00000 n \n\
trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n175\n%%EOF\n"
.to_vec();
let doc = PdfDocument::from_bytes(pdf).expect("synthetic PDF parses");
let components = [0.6_f32];
let spots = extract_paint_spot_inks(&pattern_cs, &components, &doc);
assert_eq!(
spots.len(),
1,
"ISO 32000-1 §8.7.3.1: Pattern[/Separation /PMS185 …] must \
surface PMS185 via the underlying-space recursion. Got \
{} entries; expected 1.",
spots.len()
);
assert_eq!(spots[0].0, "PMS185", "spot identity propagation");
assert_eq!(spots[0].1, 0.6_f32, "spot tint propagation (0.6_f32 is exact in f32)");
}
#[test]
fn process_names_if_valid_prefix_returns_set_for_valid_prefix() {
let deref = |o: &Object| -> Object { o.clone() };
let names = vec![
Object::Name("Cyan".to_string()),
Object::Name("Magenta".to_string()),
Object::Name("Yellow".to_string()),
Object::Name("Black".to_string()),
Object::Name("PMS185".to_string()),
];
let attrs = Object::Dictionary(
[(
"Process".to_string(),
Object::Dictionary(
[
("ColorSpace".to_string(), Object::Name("DeviceCMYK".to_string())),
(
"Components".to_string(),
Object::Array(vec![
Object::Name("Cyan".to_string()),
Object::Name("Magenta".to_string()),
Object::Name("Yellow".to_string()),
Object::Name("Black".to_string()),
]),
),
]
.into_iter()
.collect(),
),
)]
.into_iter()
.collect(),
);
let cs_arr = vec![
Object::Name("DeviceN".to_string()),
Object::Array(names.clone()),
Object::Name("DeviceCMYK".to_string()),
Object::Null,
attrs,
];
let result = process_names_if_valid_prefix(&cs_arr, &names, &deref);
let expected: std::collections::HashSet<String> = ["Cyan", "Magenta", "Yellow", "Black"]
.into_iter()
.map(str::to_string)
.collect();
assert_eq!(result, expected, "valid prefix returns the /Components set");
}
#[test]
fn process_names_if_valid_prefix_returns_empty_for_invalid_prefix() {
let deref = |o: &Object| -> Object { o.clone() };
let names = vec![
Object::Name("Cyan".to_string()),
Object::Name("Magenta".to_string()),
Object::Name("Yellow".to_string()),
Object::Name("Black".to_string()),
];
let attrs = Object::Dictionary(
[(
"Process".to_string(),
Object::Dictionary(
[
("ColorSpace".to_string(), Object::Name("DeviceCMYK".to_string())),
(
"Components".to_string(),
Object::Array(vec![
Object::Name("Cyan".to_string()),
Object::Name("Magenta".to_string()),
Object::Name("Yellow".to_string()),
Object::Name("Iridescent".to_string()),
]),
),
]
.into_iter()
.collect(),
),
)]
.into_iter()
.collect(),
);
let cs_arr = vec![
Object::Name("DeviceN".to_string()),
Object::Array(names.clone()),
Object::Name("DeviceCMYK".to_string()),
Object::Null,
attrs,
];
let result = process_names_if_valid_prefix(&cs_arr, &names, &deref);
assert!(
result.is_empty(),
"ISO 32000-1 §8.6.6.5 violation (one name not in /Names) \
must return empty per HONEST_GAP_DEVICEN_PROCESS_MISMATCHED\
_NAMES. Got {:?}.",
result
);
}
fn build_doc_with_resources_and_objs(
resources_inner: &str,
extra_objs: &[&str],
) -> (PdfDocument, Object) {
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(b"%PDF-1.4\n");
let cat_off = buf.len();
buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let pages_off = buf.len();
buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
let page_off = buf.len();
let page = format!(
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \
/Resources << {} >> /Contents 4 0 R >>\nendobj\n",
resources_inner
);
buf.extend_from_slice(page.as_bytes());
let stream_off = buf.len();
let body = b"% no content\n";
let stream_hdr = format!("4 0 obj\n<< /Length {} >>\nstream\n", body.len());
buf.extend_from_slice(stream_hdr.as_bytes());
buf.extend_from_slice(body);
buf.extend_from_slice(b"\nendstream\nendobj\n");
let mut extra_offs: Vec<usize> = Vec::new();
for obj in extra_objs {
extra_offs.push(buf.len());
buf.extend_from_slice(obj.as_bytes());
}
let xref_off = buf.len();
let total_objs = 4 + extra_objs.len();
buf.extend_from_slice(
format!("xref\n0 {}\n0000000000 65535 f \n", total_objs + 1).as_bytes(),
);
for off in [cat_off, pages_off, page_off, stream_off] {
buf.extend_from_slice(format!("{:010} 00000 n \n", off).as_bytes());
}
for off in extra_offs {
buf.extend_from_slice(format!("{:010} 00000 n \n", off).as_bytes());
}
buf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n",
total_objs + 1,
xref_off
)
.as_bytes(),
);
let doc = PdfDocument::from_bytes(buf).expect("synthetic PDF parses");
let resources = doc.get_page_resources(0).expect("page resources");
(doc, resources)
}
#[test]
fn detection_resolves_indirect_ca() {
let resources_inner = "/ExtGState << /T << /Type /ExtGState /ca 6 0 R >> >>";
let extras = ["6 0 obj\n0.6\nendobj\n"];
let (doc, resources) = build_doc_with_resources_and_objs(resources_inner, &extras);
assert!(
page_declares_transparency_or_overprint(&doc, &resources),
"page_declares_transparency_or_overprint must dereference \
`/ca 6 0 R` and recognise the resolved Real(0.6) < 1.0 \
as transparent."
);
assert!(
page_declares_transparency(&doc, &resources),
"page_declares_transparency must dereference `/ca 6 0 R` \
and recognise the resolved Real(0.6) < 1.0 as transparent."
);
}
#[test]
fn detection_resolves_indirect_ca_upper() {
let resources_inner = "/ExtGState << /T << /Type /ExtGState /CA 6 0 R >> >>";
let extras = ["6 0 obj\n0.7\nendobj\n"];
let (doc, resources) = build_doc_with_resources_and_objs(resources_inner, &extras);
assert!(
page_declares_transparency_or_overprint(&doc, &resources),
"page_declares_transparency_or_overprint must dereference \
`/CA 6 0 R` and recognise the resolved Real(0.7) < 1.0 \
as transparent."
);
}
#[test]
fn detection_resolves_indirect_bm() {
let resources_inner = "/ExtGState << /T << /Type /ExtGState /BM 6 0 R >> >>";
let extras = ["6 0 obj\n/Multiply\nendobj\n"];
let (doc, resources) = build_doc_with_resources_and_objs(resources_inner, &extras);
assert!(
page_declares_transparency_or_overprint(&doc, &resources),
"page_declares_transparency_or_overprint must dereference \
`/BM 6 0 R` and recognise the resolved /Multiply name as \
non-/Normal."
);
}
#[test]
fn detection_recurses_into_form_xobject_extgstate() {
let form_obj = "6 0 obj\n\
<< /Type /XObject /Subtype /Form /FormType 1 \
/BBox [0 0 100 100] \
/Resources << /ExtGState << /Half << /Type /ExtGState /ca 0.6 >> >> >> \
/Length 14 >>\n\
stream\n% no paint\n\nendstream\nendobj\n";
let resources_inner = "/XObject << /F 6 0 R >>";
let (doc, resources) = build_doc_with_resources_and_objs(resources_inner, &[form_obj]);
assert!(
page_declares_transparency_or_overprint(&doc, &resources),
"page_declares_transparency_or_overprint must recurse into \
Form-XObject /Resources/ExtGState. The form's /Half \
ExtGState declares /ca 0.6; the page must route through \
composite-then-decompose."
);
assert!(
page_declares_transparency(&doc, &resources),
"narrower page_declares_transparency must also recurse \
into nested-form ExtGState."
);
}
#[test]
fn detection_no_trigger_returns_false() {
let resources_inner = "/ColorSpace << /CS [/Separation /InkA /DeviceCMYK << >>] >>";
let (doc, resources) = build_doc_with_resources_and_objs(resources_inner, &[]);
assert!(
!page_declares_transparency_or_overprint(&doc, &resources),
"no ExtGState or XObject → no transparency / overprint trigger."
);
assert!(
!page_declares_transparency(&doc, &resources),
"no ExtGState or XObject → no transparency-only trigger."
);
}
}