Merge PR #2977: fix(render): a stencil mask is drawn at the fill opacity once (#2975)

vendor/hayro/src/renderer.rs: main's PaintSetup::Clip match with the PR's fill paint (is_stroke false) and comment
vendor/README.md: main's row plus the PR's hayro-interpret patch and test, numbered 46 (35 is #3296's)
This commit is contained in:
aiongg
2026-10-22 01:48:34 +00:00
6 changed files with 87 additions and 4 deletions
+50
View File
@@ -6288,6 +6288,56 @@ trailer << /Root 1 0 R >>
assert_eq!(pixel(&on, 150, 55), &[255, 255, 255, 255], "the image's white quadrant stays white");
}
/// #2975: a stencil mask (`/ImageMask true`) is painted in the fill colour at the fill
/// opacity, once: at `ca 0.5` it is as opaque as a rectangle filled at `ca 0.5` beside it
/// (it was a quarter as opaque, the opacity applied by both the image's group and its paint).
/// The same holds when the fill is a pattern, whose paint keeps the opacity in force when it
/// was set with `scn`: an uncoloured tiling pattern and a shading pattern.
#[test]
fn stencil_masks_are_drawn_at_the_fill_opacity_once() {
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec();
let fills = [("colour", "0 0 1 rg"), ("uncoloured tiling pattern", "/CsP cs 0 0 1 /Tp scn"), ("shading pattern", "/Pattern cs /Sp scn")];
for (fill_name, fill) in fills {
for alpha in ["1", "0.5", "0.25"] {
let content = format!("/A gs {fill} q 100 0 0 100 20 50 cm /Msk Do Q 160 50 100 100 re f");
let tile = "0 0 10 10 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 300 200] /Contents 4 0 R /Resources << /ExtGState << /A << /ca {alpha} >> >> /XObject << /Msk 5 0 R >> /ColorSpace << /CsP [/Pattern /DeviceRGB] >> /Pattern << /Tp 6 0 R /Sp 7 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Length 2 >> stream
\0\0
endstream endobj
6 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream
{tile}
endstream endobj
7 0 obj << /Type /Pattern /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 300 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> >> >> endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
tile.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(), "{fill_name}, ca {alpha}: {:?}", p.error);
// Pixel rows run top down: PDF (70, 100) is in the mask, (210, 100) in the rectangle.
let (mask, rect) = (px(&p, 70, 100), px(&p, 210, 100));
assert!(rect != [255, 255, 255], "{fill_name}, ca {alpha}: the rectangle is painted");
assert!(mask.iter().zip(&rect).all(|(m, r)| m.abs_diff(*r) <= 1), "{fill_name}, ca {alpha}: mask {mask:?}, rectangle {rect:?}");
}
}
}
/// 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 -1
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -1256,6 +1256,12 @@ pub struct Color {
}
impl Color {
/// PdfCraft patch (35): the same colour, fully opaque (see `Pattern::without_opacity`).
pub(crate) fn opaque(mut self) -> Self {
self.opacity = 1.0;
self
}
pub(crate) fn new(color_space: ColorSpace, components: ColorComponents, opacity: f32) -> Self {
Self {
color_space,
+14
View File
@@ -67,6 +67,20 @@ impl<'a> Pattern<'a> {
}
}
/// PdfCraft patch (35): the pattern without the fill or stroke opacity it captured when it was
/// set (`scn`). A stencil mask's transparency group applies the opacity in force when the mask
/// is drawn, so a pattern paint carrying it too drew the mask at the opacity squared (#2975).
pub(crate) fn without_opacity(mut self) -> Self {
match &mut self {
Self::Shading(p) => p.opacity = 1.0,
Self::Tiling(p) => {
p.non_stroking_paint = p.non_stroking_paint.clone().opaque();
p.stroke_paint = p.stroke_paint.clone().opaque();
}
}
self
}
pub(crate) fn set_transfer_function(&mut self, tf: ActiveTransferFunction) {
if let Self::Shading(p) = self {
p.transfer_function = Some(tf);
+10 -2
View File
@@ -5,7 +5,7 @@ use crate::device::Device;
use crate::function::{Function, interpolate};
use crate::interpret::path::get_paint;
use crate::interpret::state::ActiveTransferFunction;
use crate::{BlendMode, CacheKey, ClipPath, Image, RasterImage, StencilImage};
use crate::{BlendMode, CacheKey, ClipPath, Image, Paint, RasterImage, StencilImage};
use crate::{FillRule, InterpreterWarning, WarningSinkFn, interpret};
use crate::{ImageData, LumaData, RgbData};
use hayro_syntax::bit_reader::BitReader;
@@ -262,10 +262,18 @@ pub(crate) fn draw_image_xobject<'a, 'b>(
device.set_soft_mask(None);
device.set_blend_mode(BlendMode::default());
// PdfCraft patch (35): the group just pushed applies the fill opacity, so a stencil mask's
// paint (the fill colour) must not carry it again: at `ca 0.5` a mask was drawn at a
// quarter of its opacity (#2975). The state is restored below.
context.get_mut().graphics_state.non_stroke_alpha = 1.0;
let image = if x_object.is_mask {
Image::Stencil(StencilImage {
paint: get_paint(context, false),
// A pattern paint keeps the opacity it was set with: drop it too (patch 35).
paint: match get_paint(context, false) {
Paint::Pattern(pattern) => Paint::Pattern(Box::new(pattern.without_opacity())),
paint => paint,
},
image_xobject: x_object.clone(),
})
} else {
+4 -1
View File
@@ -1596,8 +1596,11 @@ impl<'a> Device<'a> for Renderer {
);
self.ctx.set_transform(transform);
// PdfCraft patch: a stencil mask is painted with the fill
// (non-stroking) paint; `true` drew an uncoloured tiling pattern
// in its stroke colour (black by default) (#2975).
if let PaintSetup::Clip(clip_path) =
self.set_paint(paint, &stencil_rect.to_path(0.1), true)
self.set_paint(paint, &stencil_rect.to_path(0.1), false)
{
if let Some(clip_path) = clip_path.as_ref() {
self.push_clip_path_inner(clip_path, FillRule::NonZero);