M2: a luminosity soft mask in a DeviceCMYK group converts as ISO 32000-2 10.4.2 says, so 100% K masks fully (#4423)

A luminosity soft mask is drawn into an RGB buffer and its value read
as the luminance of that buffer. A /DeviceCMYK colour reached the
buffer through the CMYK display profile, where 100% K is a dark grey,
so a mask painted 100% K in a DeviceCMYK group let about an eighth of
the masked paint through (mask value 0.125 instead of 0).

ISO 32000-2 11.5.3 converts a device-space group colour to gray "with
no compensation for gamma or other colour calibration", and 10.4.2.3
gives DeviceCMYK to DeviceGray as
gray = 1 - min(1, 0.3 c + 0.59 m + 0.11 y + k).

hayro-interpret patch (63): when the group colour space of a luminosity
soft mask is /DeviceCMYK itself (not an ICC-based or calibrated space),
the mask's context paints /DeviceCMYK fill and stroke colours as that
gray, and a four-component /BC becomes the same gray. A gray reads back
as itself through any luminance weights that sum to one. CMYK on the
page, CMYK in DeviceGray/DeviceRGB mask groups, and CMYK images,
shadings and tiling patterns inside such a mask keep the profile.

Tests: color::device_cmyk_gray_tests (hayro-interpret) and
it::luminosity_group_space (pdfcraft-render); the three DeviceCMYK
render tests fail with the patch reverted.


Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mtlcl410
2026-10-25 10:02:49 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2be6eb1577
commit 7dd44301db
8 changed files with 230 additions and 9 deletions
@@ -0,0 +1,132 @@
//! A luminosity soft mask takes its values from the gray of its group's colour (ISO 32000-2
//! §11.5.3). In a `/DeviceCMYK` group that gray is `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)`
//! (§10.4.2.3), so 100% K masks fully, whatever profile CMYK is displayed through.
use std::sync::Arc;
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
/// The page's grey backdrop, `0.5 g`, as the renderer writes it.
const BACKDROP: f64 = 128.0;
fn pdf(objects: &[String]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out
}
fn stream(dict: &str, content: &str) -> String {
format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len())
}
fn render(bytes: Vec<u8>) -> (Vec<u8>, u32) {
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(page.error.is_none(), "{:?}", page.error);
(page.rgba.to_vec(), page.width)
}
fn pixel(rgba: &[u8], width: u32, x: u32, y: u32) -> [u8; 3] {
let offset = usize::try_from((y * width + x) * 4).unwrap();
[rgba[offset], rgba[offset + 1], rgba[offset + 2]]
}
/// Red filled over the grey backdrop under a luminosity mask whose group, in `space`, paints its
/// left half with `paint`; its right half is the backdrop `/BC` (`bc`, when given). Returns the
/// mask values read back from the left and the right half.
fn mask_values(space: &str, paint: &str, bc: Option<&str>) -> (f64, f64) {
let bc = bc.map(|bc| format!("/BC {bc}")).unwrap_or_default();
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> >> >>".to_string(),
stream("", "0.5 g 0 0 100 100 re f /M gs 1 0 0 rg 0 0 100 100 re f"),
format!("<< /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 6 0 R {bc} >> >>"),
stream(
&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS {space} >>"),
&format!("{paint} 0 0 50 100 re f"),
),
];
let (rgba, width) = render(pdf(&objects));
let value = |x| {
let [r, g, b] = pixel(&rgba, width, x, 50);
// Red over grey: red rises from the backdrop to 255 and green and blue fall to 0 as the
// mask goes from 0 to 1.
let from_red = (f64::from(r) - BACKDROP) / (255.0 - BACKDROP);
let from_green = (BACKDROP - f64::from(g)) / BACKDROP;
let from_blue = (BACKDROP - f64::from(b)) / BACKDROP;
assert!(
(from_red - from_green).abs() < 0.03 && (from_green - from_blue).abs() < 0.03,
"{space} {paint}: pixel {:?} is not red over the backdrop",
[r, g, b]
);
from_green
};
(value(25), value(75))
}
fn assert_mask(space: &str, paint: &str, bc: Option<&str>, left: f64, right: f64) {
let (got_left, got_right) = mask_values(space, paint, bc);
assert!((got_left - left).abs() < 0.02, "{space} group, `{paint}`: mask {got_left:.3}, expected {left}");
assert!((got_right - right).abs() < 0.02, "{space} group, /BC {bc:?}: mask {got_right:.3}, expected {right}");
}
#[test]
fn black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully() {
assert_mask("/DeviceCMYK", "0 0 0 1 k", None, 0.0, 0.0);
assert_mask("/DeviceCMYK", "0 0 0 0 k", None, 1.0, 0.0);
assert_mask("/DeviceCMYK", "0 0 0 0.5 k", None, 0.5, 0.0);
}
#[test]
fn each_process_ink_in_a_device_cmyk_group_masks_by_its_weight() {
assert_mask("/DeviceCMYK", "1 0 0 0 k", None, 0.7, 0.0);
assert_mask("/DeviceCMYK", "0 1 0 0 k", None, 0.41, 0.0);
assert_mask("/DeviceCMYK", "0 0 1 0 k", None, 0.89, 0.0);
assert_mask("/DeviceCMYK", "1 1 1 1 k", None, 0.0, 0.0);
// The stroking colour too, and a colour selected with `cs`/`sc`.
assert_mask("/DeviceCMYK", "0 0 0 1 K 0 0 0 1 k 50 w 25 0 m 25 100 l S", None, 0.0, 0.0);
assert_mask("/DeviceCMYK", "/DeviceCMYK cs 0 0 0 0.5 sc", None, 0.5, 0.0);
}
#[test]
fn a_device_cmyk_backdrop_colour_masks_by_its_gray() {
assert_mask("/DeviceCMYK", "0 0 0 1 k", Some("[0 0 0 0]"), 0.0, 1.0);
assert_mask("/DeviceCMYK", "0 0 0 0 k", Some("[0 0 0 1]"), 1.0, 0.0);
assert_mask("/DeviceCMYK", "0 0 0 1 k", Some("[1 0 0 0]"), 0.0, 0.7);
}
#[test]
fn gray_and_rgb_groups_mask_as_before() {
assert_mask("/DeviceGray", "0 g", None, 0.0, 0.0);
assert_mask("/DeviceGray", "1 g", Some("[1]"), 1.0, 1.0);
assert_mask("/DeviceGray", "0.5 g", None, 0.5, 0.0);
assert_mask("/DeviceRGB", "0 0 0 rg", None, 0.0, 0.0);
assert_mask("/DeviceRGB", "1 1 1 rg", Some("[1 1 1]"), 1.0, 1.0);
assert_mask("/DeviceRGB", "0.5 0.5 0.5 rg", None, 0.5, 0.0);
}
#[test]
fn device_cmyk_on_the_page_keeps_its_display_colour() {
// Only a luminosity mask in a DeviceCMYK group reads CMYK as its gray. On the page, CMYK is
// displayed through the CMYK profile as before, where 100% K is a dark grey.
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_string(),
stream("", "0 0 0 1 k 0 0 100 100 re f"),
];
let (rgba, width) = render(pdf(&objects));
let [r, g, b] = pixel(&rgba, width, 50, 50);
assert!(r > 10 && g > 10 && b > 10 && r < 80, "100% K on the page: {:?}", [r, g, b]);
}
+1
View File
@@ -3,6 +3,7 @@
mod cid_fallback_query;
mod inline_image_ei;
mod jbig2_invariants;
mod luminosity_group_space;
mod named_form_properties;
mod search;
mod separation_all;
+2 -2
View File
@@ -758,8 +758,8 @@ milestone = "M2"
status = "partial"
commands = []
tools = []
evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text"]
notes = "via hayro bootstrap. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones"
evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text", "crates/render/tests/it/luminosity_group_space.rs::black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully"]
notes = "via hayro bootstrap. A luminosity soft mask whose group is in DeviceCMYK reads its flat CMYK colours and backdrop as their gray, 1 - min(1, 0.3 c + 0.59 m + 0.11 y + k) (ISO 32000-2 §11.5.3), so 100% K masks fully; CMYK images, shadings and tiling patterns inside such a mask still go through the display profile. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones"
[[feature]]
id = "view.render-knockout"
+1 -1
View File
File diff suppressed because one or more lines are too long
+52
View File
@@ -391,6 +391,13 @@ impl ColorSpace {
matches!(self.0.as_ref(), ColorSpaceType::DeviceGray)
}
// PdfCraft patch (63).
/// Whether this is `/DeviceCMYK` itself, not an ICC-based or default colour space that
/// stands in for it.
pub(crate) fn is_device_cmyk(&self) -> bool {
matches!(self.0.as_ref(), ColorSpaceType::DeviceCmyk)
}
/// Get the number of components of the color space.
pub(crate) fn num_components(&self) -> u8 {
match self.0.as_ref() {
@@ -1342,6 +1349,18 @@ fn f32_to_u8(val: f32) -> u8 {
(val * 255.0 + 0.5) as u8
}
// PdfCraft patch (63).
/// The `/DeviceCMYK` to `/DeviceGray` conversion of ISO 32000-2 §10.4.2.3,
/// `gray = 1.0 - min(1.0, 0.3 c + 0.59 m + 0.11 y + k)`, which §11.5.3 also gives for the
/// luminosity of a soft mask whose group is in `/DeviceCMYK`. A missing component counts as
/// zero, and each component is clamped to `[0, 1]` first (`NaN` and infinities read as zero).
pub(crate) fn device_cmyk_to_gray(cmyk: &[f32]) -> f32 {
let component = |i: usize| clamp_component(cmyk.get(i).copied().unwrap_or(0.0), 0.0, 1.0);
let ink = 0.3 * component(0) + 0.59 * component(1) + 0.11 * component(2) + component(3);
// Every component is in [0, 1], so `ink` is not negative and the result is in [0, 1].
1.0 - ink.min(1.0)
}
/// Clamp an input-derived colour component to `[lo, hi]` without panicking on
/// NaN or on a reversed range. Non-finite values map to zero.
fn clamp_component(value: f32, lo: f32, hi: f32) -> f32 {
@@ -1450,3 +1469,36 @@ pub(crate) trait ToRgb {
))
}
}
#[cfg(test)]
mod device_cmyk_gray_tests {
use super::device_cmyk_to_gray;
fn assert_close(cmyk: &[f32], expected: f32) {
let got = device_cmyk_to_gray(cmyk);
assert!(
(got - expected).abs() < 1e-5,
"{cmyk:?}: gray {got}, expected {expected}"
);
}
#[test]
fn each_ink_darkens_by_its_iso_weight() {
assert_close(&[0.0, 0.0, 0.0, 0.0], 1.0);
assert_close(&[0.0, 0.0, 0.0, 1.0], 0.0);
assert_close(&[0.0, 0.0, 0.0, 0.5], 0.5);
assert_close(&[1.0, 0.0, 0.0, 0.0], 0.7);
assert_close(&[0.0, 1.0, 0.0, 0.0], 0.41);
assert_close(&[0.0, 0.0, 1.0, 0.0], 0.89);
assert_close(&[0.5, 0.5, 0.5, 0.25], 0.25);
}
#[test]
fn ink_past_full_coverage_is_black_and_bad_components_read_as_zero() {
assert_close(&[1.0, 1.0, 1.0, 1.0], 0.0);
assert_close(&[0.0, 0.0, 0.0, 1.5], 0.0);
assert_close(&[2.0, -1.0, f32::NAN, f32::INFINITY], 0.7);
assert_close(&[], 1.0);
assert_close(&[0.0, 0.0], 1.0);
}
}
+6
View File
@@ -173,6 +173,11 @@ pub struct Context<'a> {
/// a limit was reached, the clip count to return to at its `Q`.
saved_bytes: usize,
skipped_saves: Vec<usize>,
// PdfCraft patch (63).
/// This is the content of a luminosity soft mask whose group colour space is `/DeviceCMYK`,
/// so a `/DeviceCMYK` fill or stroke colour paints as its gray (ISO 32000-2 §11.5.3,
/// §10.4.2.3) rather than through the CMYK profile used for display.
pub(crate) luminosity_in_device_cmyk: bool,
}
/// PdfCraft patch: most graphics states saved at once. Every `q` copies the current state, dash
@@ -251,6 +256,7 @@ impl<'a> Context<'a> {
nesting_depth,
saved_bytes: 0,
skipped_saves: Vec::new(),
luminosity_in_device_cmyk: false,
}
}
+8 -2
View File
@@ -1,4 +1,4 @@
use crate::color::{Color, ColorSpace};
use crate::color::{Color, ColorSpace, device_cmyk_to_gray};
use crate::context::{Context, path_as_rect};
use crate::device::Device;
use crate::util::{BezPathExt, Float32Ext};
@@ -169,7 +169,13 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a>
Paint::Color(Color::new(ColorSpace::device_gray(), smallvec![0.0], 0.0))
}
} else {
let color = Color::new(data.color_space, data.color, data.alpha);
// PdfCraft patch (63): see `Context::luminosity_in_device_cmyk`.
let color = if context.luminosity_in_device_cmyk && data.color_space.is_device_cmyk() {
let gray = device_cmyk_to_gray(&data.color);
Color::new(ColorSpace::device_gray(), smallvec![gray], data.alpha)
} else {
Color::new(data.color_space, data.color, data.alpha)
};
if let Some(tf) = &data.transfer_function {
Paint::Color(Color::from_rgba(tf.apply(&color.to_rgba())))
+28 -4
View File
@@ -1,4 +1,4 @@
use crate::color::{Color, ColorComponents, ColorSpace};
use crate::color::{Color, ColorComponents, ColorSpace, DeviceFamily, device_cmyk_to_gray};
use crate::context::{Context, InterpreterCache};
use crate::device::Device;
use crate::function::Function;
@@ -59,6 +59,9 @@ struct Repr<'a> {
transfer_function: Option<TransferFunction>,
settings: InterpreterSettings,
background: Color,
// PdfCraft patch (63).
/// A luminosity mask whose group colour space is `/DeviceCMYK`.
luminosity_in_device_cmyk: bool,
xref: &'a XRef,
nesting_depth: u32,
}
@@ -107,10 +110,17 @@ impl<'a> SoftMask<'a> {
// PdfCraft patch: the group's /CS is optional (ISO 32000-2 §11.6.5.2; Chrome/Skia omit
// it). Upstream returned None here, silently dropping the whole soft mask. Fall back to
// DeviceRGB, the blending space our renderer composites in.
let cs = group
let cs_object = group
.dict
.get::<Dict<'_>>(GROUP)
.and_then(|g| g.get::<Object<'_>>(CS))
.and_then(|g| g.get::<Object<'_>>(CS));
// PdfCraft patch (63): the group is composited in `/DeviceCMYK` itself. An ICC-based
// or calibrated group space keeps its own conversion.
let group_in_device_cmyk = cs_object
.as_ref()
.and_then(DeviceFamily::of_object)
.is_some_and(|family| family == DeviceFamily::Cmyk);
let cs = cs_object
.and_then(|o| ColorSpace::new(o, &context.interpreter_cache.object_cache))
.unwrap_or_else(ColorSpace::device_rgb);
let transfer_function = dict
@@ -121,7 +131,19 @@ impl<'a> SoftMask<'a> {
LUMINOSITY => {
let color = dict
.get::<ColorComponents>(BC)
.map(|c| Color::new(cs, c, 1.0))
.map(|c| {
// PdfCraft patch (63): a DeviceCMYK backdrop's luminosity is its gray
// (ISO 32000-2 §11.5.3), as for the group's content.
if group_in_device_cmyk && c.len() == 4 {
Color::new(
ColorSpace::device_gray(),
smallvec![device_cmyk_to_gray(&c)],
1.0,
)
} else {
Color::new(cs, c, 1.0)
}
})
.unwrap_or(Color::new(ColorSpace::device_gray(), smallvec![0.0], 1.0));
(MaskType::Luminosity, color)
@@ -157,6 +179,7 @@ impl<'a> SoftMask<'a> {
settings: context.settings.clone(),
xref: context.xref,
background,
luminosity_in_device_cmyk: group_in_device_cmyk && mask_type == MaskType::Luminosity,
parent_resources,
nesting_depth,
})))
@@ -174,6 +197,7 @@ impl<'a> SoftMask<'a> {
state,
self.0.nesting_depth,
);
ctx.luminosity_in_device_cmyk = self.0.luminosity_in_device_cmyk;
draw_form_xobject(&self.0.parent_resources, &self.0.group, &mut ctx, device);
}