1use crate::color::ColorSpace;
24use crate::function::FunctionCache;
25use crate::names;
26use crate::transfer::CHANNEL_SAMPLES;
27use kurbo::Affine;
28use pdfrum_common::{Diagnostics, Limits};
29use pdfrum_object::{Dict, Object, Resolve, Stream};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub struct Transparency {
34 pub group: bool,
36 pub isolated: bool,
39 pub knockout: bool,
41}
42
43impl Transparency {
44 #[must_use]
51 pub fn from_group(group: Option<&Dict>, r: &impl Resolve) -> Self {
52 let Some(group) = group else {
53 return Self::default();
54 };
55 if group.byte_string(names::S, r).as_deref() != Some(b"Transparency") {
56 return Self::default();
57 }
58 Self {
59 group: true,
60 isolated: truthy(group, names::I, r),
61 knockout: truthy(group, names::K, r),
62 }
63 }
64
65 #[must_use]
67 pub fn for_page(group: Option<&Dict>, r: &impl Resolve) -> Self {
68 Self {
69 isolated: true,
70 ..Self::from_group(group, r)
71 }
72 }
73}
74
75fn truthy(dict: &Dict, key: &pdfrum_object::Name, r: &impl Resolve) -> bool {
77 match dict.get(key, r).as_deref() {
78 Some(Object::Bool(b)) => *b,
79 Some(Object::Int(i)) => *i != 0,
80 Some(Object::Real(v)) => *v != 0.0,
81 _ => false,
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub enum SoftMaskKind {
88 #[default]
91 Luminosity,
92 Alpha,
94}
95
96#[derive(Debug, Clone, PartialEq)]
98pub struct SoftMask {
99 pub group: Stream,
101 pub kind: SoftMaskKind,
103 pub backdrop: crate::color::Rgb,
106 pub transfer: Option<Box<[u8; CHANNEL_SAMPLES]>>,
108 pub matrix: Affine,
111 pub objects: Vec<crate::page::PageObject>,
122}
123
124impl SoftMask {
125 #[must_use]
131 pub fn load<R: Resolve>(
132 value: &Object,
133 matrix: Affine,
134 r: &R,
135 functions: &mut FunctionCache,
136 limits: &Limits,
137 diags: &mut Diagnostics,
138 ) -> Option<Self> {
139 let resolved = value.resolve(r).ok()?;
142 let dict = resolved.as_dict()?;
143 let group = dict.stream(names::G, r)?;
145
146 let kind = if dict.byte_string(names::S, r).as_deref() == Some(b"Alpha") {
149 SoftMaskKind::Alpha
150 } else {
151 SoftMaskKind::Luminosity
152 };
153
154 let backdrop = match kind {
155 SoftMaskKind::Luminosity => backdrop_color(dict, &group, r, functions, limits, diags),
157 SoftMaskKind::Alpha => crate::color::Rgb::BLACK,
158 };
159
160 let transfer = dict
163 .raw(names::TR)
164 .filter(|obj| {
165 matches!(
166 obj.resolve(r).as_deref(),
167 Ok(Object::Dict(_) | Object::Stream(_))
168 )
169 })
170 .and_then(|obj| functions.load(obj, r, limits, diags))
171 .map(|func| {
172 let mut lut = Box::new([0u8; CHANNEL_SAMPLES]);
173 let mut results = vec![0.0f32; func.output_count().max(1)];
174 for (i, slot) in lut.iter_mut().enumerate() {
175 #[expect(
176 clippy::cast_precision_loss,
177 reason = "an index below 256 is exact in f32"
178 )]
179 let input = (i as f32) / 255.0;
180 let identity = u8::try_from(i).unwrap_or(u8::MAX);
181 if func.eval_into(&[input], &mut results) == 0 {
182 *slot = identity;
183 continue;
184 }
185 #[expect(
187 clippy::cast_possible_truncation,
188 clippy::cast_sign_loss,
189 reason = "the clamp bounds the product to 0..=255"
190 )]
191 let byte = (results.first().copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0)
192 .round() as u8;
193 *slot = byte;
194 }
195 lut
196 });
197
198 Some(Self {
199 group,
200 kind,
201 backdrop,
202 transfer,
203 matrix,
204 objects: Vec::new(),
207 })
208 }
209
210 #[must_use]
212 pub fn apply_transfer(&self, value: u8) -> u8 {
213 self.transfer
214 .as_ref()
215 .and_then(|lut| lut.get(usize::from(value)))
216 .copied()
217 .unwrap_or(value)
218 }
219}
220
221fn backdrop_color<R: Resolve>(
228 smask: &Dict,
229 group_stream: &Stream,
230 r: &R,
231 functions: &mut FunctionCache,
232 limits: &Limits,
233 diags: &mut Diagnostics,
234) -> crate::color::Rgb {
235 let default = crate::color::Rgb::BLACK;
236 let Some(bc) = smask.array(names::BC, r) else {
237 return default;
238 };
239 let Some(cs_obj) = group_stream
240 .dict
241 .dict(names::GROUP, r)
242 .and_then(|g| g.raw(names::CS).cloned())
243 else {
244 return default;
245 };
246 let Some(space) = crate::color::load_colorspace(&cs_obj, None, r, functions, limits, diags)
247 else {
248 return default;
249 };
250 if matches!(space, ColorSpace::Lab(_)) || space.is_special() {
251 return default;
252 }
253 if matches!(space, ColorSpace::IccBased(_)) && !space.is_normal() {
254 return default;
255 }
256 let count = bc.len().min(8);
259 let width = space.n_components().max(8);
260 let mut comps = vec![0.0f32; width];
261 for i in 0..count {
262 if let Some(slot) = comps.get_mut(i) {
263 *slot = bc.number_at_or_zero(i);
264 }
265 }
266 space.to_rgb(&comps)
267}
268
269#[cfg(test)]
270mod tests {
271 #![allow(
275 clippy::unreadable_literal,
276 clippy::float_cmp,
277 clippy::indexing_slicing,
278 clippy::cast_precision_loss,
279 clippy::cast_possible_truncation,
280 reason = "test fixtures quote oracle vectors verbatim and compare exactly"
281 )]
282
283 use super::{SoftMask, SoftMaskKind, Transparency};
284 use crate::function::FunctionCache;
285 use kurbo::Affine;
286 use pdfrum_common::{Diagnostics, Limits};
287 use pdfrum_object::{ByteSpan, Dict, Name, NoResolve, ObjRef, Object, Resolve, Stream};
288 use std::sync::Arc;
289
290 const GROUP_REF: ObjRef = ObjRef::new(1, 0);
292
293 struct GroupStore;
296
297 impl Resolve for GroupStore {
298 fn fetch(&self, r: ObjRef) -> Result<Arc<Object>, pdfrum_object::Error> {
299 if r.num == GROUP_REF.num {
300 return Ok(Arc::new(Object::Stream(Box::new(Stream::new(
301 Dict::new(),
302 ByteSpan::empty(),
303 )))));
304 }
305 Err(pdfrum_object::Error::UnresolvedRef(r))
306 }
307 }
308
309 fn group_ref() -> Object {
310 Object::Ref(GROUP_REF)
311 }
312
313 fn load_mask(pairs: Vec<(Name, Object)>) -> Option<SoftMask> {
314 let mut funcs = FunctionCache::new();
315 let mut diags = Diagnostics::default();
316 SoftMask::load(
317 &Object::Dict(Dict::from_pairs(pairs)),
318 Affine::IDENTITY,
319 &GroupStore,
320 &mut funcs,
321 &Limits::default(),
322 &mut diags,
323 )
324 }
325
326 #[test]
327 fn a_group_needs_s_to_be_exactly_transparency() {
328 let good = Dict::from_pairs([(Name::from("S"), Object::Name(Name::from("Transparency")))]);
329 assert!(Transparency::from_group(Some(&good), &NoResolve).group);
330
331 let wrong = Dict::from_pairs([(Name::from("S"), Object::Name(Name::from("Transp")))]);
332 assert!(!Transparency::from_group(Some(&wrong), &NoResolve).group);
333 assert!(!Transparency::from_group(None, &NoResolve).group);
334 }
335
336 #[test]
337 fn isolation_and_knockout_are_true_for_any_non_zero_value() {
338 let make = |key: &str, value: Object| {
339 Dict::from_pairs([
340 (Name::from("S"), Object::Name(Name::from("Transparency"))),
341 (Name::from(key), value),
342 ])
343 };
344 assert!(
345 Transparency::from_group(Some(&make("I", Object::Bool(true))), &NoResolve).isolated
346 );
347 assert!(Transparency::from_group(Some(&make("I", Object::Int(1))), &NoResolve).isolated);
348 assert!(!Transparency::from_group(Some(&make("I", Object::Int(0))), &NoResolve).isolated);
349 assert!(
350 Transparency::from_group(Some(&make("K", Object::Bool(true))), &NoResolve).knockout
351 );
352 }
353
354 #[test]
355 fn a_page_is_isolated_whatever_its_group_says() {
356 let not_isolated = Dict::from_pairs([
357 (Name::from("S"), Object::Name(Name::from("Transparency"))),
358 (Name::from("I"), Object::Bool(false)),
359 ]);
360 assert!(Transparency::for_page(Some(¬_isolated), &NoResolve).isolated);
361 assert!(Transparency::for_page(None, &NoResolve).isolated);
363 }
364
365 #[test]
366 fn a_soft_mask_defaults_to_luminosity() {
367 let mask = load_mask(vec![(Name::from("G"), group_ref())]).expect("should load");
368 assert_eq!(mask.kind, SoftMaskKind::Luminosity);
369
370 let mask = load_mask(vec![
372 (Name::from("G"), group_ref()),
373 (Name::from("S"), Object::Name(Name::from("Luminosity"))),
374 ])
375 .expect("should load");
376 assert_eq!(mask.kind, SoftMaskKind::Luminosity);
377
378 let mask = load_mask(vec![
380 (Name::from("G"), group_ref()),
381 (Name::from("S"), Object::Name(Name::from("Nonsense"))),
382 ])
383 .expect("should load");
384 assert_eq!(mask.kind, SoftMaskKind::Luminosity);
385 }
386
387 #[test]
388 fn only_the_exact_string_alpha_selects_alpha() {
389 let mask = load_mask(vec![
390 (Name::from("G"), group_ref()),
391 (Name::from("S"), Object::Name(Name::from("Alpha"))),
392 ])
393 .expect("should load");
394 assert_eq!(mask.kind, SoftMaskKind::Alpha);
395 }
396
397 #[test]
398 fn a_mask_without_a_stream_g_is_dropped() {
399 assert!(load_mask(vec![(Name::from("G"), Object::Int(7))]).is_none());
400 assert!(load_mask(vec![]).is_none());
401 }
402
403 #[test]
404 fn smask_none_yields_no_mask() {
405 let mut funcs = FunctionCache::new();
406 let mut diags = Diagnostics::default();
407 let got = SoftMask::load(
408 &Object::Name(Name::from("None")),
409 Affine::IDENTITY,
410 &NoResolve,
411 &mut funcs,
412 &Limits::default(),
413 &mut diags,
414 );
415 assert!(got.is_none());
416 }
417
418 #[test]
419 fn the_default_backdrop_is_opaque_black() {
420 let mask = load_mask(vec![(Name::from("G"), group_ref())]).expect("should load");
421 assert_eq!(mask.backdrop, crate::color::Rgb::BLACK);
422 }
423
424 #[test]
425 fn a_tr_name_is_ignored_rather_than_installed() {
426 let mask = load_mask(vec![
427 (Name::from("G"), group_ref()),
428 (Name::from("TR"), Object::Name(Name::from("Identity"))),
429 ])
430 .expect("should load");
431 assert!(mask.transfer.is_none());
432 assert_eq!(mask.apply_transfer(128), 128);
434 }
435}