mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 22:19:10 -06:00
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
96 lines
4.4 KiB
Rust
96 lines
4.4 KiB
Rust
//! 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);
|
||
}
|
||
}
|