1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
//! Per-channel ink-routing stage.
//!
//! Subsumes the role of `separation_renderer.rs:714-822` (`tint_for_ink`):
//! given a fully-resolved colour and a target ink, decide whether the backend
//! paints into the plate (and at what tint) or skips it.
//!
//! Today this stage is dead code at the integration layer — the separation
//! renderer still uses its own `tint_for_ink`. The stage is here so that when
//! the separation backend migrates onto the pipeline (follow-up branch) the
//! per-plate decision can be taken by reading the [`ResolvedColor`]
//! produced by [`super::ColorResolver`] plus the [`OverprintPlan`] produced
//! by [`super::OverprintResolver`] without re-walking the source colour
//! space.
use crate::content::graphics_state::GraphicsState;
use super::resolved::{InkName, InkSelector, OverprintPlan, ResolvedColor};
pub(crate) struct InkRouter;
/// Per-plate decision returned by [`InkRouter::route`].
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum InkAction {
/// Paint into the target plate with the given tint (0.0 = knock out the
/// plate at the touched pixels; 1.0 = full ink coverage).
Paint(f32),
/// Leave the target plate completely untouched (overprint-skip).
Skip,
}
impl InkRouter {
pub(crate) const fn new() -> Self {
Self
}
/// Decide what to do with `target_ink` for the given resolved colour.
///
/// Implements the decision tree from ISO 32000-1:2008 §11.7.4 plus
/// the `/All` and `/None` reserved-name handling from §8.6.6.3:
///
/// - [`InkSelector::All`] (Separation `/All`): paint *every* plate at
/// the single tint value carried on [`OverprintPlan::all_tint`],
/// including spot plates the source doesn't name. The spec calls
/// out this is the one case where a single colorant operator
/// targets every output separation.
/// - [`InkSelector::None`] (Separation `/None`): produce no visible
/// output; skip every plate.
/// - [`InkSelector::Listed`] (every other case): if the colour's
/// participating channel set names `target_ink`, paint with the
/// channel value; if it doesn't and overprint is enabled, leave
/// the plate untouched; if it doesn't and overprint is disabled
/// (the spec default), paint 0.0 — "areas of unspecified
/// colorants are erased" (the per-plate knockout rule).
/// - For OPM=1 sources, a zero-valued channel for `target_ink` means
/// "colorant not specified" — leave the plate untouched even when
/// the channel is in the participating set.
/// - For DeviceN, a channel literally named `"None"` is dropped per
/// §8.6.6.4 and never matches.
pub(crate) fn route(
&self,
_gs: &GraphicsState,
target_ink: &InkName,
color: &ResolvedColor,
overprint: &OverprintPlan,
) -> InkAction {
// /All and /None are reserved Separation colorant names per
// §8.6.6.3 — the OverprintResolver marks them on the plan's
// `selector` so the router can short-circuit before walking
// the per-channel participating list.
match overprint.selector {
InkSelector::All => return InkAction::Paint(overprint.all_tint),
InkSelector::None => return InkAction::Skip,
InkSelector::Listed => {},
}
// Pull the participating channels from the appropriate variant.
let participating = &overprint.participating;
if participating.is_empty() {
// RGB sources don't route to plates at all.
return InkAction::Skip;
}
// Look for our target ink in the participating channels. Per
// §8.6.6.4 a DeviceN channel named "None" is dropped — we don't
// even consider it a match.
if let Some(ch) = participating
.iter()
.find(|c| c.ink == *target_ink && c.ink.as_str() != "None")
{
// OPM=1 "Adobe nonzero overprint": a zero channel value on
// DeviceCMYK means "colorant not specified" → skip.
// §11.7.4.3 limits OPM=1 to DeviceCMYK sources; we identify
// those by the colour variant. `IccCmyk` is a CMYK source
// for OPM purposes — the embedded ICC profile only changes
// the composite-RGB path; the per-plate model is identical.
let is_cmyk =
matches!(color, ResolvedColor::Cmyk { .. } | ResolvedColor::IccCmyk { .. });
if overprint.enabled && overprint.mode == 1 && is_cmyk && ch.value == 0.0 {
return InkAction::Skip;
}
return InkAction::Paint(ch.value);
}
// Target ink is outside the source's colorant set. Overprint=true
// leaves the plate untouched; overprint=false knocks it out.
if overprint.enabled {
InkAction::Skip
} else {
InkAction::Paint(0.0)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use smallvec::smallvec;
use super::super::resolved::ParticipatingChannel;
fn fresh_gs() -> GraphicsState {
GraphicsState::new()
}
fn cmyk_color() -> ResolvedColor {
ResolvedColor::Cmyk {
c: 0.5,
m: 0.25,
y: 0.0,
k: 0.1,
a: 1.0,
}
}
fn cmyk_plan(enabled: bool, mode: u8) -> OverprintPlan {
OverprintPlan {
enabled,
mode,
participating: smallvec![
ParticipatingChannel {
ink: InkName::new("Cyan"),
value: 0.5
},
ParticipatingChannel {
ink: InkName::new("Magenta"),
value: 0.25
},
ParticipatingChannel {
ink: InkName::new("Yellow"),
value: 0.0
},
ParticipatingChannel {
ink: InkName::new("Black"),
value: 0.1
},
],
selector: InkSelector::Listed,
all_tint: 0.0,
spot_source: None,
alt_cmyk_fallback: None,
}
}
#[test]
fn cmyk_paints_named_channel() {
let gs = fresh_gs();
let plan = cmyk_plan(false, 0);
let color = cmyk_color();
let action = InkRouter::new().route(&gs, &InkName::new("Magenta"), &color, &plan);
assert_eq!(action, InkAction::Paint(0.25));
}
#[test]
fn spot_plate_outside_cmyk_knocks_out_by_default() {
// §11.7.4 default: overprint=false → unspecified plates knock out
// (paint 0.0 to erase underlying ink).
let gs = fresh_gs();
let plan = cmyk_plan(false, 0);
let color = cmyk_color();
let action = InkRouter::new().route(&gs, &InkName::new("PANTONE 185 C"), &color, &plan);
assert_eq!(action, InkAction::Paint(0.0));
}
#[test]
fn spot_plate_outside_cmyk_skips_when_overprint() {
// §11.7.4 with OP=true: unspecified plates are left untouched.
let gs = fresh_gs();
let plan = cmyk_plan(true, 0);
let color = cmyk_color();
let action = InkRouter::new().route(&gs, &InkName::new("PANTONE 185 C"), &color, &plan);
assert_eq!(action, InkAction::Skip);
}
#[test]
fn opm_one_skips_zero_components_on_cmyk() {
// §11.7.4.3 OPM=1: a zero channel on DeviceCMYK is "colorant not
// specified" → leave the matching plate alone.
let gs = fresh_gs();
let plan = cmyk_plan(true, 1);
let color = ResolvedColor::Cmyk {
c: 0.5,
m: 0.0,
y: 0.0,
k: 0.0,
a: 1.0,
};
// Plan reflects the zero values; ensure routing acts on them.
let mut plan = plan;
plan.participating[1].value = 0.0; // Magenta = 0
let action = InkRouter::new().route(&gs, &InkName::new("Magenta"), &color, &plan);
assert_eq!(action, InkAction::Skip);
}
#[test]
fn opm_zero_paints_zero_components_normally() {
// §11.7.4 OPM=0 (default): zero is *not* special — paint it
// (which knocks the plate out at the painted pixels).
let gs = fresh_gs();
let mut plan = cmyk_plan(true, 0);
plan.participating[1].value = 0.0;
let color = ResolvedColor::Cmyk {
c: 0.5,
m: 0.0,
y: 0.0,
k: 0.0,
a: 1.0,
};
let action = InkRouter::new().route(&gs, &InkName::new("Magenta"), &color, &plan);
assert_eq!(action, InkAction::Paint(0.0));
}
#[test]
fn rgb_source_skips_all_plates() {
// §11.7.4 doesn't define overprint for RGB sources. The plan's
// participating set is empty (by construction in OverprintResolver),
// so every plate gets Skip.
let gs = fresh_gs();
let plan = OverprintPlan {
enabled: true,
mode: 0,
participating: smallvec![],
selector: InkSelector::Listed,
all_tint: 0.0,
spot_source: None,
alt_cmyk_fallback: None,
};
let color = ResolvedColor::Rgba {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
};
let action = InkRouter::new().route(&gs, &InkName::new("Cyan"), &color, &plan);
assert_eq!(action, InkAction::Skip);
}
#[test]
fn all_inks_paints_every_plate_at_single_tint() {
// §8.6.6.3: Separation /All names every output plate. Both
// process and spot plates receive the same tint, regardless of
// overprint state and regardless of whether participating
// happens to list them. The router consults the
// `selector: InkSelector::All` marker to short-circuit.
let gs = fresh_gs();
// Composite colour resolution may still produce gray-at-tint;
// the router does not read `color` when selector is All/None.
let color = ResolvedColor::Rgba {
r: 0.6,
g: 0.6,
b: 0.6,
a: 1.0,
};
let plan = OverprintPlan {
enabled: false,
mode: 0,
participating: smallvec![],
selector: InkSelector::All,
all_tint: 0.6,
spot_source: None,
alt_cmyk_fallback: None,
};
let router = InkRouter::new();
for ink_name in [
"Cyan",
"Magenta",
"Yellow",
"Black",
"PANTONE 185 C",
"Dieline",
] {
let action = router.route(&gs, &InkName::new(ink_name), &color, &plan);
assert_eq!(action, InkAction::Paint(0.6), "/All must paint plate {ink_name}");
}
}
#[test]
fn all_inks_paints_even_when_overprint_enabled() {
// /All is unconditional: spec doesn't carve out an overprint
// exception. Same tint, every plate, OP=true.
let gs = fresh_gs();
let color = ResolvedColor::Rgba {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
};
let plan = OverprintPlan {
enabled: true,
mode: 1, // even with OPM=1 active
participating: smallvec![],
selector: InkSelector::All,
all_tint: 1.0,
spot_source: None,
alt_cmyk_fallback: None,
};
let router = InkRouter::new();
for ink_name in ["Cyan", "Magenta", "Yellow", "Black", "PANTONE Reflex Blue"] {
let action = router.route(&gs, &InkName::new(ink_name), &color, &plan);
assert_eq!(action, InkAction::Paint(1.0), "/All ignores overprint; plate {ink_name}");
}
}
#[test]
fn none_inks_skips_every_plate() {
// §8.6.6.3: Separation /None produces no visible output. Every
// plate skips, regardless of overprint state.
let gs = fresh_gs();
let color = ResolvedColor::Rgba {
r: 0.0,
g: 0.0,
b: 0.0,
a: 0.0,
};
let plan = OverprintPlan {
enabled: false,
mode: 0,
participating: smallvec![],
selector: InkSelector::None,
all_tint: 0.0,
spot_source: None,
alt_cmyk_fallback: None,
};
let router = InkRouter::new();
for ink_name in [
"Cyan",
"Magenta",
"Yellow",
"Black",
"PANTONE 185 C",
"Dieline",
] {
let action = router.route(&gs, &InkName::new(ink_name), &color, &plan);
assert_eq!(action, InkAction::Skip, "/None must skip plate {ink_name}");
}
}
#[test]
fn devicen_channel_named_none_is_dropped() {
// §8.6.6.4: a DeviceN channel literally named "None" is dropped
// from per-plate routing. Even if a target plate happens to be
// named "None", the router does not treat that as a match — it
// falls through to the unspecified-plate path (knock out when
// overprint is off).
let gs = fresh_gs();
let plan = OverprintPlan {
enabled: false,
mode: 0,
participating: smallvec![
ParticipatingChannel {
ink: InkName::new("None"),
value: 0.5,
},
ParticipatingChannel {
ink: InkName::new("PANTONE 185 C"),
value: 0.75,
},
],
selector: InkSelector::Listed,
all_tint: 0.0,
spot_source: None,
alt_cmyk_fallback: None,
};
let color = ResolvedColor::PerChannel {
channels: Box::new(smallvec![
(InkName::new("None"), 0.5),
(InkName::new("PANTONE 185 C"), 0.75),
]),
a: 1.0,
};
let router = InkRouter::new();
// "None" target falls through to other_plate_action — knock out.
let action = router.route(&gs, &InkName::new("None"), &color, &plan);
assert_eq!(action, InkAction::Paint(0.0));
// Real ink still routes normally.
let action = router.route(&gs, &InkName::new("PANTONE 185 C"), &color, &plan);
assert_eq!(action, InkAction::Paint(0.75));
}
#[test]
fn per_channel_devicen_routes_by_ink_name() {
// DeviceN with named channels: route by exact ink name.
let gs = fresh_gs();
let plan = OverprintPlan {
enabled: false,
mode: 0,
participating: smallvec![
ParticipatingChannel {
ink: InkName::new("PANTONE 185 C"),
value: 0.75
},
ParticipatingChannel {
ink: InkName::new("Dieline"),
value: 0.1
},
],
selector: InkSelector::Listed,
all_tint: 0.0,
spot_source: None,
alt_cmyk_fallback: None,
};
let color = ResolvedColor::PerChannel {
channels: Box::new(smallvec![
(InkName::new("PANTONE 185 C"), 0.75),
(InkName::new("Dieline"), 0.1),
]),
a: 1.0,
};
let action = InkRouter::new().route(&gs, &InkName::new("Dieline"), &color, &plan);
assert_eq!(action, InkAction::Paint(0.1));
}
}