From b744866deeb6ef469949637993a869801a62833a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:49:58 +0200 Subject: [PATCH 1/3] Shading patterns use the stroke or fill alpha current at paint time (#3176) --- crates/render/src/raster.rs | 32 ++++++++++++++++++++ vendor/hayro-interpret/src/interpret/path.rs | 2 ++ vendor/hayro-interpret/src/pattern.rs | 13 ++++++-- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index b2127894f..21b201115 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -6760,6 +6760,38 @@ trailer << /Root 1 0 R >> assert_eq!(px(75, 50), vec![255, 255, 255, 255], "outside the mask"); } + /// Issue #3176: `CA` / `ca` belong to the graphics state at paint time, so a `gs` after + /// `SCN` / `scn` still fades a shading pattern, and a stroke takes the stroke alpha. The + /// pattern took the fill alpha current at `scn`/`SCN`, so both shapes came out opaque. + #[test] + fn shading_pattern_uses_the_alpha_current_at_paint_time() { + let content = "/Pattern CS /P0 SCN /S1 gs 10 w 0 75 m 100 75 l S /Pattern cs /P0 scn /F1 gs 0 0 100 50 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 100 100] /Contents 4 0 R + /Resources << /ExtGState << /S1 << /CA 0.25 >> /F1 << /ca 0.25 >> >> /Pattern << /P0 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Extend [true true] + /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [1 0 0] /N 1 >> >> >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + // Red at 25% over white is about (255, 191, 191); opaque red would be (255, 0, 0). + for (what, x, y) in [("the stroke under /CA 0.25", 50, 25), ("the fill under /ca 0.25", 50, 75)] { + let c = px(x, y); + assert!(c[0] >= 250 &&(185..=197).contains(&c[1]) && (185..=197).contains(&c[2]), "{what}: {c:?}"); + } + } + /// ISO 32000-2 ยง8.5.4: `W` / `W*` clip with the current path once whichever path-painting /// operator ends it has painted it, `S`, `f` or `B` as much as `n`. The vendored interpreter /// applied the clip only on `n`: after `re W* S` everything later painted outside the clip, diff --git a/vendor/hayro-interpret/src/interpret/path.rs b/vendor/hayro-interpret/src/interpret/path.rs index 838ea099f..cd0767367 100644 --- a/vendor/hayro-interpret/src/interpret/path.rs +++ b/vendor/hayro-interpret/src/interpret/path.rs @@ -159,6 +159,8 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> pattern.set_transfer_function(tf.clone()); } + // `CA`/`ca` can be set by a `gs` after the pattern colour operator, so use the alpha current at paint time. + pattern.set_alpha(data.alpha); pattern.pre_concat_transform(context.root_transform()); Paint::Pattern(Box::new(pattern)) diff --git a/vendor/hayro-interpret/src/pattern.rs b/vendor/hayro-interpret/src/pattern.rs index 9295f5170..b8ff23db2 100644 --- a/vendor/hayro-interpret/src/pattern.rs +++ b/vendor/hayro-interpret/src/pattern.rs @@ -43,7 +43,6 @@ impl<'a> Pattern<'a> { Object::Dict(dict) => Some(Self::Shading(ShadingPattern::new( &dict, &ctx.interpreter_cache.object_cache, - ctx.get().graphics_state.non_stroke_alpha, )?)), Object::Stream(stream) => Some(Self::Tiling(Box::new(TilingPattern::new( stream, ctx, resources, @@ -63,6 +62,14 @@ impl<'a> Pattern<'a> { } } + /// Set the alpha of the graphics state in effect when the pattern is painted. The `gs` operator + /// can come after `scn`/`SCN`, so the alpha must not be captured when the pattern is selected. + pub(crate) fn set_alpha(&mut self, alpha: f32) { + if let Self::Shading(p) = self { + p.opacity = alpha; + } + } + pub(crate) fn set_transfer_function(&mut self, tf: ActiveTransferFunction) { if let Self::Shading(p) = self { p.transfer_function = Some(tf); @@ -93,7 +100,7 @@ pub struct ShadingPattern { } impl ShadingPattern { - pub(crate) fn new(dict: &Dict<'_>, cache: &Cache, opacity: f32) -> Option { + pub(crate) fn new(dict: &Dict<'_>, cache: &Cache) -> Option { let shading = dict.get::>(SHADING).and_then(|o| { let (dict, stream) = dict_or_stream(&o)?; @@ -110,7 +117,7 @@ impl ShadingPattern { Some(Self { shading: Arc::new(shading), - opacity, + opacity: 1.0, matrix, transfer_function: None, }) From cf1305fa4adfb6a0785b3fd1958a2321a1bde099 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:05:26 +0200 Subject: [PATCH 2/3] Format the shading alpha test (#3176) --- crates/render/src/raster.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 21b201115..b7611364a 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -6788,7 +6788,7 @@ trailer << /Root 1 0 R >> // Red at 25% over white is about (255, 191, 191); opaque red would be (255, 0, 0). for (what, x, y) in [("the stroke under /CA 0.25", 50, 25), ("the fill under /ca 0.25", 50, 75)] { let c = px(x, y); - assert!(c[0] >= 250 &&(185..=197).contains(&c[1]) && (185..=197).contains(&c[2]), "{what}: {c:?}"); + assert!(c[0] >= 250 && (185..=197).contains(&c[1]) && (185..=197).contains(&c[2]), "{what}: {c:?}"); } } From 853851075f1ba55631f7470640a233099ecd5921 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:37:46 +0200 Subject: [PATCH 3/3] Mark the alpha changes as PdfCraft patches --- vendor/hayro-interpret/src/interpret/path.rs | 2 +- vendor/hayro-interpret/src/pattern.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/hayro-interpret/src/interpret/path.rs b/vendor/hayro-interpret/src/interpret/path.rs index cd0767367..c1fd47f39 100644 --- a/vendor/hayro-interpret/src/interpret/path.rs +++ b/vendor/hayro-interpret/src/interpret/path.rs @@ -159,7 +159,7 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> pattern.set_transfer_function(tf.clone()); } - // `CA`/`ca` can be set by a `gs` after the pattern colour operator, so use the alpha current at paint time. + // PdfCraft patch: `CA`/`ca` can be set by a `gs` after the pattern colour operator, so use the alpha current at paint time. pattern.set_alpha(data.alpha); pattern.pre_concat_transform(context.root_transform()); diff --git a/vendor/hayro-interpret/src/pattern.rs b/vendor/hayro-interpret/src/pattern.rs index b8ff23db2..054a88e0d 100644 --- a/vendor/hayro-interpret/src/pattern.rs +++ b/vendor/hayro-interpret/src/pattern.rs @@ -62,7 +62,7 @@ impl<'a> Pattern<'a> { } } - /// Set the alpha of the graphics state in effect when the pattern is painted. The `gs` operator + /// PdfCraft patch: set the alpha of the graphics state in effect when the pattern is painted. The `gs` operator /// can come after `scn`/`SCN`, so the alpha must not be captured when the pattern is selected. pub(crate) fn set_alpha(&mut self, alpha: f32) { if let Self::Shading(p) = self {