Files
pdfcraft/crates/render/tests/it/nonseparable_gray.rs
T
KenM76andClaude Opus 5.5 11d9b4cda9 test(render): don't claim which vello_cpu pipeline the Luminosity guard runs in
The previous commit's message and this header said the Luminosity ramp
passes because pages use the u8 pipeline. That wasn't established; the
case simply renders correctly here, so the test is a guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-28 14:49:50 -04:00

96 lines
4.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The nonseparable blend modes keep grays gray (ISO 32000-2 §11.3.5.3). vello_cpu 0.0.8's
//! `clip_color` divided 0 by 0 for grays that rounding left just outside 0..1 and drew them black
//! under `Color`. Upstream's report also has black under `Luminosity` over a gray going wrong; that
//! case already renders correctly here, so its test guards rather than reproduces.
use std::sync::Arc;
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
fn pdf(objects: &[String]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out
}
fn stream(dict: &str, content: &str) -> String {
format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len())
}
/// Renders an isolated group holding a 16 × 16 grid of 6 pt cells, one per gray level, each
/// drawn by `cell(level, "x y 6 6 re")` with `/B` (`/BM /{mode} /ca {alpha}`) available, and
/// returns each level's centre pixel.
fn gray_ramp(mode: &str, alpha: f32, cell: impl Fn(u8, &str) -> String) -> Vec<(u8, [u8; 3])> {
let mut content = String::new();
for level in 0..=255_u8 {
let (x, y) = (2 + u32::from(level % 16) * 6, 2 + u32::from(level / 16) * 6);
content.push_str(&cell(level, &format!("{x} {y} 6 6 re")));
content.push('\n');
}
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>".to_string(),
stream("", "/Fm0 Do"),
stream(
&format!(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Resources << /ExtGState << /B << /BM /{mode} /ca {alpha} >> >> >>"
),
&content,
),
];
let mut renderer = PageRenderer::new(Arc::new(pdf(&objects)), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(page.error.is_none(), "{:?}", page.error);
(0..=255_u8)
.map(|level| {
let (x, y) = (5 + u32::from(level % 16) * 6, 100 - 5 - u32::from(level / 16) * 6);
let offset = usize::try_from((y * page.width + x) * 4).unwrap();
(level, [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2]])
})
.collect()
}
fn gray(level: u8) -> f32 {
f32::from(level) / 255.0
}
fn assert_ramp(what: &str, got: &[(u8, [u8; 3])], expected: impl Fn(u8) -> f32) {
let wrong: Vec<_> = got.iter().filter(|(level, rgb)| rgb.iter().any(|&c| (f32::from(c) - expected(*level)).abs() > 2.0)).collect();
assert!(wrong.is_empty(), "{what}: {} levels off, (level, pixel): {wrong:?}", wrong.len());
}
#[test]
fn every_gray_under_color_over_nothing_keeps_its_lightness() {
// With nothing beneath it the blend result is the source (§11.4.7): each gray at half
// opacity, over the white page.
let got = gray_ramp("Color", 0.5, |level, rect| format!("/B gs {} g {rect} f", gray(level)));
assert_ramp("Color", &got, |level| (f32::from(level) + 255.0) / 2.0);
}
#[test]
fn black_under_luminosity_halves_every_opaque_gray() {
// B(Cb, Cs) = SetLum(Cb, Lum(Cs)) is black for a black source, so at half opacity the
// result is half the backdrop's gray.
let got = gray_ramp("Luminosity", 0.5, |level, rect| format!("q {} g {rect} f /B gs 0 g {rect} f Q", gray(level)));
assert_ramp("Luminosity", &got, |level| f32::from(level) / 2.0);
}
#[test]
fn a_gray_into_an_empty_group_keeps_its_lightness_in_every_nonseparable_mode() {
for mode in ["Color", "Luminosity", "Hue", "Saturation"] {
let got = gray_ramp(mode, 1.0, |level, rect| format!("/B gs {} g {rect} f", gray(level)));
assert_ramp(mode, &got, f32::from);
}
}