mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-14 00:43:28 +00:00
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:
co-authored by
Claude Opus 5.5
KenM76
parent
d749fd8062
commit
72c607fc47
@@ -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);
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+118
@@ -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)
|
||||
}
|
||||
Vendored
+25
-6
@@ -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<()> {
|
||||
|
||||
Vendored
+1
@@ -33,6 +33,7 @@ mod log;
|
||||
|
||||
mod cache;
|
||||
mod cmyk_group;
|
||||
mod cmyk_inverse;
|
||||
mod context;
|
||||
mod convert;
|
||||
mod device;
|
||||
|
||||
Vendored
+2
-2
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user