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

hayro-interpret draws every image XObject inside a transparency group
that applies the fill opacity (ca). A stencil mask (/ImageMask true)
also takes the fill colour, with that same alpha, as its paint, so the
opacity was applied twice: at ca 0.5 a mask was drawn at 25 %, at
ca 0.25 at about 6 %. Rectangles and raster images were right.

Vendored hayro-interpret patch (36) resets the fill alpha to 1 once
the image's group is pushed, before the stencil's paint is taken; the
saved state is restored after the image. Type 3 glyph and pattern
stencils take their paint from the glyph or pattern and are unchanged.

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 06:07:09 +08:00
co-authored by Claude Opus 5.5
parent dab3149110
commit 1cf6ca5579
3 changed files with 43 additions and 1 deletions
+38
View File
@@ -5822,6 +5822,44 @@ 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).
#[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();
for alpha in ["1", "0.5", "0.25"] {
let content = "/A gs 0 0 1 rg q 100 0 0 100 20 50 cm /Msk Do Q 160 50 100 100 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 >> >> >> 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
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.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(), "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!(mask.iter().zip(&rect).all(|(m, r)| m.abs_diff(*r) <= 1), "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.
+1 -1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -259,6 +259,10 @@ 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 {