Merge PR #2972: fix(render): blend modes over an empty part of the page keep their colour (#2970)

crates/render/src/raster.rs: main's tests plus the PR's #2970 test after the knockout test
vendor/README.md: main's rows with the PR's rewording of hayro patch 18
This commit is contained in:
aiongg
2026-10-22 01:50:51 +00:00
3 changed files with 81 additions and 12 deletions
+56
View File
@@ -6484,6 +6484,62 @@ trailer << /Root 1 0 R >>
check(&render_as(ko, &tiny, false), 101, 101, [128, 128, 255], "the tiny image in a final render");
}
/// #2970: a page's objects composite with a transparent backdrop and the page group is then
/// put on the paper, so a separable blend mode only blends where something was drawn. A
/// green fill with `/BM /Screen` (or Lighten, Difference…) half over a red square is yellow
/// (or whatever the mode gives) over the red, and plain green over the empty page: blending
/// with the white paper made it white or magenta.
#[test]
fn blend_modes_over_an_empty_part_of_the_page_keep_their_colour() {
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
for (mode, over_red) in [
("Normal", Some([0, 255, 0])),
("Multiply", Some([0, 0, 0])),
("Screen", Some([255, 255, 0])),
("Lighten", Some([255, 255, 0])),
("Difference", Some([255, 255, 0])),
("Exclusion", Some([255, 255, 0])),
("ColorDodge", Some([255, 0, 0])),
("Overlay", Some([255, 0, 0])),
// The non-separable modes mix hue, saturation and luminosity; over an empty part of
// the page they too must keep the source colour.
("Hue", None),
("Saturation", None),
("Color", None),
("Luminosity", None),
] {
let content = "1 0 0 rg 20 20 150 150 re f /G1 gs 0 1 0 rg 100 50 150 120 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 << /G1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /ExtGState /BM /{mode} >> 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(), "{mode}: {:?}", p.error);
// Pixel rows run top down: PDF (210, 100) and (130, 100) are rows 100 from the top.
assert_eq!(px(&p, 210, 100), vec![0, 255, 0, 255], "{mode} over the empty page");
if let Some(over_red) = over_red {
assert_eq!(px(&p, 130, 100)[..3], over_red, "{mode} over the red square");
}
assert_eq!(px(&p, 280, 10), vec![255, 255, 255, 255], "{mode}: the paper stays white");
}
}
/// 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.
+2 -2
View File
File diff suppressed because one or more lines are too long
+23 -10
View File
@@ -254,15 +254,20 @@ pub fn render_into<'a>(
interpreter_settings.preview,
);
// PdfCraft patch (18): the page renders as one transparency group with the background
// painted *inside* it, as the initial backdrop. A group isolates what it wraps from what
// was painted before it, so with the background outside, a Multiply highlight blended
// only with the group's transparent backdrop and stayed its own colour — over a dark
// reading-colours background the whole quad became one flat bar and the words under it
// vanished. Inside the group the highlight multiplies with the background too: the words
// keep the highlight's colour and the band between them takes the background (white
// backgrounds are unaffected — multiplying with white is the identity).
device.push_transparency_group(1.0, None, BlendMode::Normal);
// PdfCraft patch (18): the page renders as one transparency group (the page group). Its
// objects composite with an initially transparent backdrop, and the group is then put on
// the paper (ISO 32000-2, page group), so a blend mode only blends where something was
// drawn: a /BM /Screen fill over an empty part of the page keeps its colour (#2970).
// Under reading colours the background is painted *inside* the group instead, as its
// initial backdrop, so a Multiply highlight blends with the dark background: outside it,
// the highlight kept its own flat colour and the words under it vanished into the band
// (#1548). Reading colours replace the page's colours anyway; for document colours the
// background stays outside, or every separable mode but Normal, Multiply, Darken and
// HardLight would blend with the white paper.
let backdrop_inside = render_settings.color_replacement.is_some();
if backdrop_inside {
device.push_transparency_group(1.0, None, BlendMode::Normal);
}
device.ctx.set_paint(render_settings.bg_color);
device
@@ -274,11 +279,19 @@ pub fn render_into<'a>(
path: clip_path,
fill: FillRule::NonZero,
});
if !backdrop_inside {
device.push_transparency_group(1.0, None, BlendMode::Normal);
}
interpret_page(page, &mut state, &mut device);
if !backdrop_inside {
device.pop_transparency_group();
}
device.pop_clip_path();
device.pop_transparency_group();
if backdrop_inside {
device.pop_transparency_group();
}
let mut resources = vello_cpu::Resources::default();
device.ctx.render_to_buffer(