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

crates/render/src/raster.rs: both new tests (#2975 stencil masks, #2971 knockout groups)
vendor/hayro/src/renderer.rs: set_paint returns main's PaintSetup; the PR's knockout erase early return wraps its clip in PaintSetup::Clip
vendor/README.md: main's rows plus the PR's hayro-interpret patch 39 and hayro patch numbered 22 (19 is #3296's), with tests
This commit is contained in:
aiongg
2026-10-22 01:49:59 +00:00
5 changed files with 410 additions and 34 deletions
+146
View File
@@ -6338,6 +6338,152 @@ 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, 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_as = |group: &str, form: &str, preview: bool| {
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 /Neu 8 0 R /Img 9 0 R /Sm 10 0 R /Nimg 12 0 R /Deep 13 0 R /Shg 16 0 R /Big 17 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 34 >> 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 /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
15 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> /Extend [true true] /BBox [100 50 140 170] >> >> endobj
16 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /Pattern << /P 15 0 R >> >> /Length 38 >> stream
/Pattern cs /P scn 100 50 150 120 re f
endstream endobj
17 0 obj << /Type /XObject /Subtype /Image /Width 20000 /Height 20000 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream
0000FF>
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,
preview,
});
assert!(p.error.is_none(), "{group} {form}: {:?}", p.error);
p
};
let render = |group: &str, form: &str| render_as(group, form, false);
// 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.
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");
// Where the stencil paints (every sample 0), its blue is drawn (review on #2974: the check
// above alone passed even if the stencil drew nothing). A stencil composites as it did
// before rather than knocking the red out, and its exact colour also depends on #2975 (a
// stencil's fill opacity applied twice), so only "blue was drawn here" is pinned.
let under = px(&stencil, 130, 100);
assert!(under != [255, 128, 128] && under[2] >= 150 && under[0] < 230, "stencil drew nothing: {under:?}");
// A shading's /BBox clips it (ISO 32000-2 Table 77), so a shading fill's shape is its path
// inside the box: the erase pass must clear only that, or the red outside the box but
// inside the path stays erased (review on #2974).
let shaded = render(ko, &format!("{red}/Shg Do"));
check(&shaded, 120, 100, [128, 128, 255], "the shading inside its box");
check(&shaded, 155, 100, [255, 128, 128], "red outside the shading's box");
// An image that isn't decoded (here over the renderer's pixel limit) draws nothing, so it
// must not clear what is under its rectangle either (review on #2974).
let undecoded = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Big Do Q"));
check(&undecoded, 100, 100, [255, 128, 128], "red under an image that isn't drawn");
// Nor one a preview skips for covering only a few device pixels (review on #2974): the
// red under a 3 x 3 image stays in a preview, and a final render knocks it out.
let tiny = format!("{red}q 3 0 0 3 100 100 cm /Img Do Q");
check(&render_as(ko, &tiny, true), 101, 101, [255, 128, 128], "red under a preview's skipped image");
check(&render_as(ko, &tiny, false), 101, 101, [128, 128, 255], "the tiny image in a final render");
}
/// 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
+34
View File
@@ -73,6 +73,40 @@ 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) {}
/// 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 `knockout_shape` would clear anything now, that is whether
/// the innermost transparency group is a knockout group. Lets the interpreter skip work it
/// only needs for the clear.
fn in_knockout_group(&mut self) -> bool {
false
}
/// PdfCraft patch (39): `image`, an unmasked raster image, is about to be drawn with
/// `transform` in a knockout group. A device that will draw it (it isn't skipped and it
/// decodes) clears what the group drew under its placement rectangle first, so the image
/// replaces it. Decided by the device, at the size it draws the image, so the draw reuses
/// that decode.
fn knockout_image(&mut self, _image: &crate::RasterImage<'_>, _transform: Affine) {}
/// 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.
+47 -1
View File
@@ -162,7 +162,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(
@@ -173,6 +204,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());
@@ -254,6 +289,17 @@ 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). Only an image the device draws clears: one
// that doesn't decode (over `MAX_IMAGE_PIXELS`, damaged data) or that it skips (a preview's
// tiny image) replaces nothing. The device decides, at its own draw size (review on #2974:
// a full-size decode here cost a second decode of every downscaled image).
if !x_object.is_mask && !has_alpha && device.in_knockout_group() {
device.knockout_image(&RasterImage(x_object.clone()), transform);
}
device.push_transparency_group(
context.get().graphics_state.non_stroke_alpha,
std::mem::take(&mut soft_mask),
+180 -30
View File
@@ -444,6 +444,13 @@ 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>,
/// 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
@@ -467,6 +474,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,
@@ -479,6 +491,9 @@ impl Renderer {
ctx: RenderContext::new_with(width, height, settings),
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(),
@@ -580,6 +595,9 @@ impl Renderer {
// original values, so it is never remapped by the reading-colours setting.
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(),
@@ -744,6 +762,40 @@ impl Renderer {
}
}
/// The size an image of `width` x `height` samples is decoded at to be drawn with
/// `transform`, or `None` if `draw_image` skips it: off the canvas (PdfCraft patch: see
/// `may_paint_rect`; checked before decoding, so a tile no longer decodes every image on its
/// page), or, in a preview, covering only a few device pixels (PdfCraft patch; final renders
/// always draw them). The tiny-image test measures axis lengths: the size returned maps a
/// point, so it includes the translation and only suits the decode hint.
fn image_draw_size(&self, width: u32, height: u32, transform: Affine) -> Option<(u32, u32)> {
if !may_paint_rect(
Rect::new(0.0, 0.0, f64::from(width), f64::from(height)),
transform,
self.ctx.width(),
self.ctx.height(),
) {
return None;
}
let target_width = (transform * Point::new(width as f64, 0.0))
.to_vec2()
.length()
.ceil() as u32;
let target_height = (transform * Point::new(0.0, height as f64))
.to_vec2()
.length()
.ceil() as u32;
if self.preview {
let c = transform.as_coeffs();
let tiny_w = (f64::from(width) * (c[0] * c[0] + c[1] * c[1]).sqrt()).ceil() as u32;
let tiny_h = (f64::from(height) * (c[2] * c[2] + c[3] * c[3]).sqrt()).ceil() as u32;
if tiny_w.saturating_mul(tiny_h) < PREVIEW_MIN_IMAGE_AREA {
return None;
}
}
Some((target_width, target_height))
}
fn draw_image(&mut self, image_data: ImageData, alpha_data: Option<LumaData>) {
let cur_transform = *self.ctx.transform();
let (x_scale, y_scale) = {
@@ -1049,6 +1101,20 @@ impl Renderer {
#[must_use]
fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> PaintSetup {
// 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]));
// A shading's `/BBox` clips what it paints, so it clips what it clears too.
return PaintSetup::Clip(match paint {
Paint::Pattern(p) => match p.as_ref() {
Pattern::Shading(s) => s.shading.clip_path.clone(),
_ => None,
},
_ => None,
});
}
let mut paint_transform = Affine::IDENTITY;
let mut clip_path = None;
// PdfCraft patch (19): `Extend::Repeat` tiles the texture over the path; a mesh texture
@@ -1197,6 +1263,9 @@ 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(),
@@ -1251,6 +1320,9 @@ impl Renderer {
// content is never remapped by the reading-colours setting.
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(),
@@ -1444,40 +1516,19 @@ impl Renderer {
impl<'a> Device<'a> for Renderer {
fn draw_image(&mut self, image: hayro_interpret::Image<'a, '_>, mut transform: Affine) {
// 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(
Rect::new(0.0, 0.0, f64::from(image.width()), f64::from(image.height())),
transform,
self.ctx.width(),
self.ctx.height(),
) {
// 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;
}
let Some((target_width, target_height)) =
self.image_draw_size(image.width(), image.height(), transform)
else {
return;
};
self.ctx.set_paint_transform(Affine::IDENTITY);
self.ctx.set_aliasing_threshold(Some(1));
let target_width = (transform * Point::new(image.width() as f64, 0.0))
.to_vec2()
.length()
.ceil() as u32;
let target_height = (transform * Point::new(0.0, image.height() as f64))
.to_vec2()
.length()
.ceil() as u32;
// PdfCraft patch: previews skip images that would cover only a few device pixels.
// Final renders always draw them. (Measured as axis lengths: the `target_*` above
// map a point, so they include the translation and only suit the decode hint.)
if self.preview {
let c = transform.as_coeffs();
let tiny_w = (f64::from(image.width()) * (c[0] * c[0] + c[1] * c[1]).sqrt()).ceil() as u32;
let tiny_h = (f64::from(image.height()) * (c[2] * c[2] + c[3] * c[3]).sqrt()).ceil() as u32;
if tiny_w.saturating_mul(tiny_h) < PREVIEW_MIN_IMAGE_AREA {
return;
}
}
match image {
hayro_interpret::Image::Stencil(s) => {
s.with_stencil(
@@ -1567,6 +1618,9 @@ impl<'a> Device<'a> for Renderer {
// the reading-colours setting.
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(),
@@ -1644,6 +1698,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
@@ -1670,6 +1729,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) {
@@ -1677,10 +1737,86 @@ 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();
}
/// 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 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;
self.ctx.set_blend_mode(knockout_blend_mode());
}
}
/// 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 in_knockout_group(&mut self) -> bool {
self.in_knockout()
}
fn knockout_image(&mut self, image: &hayro_interpret::RasterImage<'_>, transform: Affine) {
if !self.in_knockout() {
return;
}
// Only an image `draw_image` will draw, decoded at the size it draws it (the same
// decode, so a cached decode serves both).
let Some(size) = self.image_draw_size(image.width(), image.height(), transform) else {
return;
};
let mut decodes = false;
image.with_rgba(|_, _| decodes = true, Some(size));
if decodes {
let rect = Rect::new(0.0, 0.0, f64::from(image.width()), f64::from(image.height()));
self.knockout_shape(&(transform * rect.to_path(0.1)));
}
}
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| {
@@ -1784,7 +1920,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);
}
}
@@ -1868,6 +2010,9 @@ 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(),
erasing: false,
erase_groups: 0,
inside_pattern: false,
cur_mask: None,
soft_mask_cache: HashMap::default(),
@@ -1962,6 +2107,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,