fix(render): images and nested groups knock out in a knockout group too (#2971)

An image or a nested group is drawn into a layer of its own, which
covers the whole canvas (transparent where nothing was drawn), so it
can't be composited with Compose::Copy. What is under its shape is
now cleared first and the layer then composites over it:

- an unmasked image's shape is its placement rectangle: the
  interpreter hands it to the device (`Device::knockout_shape`), which
  clears it with a transparent Copy fill;
- a nested group's shape is the union of its elements' shapes: the
  interpreter draws its content once in the device's erase mode
  (`knockout_erase_wanted`, `begin_knockout_erase`,
  `end_knockout_erase`), where every paint clears under its shape and
  nothing is layered or drawn as an image, and then draws the group,
  always in a layer of its own (drawn straight onto the knockout
  group, its own elements knocked each other out).

A masked image's shape is its mask; it composites as before, which
matches MuPDF where the mask is opaque or transparent.

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:37:30 +08:00
co-authored by Claude Opus 5.5
parent d2bcda45fd
commit 2627384757
5 changed files with 210 additions and 34 deletions
+79 -28
View File
@@ -6039,11 +6039,12 @@ trailer << /Root 1 0 R >>
}
/// #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.
/// group's initial backdrop, not with the elements drawn before it in the group, over the
/// element's shape. A blue square at 50 % opacity over a red one at 50 % shows only the blue
/// where they overlap; without `/K` the two blend. The same holds for an image (its shape
/// is its rectangle; a soft-masked image's shape is its mask, so its transparent part leaves
/// what is under it) and for a nested group (its shape is the union of its elements'
/// shapes, while its own elements still blend with each other).
#[test]
fn knockout_groups_replace_what_their_elements_cover() {
let render = |group: &str, form: &str| {
@@ -6055,14 +6056,35 @@ trailer << /Root 1 0 R >>
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
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 /Neu 8 0 R /Img 9 0 R /Sm 10 0 R /Nimg 12 0 R /Deep 13 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
7 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >> stream
0000>
endstream endobj
8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> >> /Length 62 >> stream
/H gs 0 1 0 rg 80 40 100 100 re f 0 0 1 rg 130 80 100 100 re f
endstream endobj
9 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream
0000FF>
endstream endobj
10 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R /Filter /ASCIIHexDecode /Length 13 >> stream
0000FF0000FF>
endstream endobj
11 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 5 >> stream
00FF>
endstream endobj
12 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /Img 9 0 R >> >> /Length 38 >> stream
/H gs q 160 0 0 100 60 50 cm /Img Do Q
endstream endobj
13 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /In3 14 0 R >> >> /Length 13 >> stream
/H gs /In3 Do
endstream endobj
14 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Length 28 >> stream
0 0 1 rg 100 50 150 120 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
@@ -6079,27 +6101,56 @@ trailer << /Root 1 0 R >>
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));
// Pixel rows run top down: PDF (x, y) is pixel (x, 200 - y).
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((200 - y) * p.width + x) as usize * 4..][..3].to_vec();
let check = |p: &RenderedPage, x: u32, y: u32, want: [u8; 3], what: &str| {
let got = px(p, x, y);
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{what}: {got:?}, expected {want:?}");
};
let red = "/A gs 1 0 0 rg 20 20 150 150 re f ";
let ko = "/I true /K true";
let squares = render(ko, &format!("{red}0 0 1 rg 100 50 150 120 re f"));
check(&squares, 130, 100, [128, 128, 255], "knocked out");
check(&squares, 50, 40, [255, 128, 128], "red alone");
check(&squares, 210, 100, [128, 128, 255], "blue alone");
check(&squares, 280, 190, [255, 255, 255], "paper");
// 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));
check(&squares, 30, 30, [128, 191, 64], "after the group");
let plain = render("/I true", &format!("{red}0 0 1 rg 100 50 150 120 re f"));
check(&plain, 130, 100, [128, 64, 191], "blended without /K");
// An image at 50 % replaces the red under its rectangle.
let image = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Img Do Q"));
check(&image, 100, 100, [128, 128, 255], "image knocks out");
check(&image, 40, 40, [255, 128, 128], "red beside the image");
// A soft-masked image: its transparent left half leaves the red, its opaque half replaces it.
let masked = render(ko, "1 0 0 rg 20 20 150 150 re f q 160 0 0 100 60 50 cm /Sm Do Q");
check(&masked, 100, 100, [255, 0, 0], "under the transparent half");
check(&masked, 155, 100, [0, 0, 255], "under the opaque half");
// A nested group entered at 50 % (blue at 25 % in all) replaces the red under its shape.
let nested = render(ko, &format!("{red}/In Do"));
check(&nested, 130, 100, [191, 191, 255], "nested group knocks out");
check(&nested, 50, 40, [255, 128, 128], "red beside the nested group");
// A nested group at full opacity: it knocks out the red as a whole, and its own two
// squares at 50 % still blend with each other where they overlap.
let neutral = render(ko, "1 0 0 rg 20 20 150 150 re f /Neu Do");
check(&neutral, 100, 60, [128, 255, 128], "green over the knocked-out red");
check(&neutral, 150, 110, [64, 128, 191], "the nested squares blend");
check(&neutral, 40, 40, [255, 0, 0], "red beside the nested group");
// An image inside a nested group is drawn once (blue at 50 %), over the knocked-out red.
let nested_image = render(ko, "1 0 0 rg 20 20 150 150 re f /Nimg Do");
check(&nested_image, 100, 100, [128, 128, 255], "image in a nested group");
// A group inside a nested group knocks out the red as well.
let deep = render(ko, "1 0 0 rg 20 20 150 150 re f /Deep Do");
check(&deep, 130, 100, [128, 128, 255], "group in a nested group");
check(&deep, 40, 40, [255, 0, 0], "red beside it");
// A stencil mask must not erase the red beside it.
let stencil = render(ko, &format!("{red}0 0 1 rg q 150 0 0 120 100 50 cm /Msk Do Q"));
check(&stencil, 50, 40, [255, 128, 128], "red beside a stencil");
}
/// Reading colours replace *document* colours, never annotation colours (issue #1548): a
+3 -3
View File
File diff suppressed because one or more lines are too long
+17
View File
@@ -78,6 +78,23 @@ pub trait Device<'a> {
/// elements drawn before it in the group (ISO 32000-2, knockout groups), until the
/// matching `pop_transparency_group`.
fn set_knockout(&mut self) {}
/// PdfCraft patch (39): an element whose shape is `shape` (device space) is about to be
/// drawn into a transparency group of its own. In a knockout group (`set_knockout`) the
/// element replaces what the group drew under its shape, so a device clears that first;
/// the element's layer then composites over the cleared area as usual.
fn knockout_shape(&mut self, _shape: &BezPath) {}
/// PdfCraft patch (39): whether a transparency group drawn now is an element of a knockout
/// group that the device wants cleared under the group's shape first. If so the
/// interpreter draws the group's content once between `begin_knockout_erase` and
/// `end_knockout_erase`, in which every paint clears what is under its shape instead of
/// painting, and then draws the group as usual, in a layer of its own.
fn knockout_erase_wanted(&mut self) -> bool {
false
}
/// PdfCraft patch (39): see `knockout_erase_wanted`.
fn begin_knockout_erase(&mut self) {}
/// PdfCraft patch (39): see `knockout_erase_wanted`.
fn end_knockout_erase(&mut self) {}
}
/// A device that discards all drawing operations.
+40 -1
View File
@@ -159,7 +159,38 @@ pub(crate) fn draw_form_xobject<'a, 'b>(
&& state.soft_mask.is_none()
&& state.blend_mode == BlendMode::Normal
};
let own_layer = x_object.is_transparency_group && !neutral;
// PdfCraft patch (39): a group drawn as an element of a knockout group replaces what that
// group drew under its shape, the union of its own elements' shapes (#2971). The shape is
// cleared by drawing the group's content once in the device's erase mode; the group then
// composites over the cleared area in a layer of its own (also when it would otherwise be
// drawn straight onto its parent, where its elements would knock each other out).
let knockout_element = x_object.is_transparency_group && device.knockout_erase_wanted();
if knockout_element {
context.save_state();
device.begin_knockout_erase();
device.push_clip_path(&ClipPath {
path: context.get().ctm
* Rect::new(
x_object.bbox[0] as f64,
x_object.bbox[1] as f64,
x_object.bbox[2] as f64,
x_object.bbox[3] as f64,
)
.to_path(0.1),
fill: FillRule::NonZero,
});
interpret(
TypedIter::new(x_object.decoded.as_ref()),
&Resources::from_parent(x_object.resources.clone(), resources.clone()),
context,
device,
);
device.pop_clip_path();
device.end_knockout_erase();
context.restore_state(device);
context.path_mut().truncate(0);
}
let own_layer = x_object.is_transparency_group && (!neutral || knockout_element);
if own_layer {
device.push_transparency_group(
@@ -255,6 +286,14 @@ pub(crate) fn draw_image_xobject<'a, 'b>(
soft_mask = None;
}
// PdfCraft patch (39): an image without a mask covers its whole placement rectangle, so in
// a knockout group it replaces what the group drew there (#2971). A stencil's or a masked
// image's shape is its mask; those composite as before (exact where the mask is opaque or
// transparent, which is what masks mostly are).
if !x_object.is_mask && !has_alpha {
device.knockout_shape(&(transform * Rect::new(0.0, 0.0, width, height).to_path(0.1)));
}
device.push_transparency_group(
context.get().graphics_state.non_stroke_alpha,
std::mem::take(&mut soft_mask),
+71 -2
View File
@@ -438,6 +438,10 @@ pub(crate) struct Renderer {
/// PdfCraft patch (19): one entry per transparency group pushed, whether it is a knockout
/// group (`Device::set_knockout`).
knockout: Vec<bool>,
/// PdfCraft patch (19): inside a knockout erase pass (`Device::begin_knockout_erase`), and
/// how many transparency groups were skipped in it (nothing is layered while erasing).
erasing: bool,
erase_groups: usize,
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
@@ -479,6 +483,8 @@ impl Renderer {
color_replacement,
in_annotations: false,
knockout: Vec::new(),
erasing: false,
erase_groups: 0,
inside_pattern: false,
soft_mask_cache: HashMap::default(),
outline_cache: cache.outline_cache.clone(),
@@ -581,6 +587,8 @@ impl Renderer {
color_replacement: None,
in_annotations: false,
knockout: Vec::new(),
erasing: false,
erase_groups: 0,
inside_pattern: false,
soft_mask_cache: HashMap::default(),
outline_cache: self.outline_cache.clone(),
@@ -1050,6 +1058,13 @@ impl Renderer {
#[must_use]
fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> Option<BezPath> {
// PdfCraft patch (19): in a knockout erase pass every paint clears what is under its
// shape (a transparent fill with `Compose::Copy`, weighted by coverage).
if self.erasing {
self.ctx.set_blend_mode(knockout_blend_mode());
self.ctx.set_paint(AlphaColor::<Srgb>::new([0.0, 0.0, 0.0, 0.0]));
return None;
}
let mut paint_transform = Affine::IDENTITY;
let mut clip_path = None;
@@ -1228,6 +1243,8 @@ impl Renderer {
color_replacement: None,
in_annotations: false,
knockout: Vec::new(),
erasing: false,
erase_groups: 0,
cur_mask: None,
inside_pattern: true,
soft_mask_cache: HashMap::default(),
@@ -1421,6 +1438,11 @@ impl Renderer {
impl<'a> Device<'a> for Renderer {
fn draw_image(&mut self, image: hayro_interpret::Image<'a, '_>, mut transform: Affine) {
// PdfCraft patch (19): an erase pass paints nothing; an unmasked image's rectangle was
// cleared by `knockout_shape`, and a masked image's shape is left alone.
if self.erasing {
return;
}
// PdfCraft patch: see `may_paint_rect`. Checked before decoding: off-canvas images
// cost nothing, so a tile no longer decodes every image on its page.
if !may_paint_rect(
@@ -1545,6 +1567,8 @@ impl<'a> Device<'a> for Renderer {
color_replacement: None,
in_annotations: false,
knockout: Vec::new(),
erasing: false,
erase_groups: 0,
inside_pattern: false,
soft_mask_cache: HashMap::default(),
outline_cache: self.outline_cache.clone(),
@@ -1617,6 +1641,11 @@ impl<'a> Device<'a> for Renderer {
mask: Option<SoftMask<'_>>,
blend_mode: BlendMode,
) {
// PdfCraft patch (19): nothing is layered in a knockout erase pass.
if self.erasing {
self.erase_groups += 1;
return;
}
let settings = *self.ctx.render_settings();
let force_images_may_have_transparency = mask.is_some()
|| blend_mode != BlendMode::Normal
@@ -1651,6 +1680,10 @@ impl<'a> Device<'a> for Renderer {
}
fn pop_transparency_group(&mut self) {
if self.erasing && self.erase_groups > 0 {
self.erase_groups -= 1;
return;
}
self.image_transparency_stack.pop();
self.knockout.pop();
self.ctx.pop_layer();
@@ -1663,8 +1696,9 @@ impl<'a> Device<'a> for Renderer {
/// 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).
/// nested groups are drawn into a layer of their own, which can't be copied (it covers the
/// whole canvas, transparent where nothing was drawn): what is under their shape is cleared
/// first (`knockout_shape`, the knockout erase pass) and the layer composites over it.
fn set_knockout(&mut self) {
if let Some(top) = self.knockout.last_mut() {
*top = true;
@@ -1672,6 +1706,39 @@ impl<'a> Device<'a> for Renderer {
}
}
/// PdfCraft patch (19): in a knockout group, clear what the group drew under `shape` (a
/// transparent fill with `Compose::Copy`, weighted by coverage) before an element drawn into
/// a layer of its own composites there, so the element replaces it.
fn knockout_erase_wanted(&mut self) -> bool {
self.in_knockout() && !self.erasing
}
fn begin_knockout_erase(&mut self) {
self.erasing = true;
self.erase_groups = 0;
}
fn end_knockout_erase(&mut self) {
self.erasing = false;
self.erase_groups = 0;
self.ctx.set_blend_mode(knockout_blend_mode());
}
fn knockout_shape(&mut self, shape: &BezPath) {
if !self.in_knockout() {
return;
}
let old_transform = *self.ctx.transform();
let old_rule = *self.ctx.fill_rule();
self.ctx.set_transform(Affine::IDENTITY);
self.ctx.set_fill_rule(Fill::NonZero);
self.ctx.set_blend_mode(knockout_blend_mode());
self.ctx.set_paint(AlphaColor::<Srgb>::new([0.0, 0.0, 0.0, 0.0]));
self.ctx.fill_path(shape);
self.ctx.set_fill_rule(old_rule);
self.ctx.set_transform(old_transform);
}
fn set_soft_mask(&mut self, mask: Option<SoftMask<'_>>) {
let settings = *self.ctx.render_settings();
self.cur_mask = mask.map(|m| {
@@ -1866,6 +1933,8 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei
color_replacement: None,
in_annotations: false,
knockout: Vec::new(),
erasing: false,
erase_groups: 0,
inside_pattern: false,
cur_mask: None,
soft_mask_cache: HashMap::default(),