fix(render): an isolated CMYK group inside a knockout element is erased, not painted

main's knockout patch (hayro (22)) draws each element of a knockout group
once in erase mode, to clear what the group drew under the element's shape.
An isolated DeviceCMYK group nested in such an element still took the CMYK
path in that pass, and drew its finished pixels with Normal blending over
what should have been cleared. draw_isolated_cmyk_group now declines while
erasing, so the group's contents are interpreted in erase mode as before.

The new test fails without the fix (the centre shows red through the
magenta: [240, 63, 168] against [245, 127, 197] for the DeviceRGB control).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76
2026-10-28 18:20:29 -04:00
co-authored by Claude Opus 5.5
parent 41277846a3
commit 6fcfb3f98d
3 changed files with 50 additions and 3 deletions
@@ -168,3 +168,49 @@ endstream endobj
let non_isolated_rgb = render(Some("/CS /DeviceRGB /I false /K false"), 1.0);
assert!(distance(&px(&non_isolated_rgb, 110.0, 110.0, 1.0), &green) <= 3, "a non-isolated nested group inherits CMYK");
}
/// An element of a knockout group is first drawn in erase mode, which clears what the group
/// drew under the element's shape. An isolated `DeviceCMYK` group nested in that element must
/// clear too: when the erase pass drew it through the CMYK path, it painted over the red
/// beneath instead of clearing it. The page must match the same element with its nested group
/// blending in `DeviceRGB`, which never takes the CMYK path.
#[test]
fn an_isolated_cmyk_group_inside_a_knockout_element_is_erased_like_any_other() {
let render = |inner_cs: &str| {
let knockout = "1 0 0 rg 20 20 160 160 re f /El Do";
let element = "/Cm Do";
let inner = "/A gs 0 1 0 0 k 60 60 80 80 re f";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Ko 5 0 R >> >> >> endobj
4 0 obj << /Length 7 >> stream
/Ko Do
endstream endobj
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /I true /K true >> /Resources << /XObject << /El 6 0 R >> >> /Length {} >> stream
{knockout}
endstream endobj
6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >> /Resources << /XObject << /Cm 7 0 R >> >> /Length {} >> stream
{element}
endstream endobj
7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS {inner_cs} /I true >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length {} >> stream
{inner}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
knockout.len(),
element.len(),
inner.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{inner_cs}: {:?}", p.error);
// The centre of the inner square, (100, 100), is also the centre of the page.
p.rgba[((100 * p.width + 100) * 4) as usize..][..3].to_vec()
};
let rgb = render("/DeviceRGB");
let cmyk = render("/DeviceCMYK");
assert!(rgb[2] > 150, "the RGB control shows half-opacity magenta over white, not red: {rgb:?}");
assert!(rgb.iter().zip(&cmyk).all(|(a, b)| a.abs_diff(*b) <= 3), "CMYK {cmyk:?}, RGB {rgb:?}");
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1832,9 +1832,10 @@ impl<'a> Device<'a> for Renderer {
/// result goes into the layer the interpreter pushed for the group, which composites it
/// with the group's opacity, soft mask and blend mode as before. Declined (and so drawn as
/// before) under reading colours, inside a pass (a nested group then blends in the pass's
/// own CMYK), and for contents a pass has no tints for.
/// own CMYK), while erasing for a knockout group (the contents must clear, not paint), and
/// for contents a pass has no tints for.
fn draw_isolated_cmyk_group(&mut self, group: &hayro_interpret::CmykGroup<'a, '_>) -> bool {
if self.color_replacement.is_some() || self.ink.pass.is_some() {
if self.color_replacement.is_some() || self.ink.pass.is_some() || self.erasing {
return false;
}
let canvas = Rect::new(0.0, 0.0, f64::from(self.ctx.width()), f64::from(self.ctx.height()));