fix(render): knockout transparency groups knock out (#2971)

In a knockout group (/K true) each element composites with the group's
initial backdrop, not with the elements drawn before it (ISO 32000-2,
knockout groups). The renderer read /K (hayro-interpret patch 28) but
drew such a group like any other, so overlapping semi-transparent
shapes in it blended with each other.

hayro-interpret patch (39) tells the device a group is a knockout
group (`Device::set_knockout`, default no-op). hayro patch (19) then
draws that group's fills, strokes, glyph outlines and shadings with
Compose::Copy, which vello_cpu weights by coverage, so each replaces
what the group drew under its shape. Images and nested groups are
drawn into a group layer of their own and still composite normally:
copying a whole layer would erase everything outside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
This commit is contained in:
mtlcl410
2026-10-19 15:36:46 +08:00
co-authored by Claude Opus 5.5
parent 43550c448d
commit d2bcda45fd
5 changed files with 119 additions and 4 deletions
+64
View File
@@ -6038,6 +6038,70 @@ trailer << /Root 1 0 R >>
assert_eq!(pixel(&on, 150, 55), &[255, 255, 255, 255], "the image's white quadrant stays white");
}
/// #2971: in a knockout transparency group (`/K true`) each element composites with the
/// group's initial backdrop, not with the elements drawn before it in the group. A blue
/// square at 50 % opacity over a red one at 50 % shows only the blue where they overlap;
/// without `/K` the two blend. An element drawn through a layer of its own (here a nested
/// group, a stencil mask) doesn't knock out yet, but it must not erase what was drawn before it
/// either.
#[test]
fn knockout_groups_replace_what_their_elements_cover() {
let render = |group: &str, form: &str| {
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 300 200] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> /ExtGState << /G << /ca 0.5 >> >> >> >> endobj
4 0 obj << /Length 39 >> stream
/Fm1 Do /G gs 0 1 0 rg 10 10 30 30 re f
endstream endobj
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency {group} >> /Resources << /ExtGState << /A << /ca 0.5 >> >> /XObject << /In 6 0 R /Msk 7 0 R >> >> /Length {} >> stream
{form}
endstream endobj
6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length 30 >> stream
/A gs 0 0 1 rg 100 50 150 120 re f
endstream endobj
7 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Length 2 >> stream
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
form.len()
);
let p = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest {
page: 0,
kind: RequestKind::Pixels,
tile: None,
scale: 1.0,
tag: 0,
..Default::default()
});
assert!(p.error.is_none(), "{group} {form}: {:?}", p.error);
p
};
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec();
let near = |a: &[u8], b: [u8; 3]| a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 2);
let squares = "/A gs 1 0 0 rg 20 20 150 150 re f 0 0 1 rg 100 50 150 120 re f";
let nested = "/A gs 1 0 0 rg 20 20 150 150 re f /In Do";
// Pixel rows run top down: PDF (x, y) is (x, 200 - y).
let knockout = render("/I true /K true", squares);
assert!(near(&px(&knockout, 130, 100), [128, 128, 255]), "knocked out: {:?}", px(&knockout, 130, 100));
assert!(near(&px(&knockout, 50, 160), [255, 128, 128]), "red alone: {:?}", px(&knockout, 50, 160));
assert!(near(&px(&knockout, 210, 100), [128, 128, 255]), "blue alone: {:?}", px(&knockout, 210, 100));
assert!(near(&px(&knockout, 280, 10), [255, 255, 255]), "paper: {:?}", px(&knockout, 280, 10));
// Page content after the group composites normally again: green at 50 % over the red.
assert!(near(&px(&knockout, 30, 170), [128, 191, 64]), "after the group: {:?}", px(&knockout, 30, 170));
let plain = render("/I true", squares);
assert!(near(&px(&plain, 130, 100), [128, 64, 191]), "blended: {:?}", px(&plain, 130, 100));
// The nested group is drawn at the outer 50 % as well: blue at 25 %.
let nested = render("/I true /K true", nested);
assert!(near(&px(&nested, 50, 160), [255, 128, 128]), "red kept: {:?}", px(&nested, 50, 160));
assert!(near(&px(&nested, 210, 100), [191, 191, 255]), "nested blue: {:?}", px(&nested, 210, 100));
// So is a stencil mask, drawn through a layer of its own.
let stencil = render("/I true /K true", "/A gs 1 0 0 rg 20 20 150 150 re f 0 0 1 rg q 150 0 0 120 100 50 cm /Msk Do Q");
assert!(near(&px(&stencil, 50, 160), [255, 128, 128]), "red kept beside a stencil: {:?}", px(&stencil, 50, 160));
}
/// Reading colours replace *document* colours, never annotation colours (issue #1548): a
/// highlight keeps its `/C`, so on a dark background the content under it shows through the
/// Multiply blend in the highlight's colour instead of being darkened into invisibility.
+3 -3
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -73,6 +73,11 @@ pub trait Device<'a> {
fn begin_annotations(&mut self) {}
/// PdfCraft patch (29): the page's annotations are done; what follows is page content.
fn end_annotations(&mut self) {}
/// PdfCraft patch (39): the transparency group just pushed is a knockout group (`/K true`):
/// each of its elements composites with the group's initial backdrop, not with the
/// elements drawn before it in the group (ISO 32000-2, knockout groups), until the
/// matching `pop_transparency_group`.
fn set_knockout(&mut self) {}
}
/// A device that discards all drawing operations.
+4
View File
@@ -170,6 +170,10 @@ pub(crate) fn draw_form_xobject<'a, 'b>(
context.get_mut().graphics_state.non_stroke_alpha = 1.0;
context.get_mut().graphics_state.stroke_alpha = 1.0;
// PdfCraft patch (39): its elements knock each other out (#2971).
if x_object.is_knockout {
device.set_knockout();
}
}
device.set_soft_mask(context.get().graphics_state.soft_mask.clone());
+43 -1
View File
@@ -435,6 +435,9 @@ pub(crate) struct Renderer {
/// PdfCraft patch (17): while the page's annotations are being drawn, paints keep their
/// colours — reading colours replace document colours, never annotation colours.
in_annotations: bool,
/// PdfCraft patch (19): one entry per transparency group pushed, whether it is a knockout
/// group (`Device::set_knockout`).
knockout: Vec<bool>,
pub(crate) inside_pattern: bool,
/// Soft masks drawn during this render. PdfCraft note: it belongs to one `Renderer` (one
/// render, every nested renderer starting empty) and so to one `preview` setting: a mask
@@ -458,6 +461,11 @@ enum ImagePixelFormat {
}
impl Renderer {
/// PdfCraft patch (19): whether the innermost transparency group is a knockout group.
fn in_knockout(&self) -> bool {
self.knockout.last().copied().unwrap_or(false)
}
pub(crate) fn new(
width: u16,
height: u16,
@@ -470,6 +478,7 @@ impl Renderer {
ctx: RenderContext::new_with(width, height, settings),
color_replacement,
in_annotations: false,
knockout: Vec::new(),
inside_pattern: false,
soft_mask_cache: HashMap::default(),
outline_cache: cache.outline_cache.clone(),
@@ -571,6 +580,7 @@ impl Renderer {
// original values, so it is never remapped by the reading-colours setting.
color_replacement: None,
in_annotations: false,
knockout: Vec::new(),
inside_pattern: false,
soft_mask_cache: HashMap::default(),
outline_cache: self.outline_cache.clone(),
@@ -1217,6 +1227,7 @@ impl Renderer {
// content is never remapped by the reading-colours setting.
color_replacement: None,
in_annotations: false,
knockout: Vec::new(),
cur_mask: None,
inside_pattern: true,
soft_mask_cache: HashMap::default(),
@@ -1533,6 +1544,7 @@ impl<'a> Device<'a> for Renderer {
// the reading-colours setting.
color_replacement: None,
in_annotations: false,
knockout: Vec::new(),
inside_pattern: false,
soft_mask_cache: HashMap::default(),
outline_cache: self.outline_cache.clone(),
@@ -1631,6 +1643,7 @@ impl<'a> Device<'a> for Renderer {
);
self.image_transparency_stack
.push(force_images_may_have_transparency);
self.knockout.push(false);
}
fn pop_clip_path(&mut self) {
@@ -1639,9 +1652,26 @@ impl<'a> Device<'a> for Renderer {
fn pop_transparency_group(&mut self) {
self.image_transparency_stack.pop();
self.knockout.pop();
self.ctx.pop_layer();
}
/// PdfCraft patch (19): the group just pushed is a knockout group. Its elements are drawn
/// with `Compose::Copy`, which (weighted by coverage) replaces what earlier elements drew
/// where the element's shape is, so each element composites with the group's initial
/// backdrop (transparent: an isolated group, and the non-isolated approximation every
/// group layer here already makes) instead of with the elements before it. Against a
/// transparent backdrop the element's own blend mode has nothing to blend with, so the
/// mix is Normal. This covers paths, strokes, glyph outlines and shadings. Images and
/// nested groups are drawn into a group layer of their own, which composites as before:
/// they don't knock out yet (copying a whole layer would erase everything outside it).
fn set_knockout(&mut self) {
if let Some(top) = self.knockout.last_mut() {
*top = true;
self.ctx.set_blend_mode(knockout_blend_mode());
}
}
fn set_soft_mask(&mut self, mask: Option<SoftMask<'_>>) {
let settings = *self.ctx.render_settings();
self.cur_mask = mask.map(|m| {
@@ -1745,7 +1775,13 @@ impl<'a> Device<'a> for Renderer {
}
fn set_blend_mode(&mut self, blend_mode: BlendMode) {
self.ctx.set_blend_mode(convert_blend_mode(blend_mode));
// PdfCraft patch (19): inside a knockout group every element replaces what it covers.
let mode = if self.in_knockout() {
knockout_blend_mode()
} else {
convert_blend_mode(blend_mode)
};
self.ctx.set_blend_mode(mode);
}
}
@@ -1829,6 +1865,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei
// content is never remapped by the reading-colours setting.
color_replacement: None,
in_annotations: false,
knockout: Vec::new(),
inside_pattern: false,
cur_mask: None,
soft_mask_cache: HashMap::default(),
@@ -1923,6 +1960,11 @@ fn convert_fill_rule(fill_rule: FillRule) -> Fill {
}
}
/// PdfCraft patch (19): how an element of a knockout group is drawn (see `set_knockout`).
fn knockout_blend_mode() -> peniko::BlendMode {
peniko::BlendMode::new(Mix::Normal, Compose::Copy)
}
fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode {
let mix = match blend_mode {
BlendMode::Normal => Mix::Normal,