fix(render): an RGB colour in an isolated DeviceCMYK group blends in CMYK, as its ink (#7031)

* fix(render): an RGB colour in an isolated DeviceCMYK group blends in CMYK, as its ink

Part of #4887. An isolated DeviceCMYK group blended in CMYK only when every
colour in it had process tints (DeviceCMYK, DeviceGray); one RGB fill sent
the whole group back to RGB blending.

Vendored hayro-interpret patch (104): Color::ink_tints gives DeviceRGB,
CalRGB, Lab and three-component ICCBased colours the ink that draws back to
them through the bundled CMYK profile (new cmyk_inverse module: black
0.4*k0^2 + 0.6*k0^8 with k0 = 1 - max(r, g, b), matched to Acrobat on
synthetic pages; then cyan, magenta and yellow by at most 12 Newton steps
against the profile; one-entry per-thread cache). ICCBased CMYK still has no
tints.

Test an_rgb_colour_in_an_isolated_cmyk_group_blends_as_its_ink: 50% grey
under Difference with black within 10 levels of Acrobat's 137 112 106;
in-gamut colours draw as themselves; red within 8 of Acrobat's 238 32 37.
With the additive arms reverted the grey draws 93 97 96 and it fails.
#5228's control now uses an RGB image, which still has no tints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(render): an RGB colour with a non-finite component has no CMYK ink

`to_rgba` turns a non-finite component into some finite colour, so the
inverse could not see it and gave that colour's ink. `ink_from_srgb` now
declines such a colour and the group draws it as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ken Mantle
2026-11-12 17:57:26 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 KenM76
parent d749fd8062
commit 72c607fc47
6 changed files with 191 additions and 14 deletions
@@ -88,11 +88,12 @@ endstream endobj
assert!(distance(&group, &square) <= 3, "{mode}: group {group:?}, square {square:?}");
}
// A group whose contents have no ink tints (here the second element is RGB) is
// composited in RGB exactly as before: the same as a group blending in DeviceRGB.
let rgb = |_: [f32; 4]| "1 0 0 rg".to_string();
let cmyk_group = render("/CS /DeviceCMYK /I true", &rgb);
let rgb_group = render("/CS /DeviceRGB /I true", &rgb);
// A group whose contents have no ink tints (here the second element draws an RGB image
// over the page) is composited in RGB exactly as before: the same as a group blending
// in DeviceRGB.
let image = |_: [f32; 4]| "q 370 0 0 60 0 0 cm BI /W 1 /H 1 /CS /DeviceRGB /BPC 8 /F /AHx ID ff0000> EI Q".to_string();
let cmyk_group = render("/CS /DeviceCMYK /I true", &image);
let rgb_group = render("/CS /DeviceRGB /I true", &image);
assert_eq!(cmyk_group.rgba, rgb_group.rgba, "contents without tints are drawn as before");
}
@@ -247,6 +248,32 @@ trailer << /Root 1 0 R >>
p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec()
}
/// An RGB colour painted in an isolated `DeviceCMYK` group is blended as the process ink that
/// draws back to it through the CMYK profile (hayro-interpret patch (104)), instead of
/// sending the whole group back to RGB blending.
///
/// Under `/BM /Difference` with black ink, a colour's cyan, magenta and yellow are
/// complemented and its black kept (ISO 32000-2 §11.3.4), so 50% grey, mostly cyan, magenta
/// and yellow ink, comes out a warm grey: neither the neutral grey of RGB blending nor the
/// near-black of a grey made from black ink alone. Acrobat draws the same blend on a CMYK page
/// as (137, 112, 106). Painted without a blend, a colour inside the profile's gamut draws as
/// itself, and pure red is clipped to the profile's red, as in Acrobat.
#[test]
fn an_rgb_colour_in_an_isolated_cmyk_group_blends_as_its_ink() {
let resources = "/ExtGState << /D << /BM /Difference >> >>";
let pixel = |group: &str| cmyk_group_pixel([100, 100], "/G Do", [20, 20, 80, 80], group, resources, (50, 50));
let near = |a: &[u8], b: [u8; 3], within: u8| a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= within);
let blended = pixel("0.5 0.5 0.5 rg 20 20 60 60 re f /D gs 0 0 0 1 k 20 20 60 60 re f");
assert!(near(&blended, [137, 112, 106], 10), "grey under Difference with black: {blended:?}");
// (colour, expected pixel, how far from it)
for (rgb, expected, within) in [("0.8 0.4 0.2", [204, 102, 51], 3), ("0.3 0.7 0.4", [77, 179, 102], 3), ("1 0 0", [238, 32, 37], 8)] {
let drawn = pixel(&format!("{rgb} rg 20 20 60 60 re f"));
assert!(near(&drawn, expected, within), "{rgb} drew as {drawn:?}, expected {expected:?}");
}
}
/// From the review of #5228: a shading pattern painted before the group left its pattern
/// transform on the context, and the finished group was drawn through it, so the cyan half
/// of the group showed magenta. The group must look the same whether or not a pattern was
@@ -325,3 +352,15 @@ trailer << /Root 1 0 R >>
assert!(rgb[1] > 120 && rgb[0] < 60 && rgb[2] < 120, "blended in CMYK, green: {rgb:?}");
}
}
/// An RGB colour with a non-finite component has no tints: converting it to RGB first would
/// give some finite colour that is not its ink.
#[test]
fn an_rgb_colour_with_a_non_finite_component_has_no_tints() {
use hayro::hayro_interpret::color::AlphaColor;
use hayro::hayro_interpret::color::Color;
let rgb = |c: [f32; 3]| Color::from_rgba(AlphaColor::new([c[0], c[1], c[2], 1.0])).ink_tints();
assert!(rgb([0.5, 0.5, 0.5]).is_some());
assert_eq!(rgb([f32::NAN, 0.5, 0.5]), None);
assert_eq!(rgb([0.5, f32::INFINITY, 0.5]), None);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+118
View File
@@ -0,0 +1,118 @@
//! PdfCraft patch (104): an sRGB colour as the process ink that draws back to it through the
//! CMYK profile pages are drawn with, for an additive colour painted into a `DeviceCMYK`
//! blending space (ISO 32000-2 §11.3.4).
//!
//! Black is `K = 0.4 · k₀² + 0.6 · k₀⁸` with `k₀ = 1 − max(r, g, b)`, light in the mid tones
//! and full for black; cyan, magenta and yellow are then solved for by Newton's method at that
//! black, clamped to the ink range. A colour inside the profile's gamut draws as itself; one
//! outside it lands where the clamped solve stops. The curve matches Acrobat's output for the
//! greys, black and red measured in a CMYK page group, plain and under a blend mode;
//! §10.4.2.4's black generation and undercolour removal is for a device without a profile.
use moxcms::{ColorProfile, Layout, TransformF32Executor, TransformOptions};
use std::cell::Cell;
use std::sync::{Arc, LazyLock};
use crate::color::CMYK_PROFILE;
/// The weight of `k₀²` in the black ink; the rest is on `k₀⁸`.
const BLACK_GENERATION: f32 = 0.4;
/// Newton steps; each costs four profile evaluations.
const MAX_STEPS: usize = 12;
/// Close enough: well under one 8-bit level.
const TOLERANCE: f32 = 0.5 / 255.0;
/// The finite-difference step for the Jacobian.
const STEP: f32 = 0.02;
static FORWARD: LazyLock<Option<Arc<TransformF32Executor>>> = LazyLock::new(|| {
ColorProfile::new_from_slice(CMYK_PROFILE)
.ok()?
.create_transform_f32(
Layout::Rgba,
&ColorProfile::new_srgb(),
Layout::Rgb,
TransformOptions::default(),
)
.ok()
});
/// The process ink `[C, M, Y, K]` (0 = no ink) that the CMYK profile draws as `rgb`; for a
/// colour outside its gamut, where the clamped solve stops. `None` when the
/// profile can't be evaluated or `rgb` isn't finite.
///
/// The last colour converted on this thread is kept, as a run of fills (the glyphs of a
/// line of text) usually shares one colour.
pub(crate) fn srgb_to_cmyk(rgb: [f32; 3]) -> Option<[f32; 4]> {
if !rgb.iter().all(|v| v.is_finite()) {
return None;
}
let rgb = rgb.map(|v| v.clamp(0.0, 1.0));
let key = rgb.map(f32::to_bits);
if let Some((last, ink)) = LAST.get()
&& last == key
{
return Some(ink);
}
let ink = solve_ink(rgb)?;
LAST.set(Some((key, ink)));
Some(ink)
}
thread_local! {
static LAST: Cell<Option<([u32; 3], [f32; 4])>> = const { Cell::new(None) };
}
fn solve_ink(rgb: [f32; 3]) -> Option<[f32; 4]> {
let forward: &TransformF32Executor = FORWARD.as_ref()?.as_ref();
let k0 = 1.0 - rgb[0].max(rgb[1]).max(rgb[2]);
let k = BLACK_GENERATION * k0 * k0 + (1.0 - BLACK_GENERATION) * k0.powi(8);
let eval = |cmy: [f32; 3]| draw(forward, [cmy[0], cmy[1], cmy[2], k]);
let mut cmy = rgb.map(|v| 1.0 - v);
for _ in 0..MAX_STEPS {
let drawn = eval(cmy)?;
let residual: [f32; 3] = std::array::from_fn(|i| drawn[i] - rgb[i]);
if residual.iter().all(|r| r.abs() < TOLERANCE) {
break;
}
// Column `j` of the Jacobian: how the drawn colour moves with ink `j`.
let mut columns = [[0.0_f32; 3]; 3];
for (j, column) in columns.iter_mut().enumerate() {
let step = if cmy[j] <= 1.0 - STEP { STEP } else { -STEP };
let mut moved = cmy;
moved[j] += step;
let shifted = eval(moved)?;
*column = std::array::from_fn(|i| (shifted[i] - drawn[i]) / step);
}
let Some(delta) = solve(columns, residual.map(|r| -r)) else {
break;
};
cmy = std::array::from_fn(|i| (cmy[i] + delta[i]).clamp(0.0, 1.0));
}
Some([cmy[0], cmy[1], cmy[2], k])
}
fn draw(forward: &TransformF32Executor, cmyk: [f32; 4]) -> Option<[f32; 3]> {
let mut out = [0.0_f32; 3];
forward.transform(&cmyk, &mut out).ok()?;
out.iter().all(|v| v.is_finite()).then_some(out)
}
/// `x` with `A x = b`, `A` given by its columns (Cramer's rule). `None` when `A` is
/// singular or the result isn't finite.
fn solve(a: [[f32; 3]; 3], b: [f32; 3]) -> Option<[f32; 3]> {
let det = |[c0, c1, c2]: [[f32; 3]; 3]| {
c0[0] * (c1[1] * c2[2] - c2[1] * c1[2]) - c1[0] * (c0[1] * c2[2] - c2[1] * c0[2])
+ c2[0] * (c0[1] * c1[2] - c1[1] * c0[2])
};
let d = det(a);
if !d.is_finite() || d.abs() < 1e-9 {
return None;
}
let x: [f32; 3] = std::array::from_fn(|j| {
let mut m = a;
m[j] = b;
det(m) / d
});
x.iter().all(|v| v.is_finite()).then_some(x)
}
+25 -6
View File
@@ -1552,9 +1552,10 @@ impl Color {
/// its opacity, for blending in a `DeviceCMYK` transparency group (ISO 32000-2 §11.3.4).
///
/// `DeviceCMYK` gives its components. `DeviceGray` is black ink alone, `K = 1 − gray`
/// (§10.4.2.2). `None` for every other space, ICCBased CMYK included: such a colour has no
/// tints without a colour conversion this crate does not make (an ICCBased colour keeps
/// its own profile), and the caller draws it as before.
/// (§10.4.2.2). PdfCraft patch (104): an additive colour (`DeviceRGB`, `CalRGB`, `Lab`, a
/// three-component ICCBased colour) is the ink that draws back to it through the CMYK
/// profile. `None` for every other space, ICCBased CMYK included (it keeps its own
/// profile), and the caller draws it as before.
pub fn ink_tints(&self) -> Option<([f32; 4], f32)> {
let c = |i: usize| {
self.components
@@ -1566,11 +1567,27 @@ impl Color {
let tints = match self.color_space.0.as_ref() {
ColorSpaceType::DeviceCmyk => [c(0), c(1), c(2), c(3)],
ColorSpaceType::DeviceGray => [0.0, 0.0, 0.0, 1.0 - c(0)],
ColorSpaceType::DeviceRgb | ColorSpaceType::CalRgb(_) | ColorSpaceType::Lab(_) => {
self.ink_from_srgb()?
}
ColorSpaceType::ICCBased(icc) if icc.0.number_components == 3 => {
self.ink_from_srgb()?
}
_ => return None,
};
Some((tints, self.opacity.clamp(0.0, 1.0)))
}
fn ink_from_srgb(&self) -> Option<[f32; 4]> {
// `to_rgba` turns a non-finite component into some finite colour, which is not the
// colour's ink, so such a colour has no tints.
if self.components.iter().any(|c| !c.is_finite()) {
return None;
}
let [r, g, b, _] = self.to_rgba().components();
crate::cmyk_inverse::srgb_to_cmyk([r, g, b])
}
/// Create a color from RGBA.
pub fn from_rgba(rgba: AlphaColor) -> Self {
let c = rgba.components();
@@ -1592,9 +1609,11 @@ pub fn cmyk_to_srgb8(input: &[u8], output: &mut [u8]) -> Option<()> {
CMYK_TRANSFORM.convert_u8(input, output)
}
static CMYK_TRANSFORM: LazyLock<ICCProfile> = LazyLock::new(|| {
ICCProfile::new(include_bytes!("../assets/CGATS001Compat-v2-micro.icc"), 4).unwrap()
});
/// The CMYK profile pages are drawn with.
pub(crate) const CMYK_PROFILE: &[u8] = include_bytes!("../assets/CGATS001Compat-v2-micro.icc");
static CMYK_TRANSFORM: LazyLock<ICCProfile> =
LazyLock::new(|| ICCProfile::new(CMYK_PROFILE, 4).unwrap());
pub(crate) trait ToRgb {
fn convert_sample(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()> {
+1
View File
@@ -33,6 +33,7 @@ mod log;
mod cache;
mod cmyk_group;
mod cmyk_inverse;
mod context;
mod convert;
mod device;
+2 -2
View File
@@ -922,8 +922,8 @@ impl TilingCells {
#[derive(Clone, Copy, Default)]
struct InkState {
pass: Option<InkPass>,
/// Something in the group has no ink tints (an RGB colour, a pattern, an image): the
/// group is drawn as before instead.
/// Something in the group has no ink tints (a pattern, an image, an ICCBased CMYK colour):
/// the group is drawn as before instead.
unsupported: bool,
}