nigig-org/crates/apps/pdf/pdf-graphics/tests/golden_pixels.rs
andodeki 1740da3f34
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
feat(pdf): render a form XObject to pixels — the golden caught what the
assertions missed

`Rasteriser::register_xobject` takes a form's recorded commands from a
caller that can resolve the page dictionary, so Phase 7's last golden-corpus
criterion is met with pixels instead of with a request recorded by name.

The first golden of that page showed the form drawn at the **page origin**,
ignoring the `1 0 0 1 20 20 cm` that placed it. The recorded commands'
`SetTransform`s are absolute in form space, and replaying them overwrote the
page's CTM rather than composing with it. Nested lists now compose against
the CTM in force at the `Do`.

The colour assertions written next to that golden all passed while the bug
was live — a red square two pixels from where it belongs is still a red
square somewhere. That is the argument for pixel goldens in one sentence,
and it is why the golden is compared after the assertions and not instead
of them. The offset now has its own assertion too.

The new fixture's form deliberately overflows its own /BBox, so the clip is
visible in the golden as an absence rather than being taken on trust.

Phase 7's golden-corpus exit criterion is now met in full. The `ui.rs` smoke
tests remain blocked on the Makepad headless backend, as they have been
since Phase 1, and are still not claimed as done.

1426 tests pass.
2026-08-18 20:06:50 +00:00

497 lines
18 KiB
Rust

//! Phase 7's exit criterion: a golden render corpus of **pixels**.
//!
//! > Exit: golden render corpus covers shading/mesh/overprint/image-XObject
//! > pages
//!
//! The Phase 2 golden corpus (`golden_render.rs`) captures the *command
//! list*. That answers "was the right instruction issued" and cannot answer
//! "does the page look right" — which is exactly where blend modes hid: the
//! Makepad renderer recorded a `TransparencyError::Unsupported` for
//! `/Multiply` and then drew the source colour, so the command list was
//! perfect and the picture was wrong.
//!
//! These goldens are pixels, rendered by `raster.rs`, written as a small
//! ASCII grid. Text rather than PNG deliberately: a golden you cannot read
//! in a diff is a golden nobody reviews, and this project has been bitten
//! three times by fixtures that encoded the bug.
//!
//! Set `UPDATE_GOLDEN=1` to rewrite them, then **read the diff**.
use std::fs;
use std::path::PathBuf;
use nigig_pdf_cos::object::PdfObj;
use nigig_pdf_document::PdfDocument;
use nigig_pdf_graphics::composite::Canvas;
use nigig_pdf_graphics::content::parse_content_stream;
use nigig_pdf_graphics::raster::Rasteriser;
use nigig_pdf_graphics::recording::RecordingDevice;
use nigig_pdf_graphics::shading::Shading;
fn corpus(relative: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tests/corpus")
.join(relative);
std::fs::read(&path).unwrap_or_else(|e| panic!("missing fixture {}: {e}", path.display()))
}
fn golden_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/golden")
.join(format!("{name}.pixels.txt"))
}
/// Render a canvas as a grid of one character per pixel, plus a legend of
/// the colours those characters stand for.
///
/// Colours are **quantised to quarter steps** for the grid. A gradient page
/// has one distinct colour per pixel, so an exact-colour legend would be
/// longer than the picture and every character would be unique — a golden
/// nobody can read is a golden nobody reviews, and this project has been
/// bitten three times by fixtures that encoded the bug.
///
/// The precision that quantisation loses is not lost from the *test*: each
/// test asserts its exact colours in the test body first, and only then
/// compares the golden. A wrong-but-stable render therefore cannot be
/// blessed by an `UPDATE_GOLDEN=1` run.
fn format_canvas(canvas: &Canvas) -> String {
// Wide enough for a Gouraud-shaded mesh page, which at quarter steps
// has around fifty distinct colours. A '?' in the grid means the table
// ran out — visible, rather than two colours silently sharing a
// character.
const SYMBOLS: &[u8] =
b".:-=+*#%@0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
/// Quarter steps: 5 levels per channel, which keeps a two-colour ramp
/// legible while still showing where it changes.
fn quantise(v: f64) -> u8 {
(v.clamp(0.0, 1.0) * 4.0).round() as u8
}
let mut order: Vec<[u8; 4]> = Vec::new();
let mut grid = String::new();
for y in 0..canvas.height() {
for x in 0..canvas.width() {
let pixel = canvas.get(x, y).expect("in bounds");
let key = [
quantise(pixel.rgb[0]),
quantise(pixel.rgb[1]),
quantise(pixel.rgb[2]),
quantise(pixel.alpha),
];
let index = match order.iter().position(|p| *p == key) {
Some(i) => i,
None => {
order.push(key);
order.len() - 1
}
};
grid.push(if index < SYMBOLS.len() {
SYMBOLS[index] as char
} else {
'?'
});
}
grid.push('\n');
}
let mut out = format!("{}x{}\n", canvas.width(), canvas.height());
for (i, key) in order.iter().enumerate() {
let symbol = if i < SYMBOLS.len() {
SYMBOLS[i] as char
} else {
'?'
};
let q = |v: u8| v as f64 / 4.0;
out.push_str(&format!(
"{symbol} = rgba({:.2}, {:.2}, {:.2}, {:.2})\n",
q(key[0]),
q(key[1]),
q(key[2]),
q(key[3])
));
}
out.push_str("---\n");
out.push_str(&grid);
out
}
fn assert_golden_pixels(name: &str, canvas: &Canvas) {
let actual = format_canvas(canvas);
let path = golden_path(name);
if std::env::var("UPDATE_GOLDEN").is_ok() {
fs::create_dir_all(path.parent().expect("parent")).expect("create golden dir");
fs::write(&path, &actual).expect("write golden");
return;
}
let expected = fs::read_to_string(&path).unwrap_or_else(|_| {
panic!(
"missing golden file {}\nrerun with UPDATE_GOLDEN=1 to create it.\nactual was:\n{actual}",
path.display()
)
});
assert_eq!(
actual,
expected,
"rendered pixels for {name} do not match {}",
path.display()
);
}
/// Read a fixture's page 0 and its `/Shading` resources into a rasteriser.
fn rasterise_page(fixture: &str, width: usize, height: usize) -> Rasteriser {
let data = corpus(fixture);
let mut doc = PdfDocument::parse(&data).expect("fixture parses");
let page = doc.page(0).expect("page 0");
// Sample the page's own box into the canvas. Without this a 300x200
// page rendered into 24 pixels shows only its first 24 user units.
let media = page.media_box;
let mut raster = Rasteriser::for_page([media[0], media[1], media[2], media[3]], width, height);
for (name, entry) in page.shadings.clone() {
let (dict, stream_data) = match &entry {
PdfObj::Dict(d) => (d.clone(), None),
PdfObj::Stream(s) => (s.dict.clone(), Some(s.data.clone())),
_ => continue,
};
let mut resolve = |o: &PdfObj| -> Option<(PdfObj, Option<Vec<u8>>)> {
match o {
PdfObj::Stream(s) => Some((o.clone(), Some(s.data.clone()))),
other => Some((other.clone(), None)),
}
};
let mut named = |_: &str| -> Option<PdfObj> { None };
if let Ok(shading) = Shading::parse(&dict, stream_data.as_deref(), &mut resolve, &mut named)
{
raster.register_shading(&name, shading);
}
}
let ops = parse_content_stream(&page.content_data).expect("content parses");
let commands = RecordingDevice::from_ops(&ops);
raster.replay(&commands);
raster
}
// ------------------------------------------------------------------ shading
#[test]
fn golden_axial_shading_page() {
// The page whose absence let `sh` be a no-op through six phases. Small
// enough to read in a diff, large enough for the ramp to be visible.
let raster = rasterise_page("shading/axial.pdf", 24, 16);
let canvas = raster.into_canvas();
// Assert the substance before the golden, so a wrong-but-stable render
// cannot be blessed by an UPDATE_GOLDEN run. The fixture ramps red to
// blue left to right.
let left = canvas.get(0, 8).expect("left").rgb;
let right = canvas.get(23, 8).expect("right").rgb;
assert!(
left[0] > 0.9 && left[2] < 0.1,
"the left end should be red, got {left:?}"
);
assert!(
right[2] > 0.9 && right[0] < 0.1,
"the right end should be blue, got {right:?}"
);
assert_golden_pixels("shading_axial", &canvas);
}
#[test]
fn golden_radial_shading_page() {
let raster = rasterise_page("shading/radial.pdf", 16, 16);
let canvas = raster.into_canvas();
// Black at the centre, white at the rim: radially symmetric.
let centre = canvas.get(8, 8).expect("centre").rgb[0];
let edge = canvas.get(8, 1).expect("edge").rgb[0];
assert!(
edge > centre,
"the radial fixture ramps outwards; centre {centre}, edge {edge}"
);
assert_golden_pixels("shading_radial", &canvas);
}
#[test]
fn golden_mesh_shading_page() {
// The mesh page. Before ADR 0029 this rendered nothing at all: mesh
// shadings parsed and `color_at_point` returned None for every pixel.
let raster = rasterise_page("shading/mesh_free_form.pdf", 16, 16);
let canvas = raster.into_canvas();
let painted = (0..canvas.height())
.flat_map(|y| (0..canvas.width()).map(move |x| (x, y)))
.filter(|(x, y)| canvas.get(*x, *y).unwrap().rgb != [1.0, 1.0, 1.0])
.count();
assert!(
painted > 100,
"the mesh covers most of the page; only {painted} pixels were painted"
);
// The mesh's corners: red at the PDF-space origin, which is the raster's
// bottom-left.
let bottom_left = canvas.get(1, 14).expect("bottom-left").rgb;
assert!(
bottom_left[0] > 0.8 && bottom_left[1] < 0.3,
"the mesh's red corner is missing, got {bottom_left:?}"
);
assert_golden_pixels("shading_mesh", &canvas);
}
// ------------------------------------------------------------------ blending
#[test]
fn golden_blend_mode_page() {
// A page that renders differently under /Multiply than under /Normal.
// Both are rendered and both are asserted, because the failure this
// guards against is the two being identical.
use nigig_pdf_graphics::recording::RenderCommand as Rc;
use nigig_pdf_graphics::transparency::BlendMode;
let page = |mode: BlendMode| {
vec![
// A grey backdrop band across the middle.
Rc::SetFillColor([0.5, 0.5, 0.5, 1.0]),
Rc::Rectangle(0.0, 4.0, 16.0, 8.0),
Rc::FillWinding,
// An orange square blended over it.
Rc::SetBlendMode(mode),
Rc::SetFillColor([0.9, 0.3, 0.1, 1.0]),
Rc::Rectangle(4.0, 0.0, 8.0, 16.0),
Rc::FillWinding,
]
};
let mut multiply = Rasteriser::new(16, 16);
multiply.replay(&page(BlendMode::Multiply));
let mut normal = Rasteriser::new(16, 16);
normal.replay(&page(BlendMode::Normal));
// Over the grey band the two must differ; over bare paper they must
// not, because §11.3.6 weights the blend by the backdrop alpha and the
// paper is opaque white.
let over_band = (8, 8);
let m = multiply.canvas().get(over_band.0, over_band.1).unwrap().rgb;
let n = normal.canvas().get(over_band.0, over_band.1).unwrap().rgb;
assert_ne!(m, n, "Multiply and Normal produced the same pixel");
assert!((m[0] - 0.45).abs() < 1e-9, "0.5 x 0.9 is 0.45, got {:?}", m);
assert_golden_pixels("blend_multiply", multiply.canvas());
assert_golden_pixels("blend_normal", normal.canvas());
}
// ----------------------------------------------------------------- overprint
#[test]
fn golden_overprint_page() {
// Overprint cannot be shown in RGB pixels — it is a statement about
// inks — so this golden is the CMYK plate values, rendered as the same
// grid so the format stays reviewable.
use nigig_pdf_graphics::composite::{
composite_cmyk, CompositeState, Overprint, ProcessColorants,
};
let width = 12usize;
let height = 8usize;
// A cyan ground with a magenta square painted over it, once
// overprinting and once not.
let render = |overprinting: bool| {
let state = CompositeState {
overprint: Overprint {
stroke: overprinting,
fill: overprinting,
mode: 1,
},
colorants: ProcessColorants::Cmyk,
alpha: 1.0,
..Default::default()
};
let mut plates = vec![[1.0f64, 0.0, 0.0, 0.0]; width * height];
for y in 2..6 {
for x in 3..9 {
let index = y * width + x;
plates[index] = composite_cmyk(plates[index], [0.0, 1.0, 0.0, 0.0], &state, 1.0);
}
}
plates
};
let over = render(true);
let knock = render(false);
let inside = 4 * width + 5;
assert_eq!(
over[inside],
[1.0, 1.0, 0.0, 0.0],
"overprinting magenta over cyan must keep the cyan ink"
);
assert_eq!(
knock[inside],
[0.0, 1.0, 0.0, 0.0],
"without overprint the cyan ink must be knocked out"
);
// Same ASCII-grid format, over CMYK rather than RGBA.
let format = |plates: &[[f64; 4]]| {
let mut order: Vec<[u8; 4]> = Vec::new();
let mut grid = String::new();
const SYMBOLS: &[u8] = b".:-=+*#%@";
for y in 0..height {
for x in 0..width {
let ink = plates[y * width + x];
let q = [
(ink[0] * 255.0).round() as u8,
(ink[1] * 255.0).round() as u8,
(ink[2] * 255.0).round() as u8,
(ink[3] * 255.0).round() as u8,
];
let index = match order.iter().position(|p| *p == q) {
Some(i) => i,
None => {
order.push(q);
order.len() - 1
}
};
grid.push(SYMBOLS[index.min(SYMBOLS.len() - 1)] as char);
}
grid.push('\n');
}
let mut out = format!("{width}x{height}\n");
for (i, ink) in order.iter().enumerate() {
out.push_str(&format!(
"{} = cmyk({}, {}, {}, {})\n",
SYMBOLS[i.min(SYMBOLS.len() - 1)] as char,
ink[0],
ink[1],
ink[2],
ink[3]
));
}
out.push_str("---\n");
out.push_str(&grid);
out
};
for (name, plates) in [("overprint_on", &over), ("overprint_off", &knock)] {
let actual = format(plates);
let path = golden_path(name);
if std::env::var("UPDATE_GOLDEN").is_ok() {
fs::write(&path, &actual).expect("write golden");
continue;
}
let expected = fs::read_to_string(&path).unwrap_or_else(|_| {
panic!(
"missing {}, rerun with UPDATE_GOLDEN=1\n{actual}",
path.display()
)
});
assert_eq!(actual, expected, "{name} does not match");
}
}
// ------------------------------------------------------------ image XObject
#[test]
fn golden_form_xobject_page() {
// A form XObject rendered to pixels. `Do` used to record a name for a
// host that never existed, so this page was blank.
//
// The form draws a red square, a blue square, and a green one that
// lies outside its own /BBox — so a reader that ignores the box paints
// something visibly different, and the golden shows which.
use nigig_pdf_graphics::nested::render_form;
let data = corpus("images/xobject_form.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
let page_ref = doc.page_object_ref(0).expect("page 0");
let page_obj = doc.resolve_ref(page_ref).expect("resolves");
let page_dict = page_obj.as_dict().expect("a page dict").clone();
let resources_obj = page_dict.get("Resources").expect("/Resources").clone();
let resources = doc
.resolve(&resources_obj)
.expect("resolves")
.as_dict()
.cloned()
.expect("a dict");
// Record the form once, then hand its commands to the rasteriser —
// resolving the resource needs the document, which the rasteriser
// deliberately does not carry.
let mut form_device = RecordingDevice::new();
render_form(&mut doc, &mut form_device, &resources, "Fx0", 0).expect("the form resolves");
let form_commands = form_device.into_commands();
assert!(
!form_commands.is_empty(),
"the form produced no commands at all"
);
let page = doc.page(0).expect("page 0");
let media = page.media_box;
let mut raster = Rasteriser::for_page([media[0], media[1], media[2], media[3]], 16, 16);
raster.register_xobject("Fx0", form_commands);
let ops = parse_content_stream(&page.content_data).expect("content parses");
raster.replay(&RecordingDevice::from_ops(&ops));
assert!(
raster.unresolved().is_empty(),
"something was left unresolved: {:?}",
raster.unresolved()
);
let canvas = raster.into_canvas();
// The form is placed at (20, 20) on a 160-unit page rendered into 16
// pixels, so one pixel is ten units and the placement is two pixels.
//
// The offset is the point of this assertion. The first golden of this
// page showed the form at the page origin — the recorded form
// transforms were replacing the page's CTM rather than composing with
// it — and the colour checks below *still passed*, because a square
// two pixels away is still a red square somewhere. The golden caught
// it; the assertions did not. That is what the golden is for.
let red = canvas.get(4, 11).expect("inside the red square").rgb;
assert_eq!(
canvas.get(0, 15).expect("the page corner").rgb,
[1.0, 1.0, 1.0],
"the form must be offset by its `cm`, not drawn at the page origin"
);
assert!(
red[0] > 0.9 && red[1] < 0.1 && red[2] < 0.1,
"the form's red square is missing, got {red:?}"
);
// The blue square is up and to the right, at PDF (70,70)-(120,120).
let blue = canvas.get(9, 6).expect("inside the blue square").rgb;
assert!(
blue[2] > 0.9 && blue[0] < 0.1,
"the form's blue square is missing, got {blue:?}"
);
// The green square is outside the /BBox and must not appear anywhere.
for y in 0..canvas.height() {
for x in 0..canvas.width() {
let pixel = canvas.get(x, y).expect("in bounds").rgb;
assert!(
!(pixel[1] > 0.5 && pixel[0] < 0.5 && pixel[2] < 0.5),
"green at ({x}, {y}) — the /BBox clip was not applied"
);
}
}
assert_golden_pixels("xobject_form", &canvas);
}
#[test]
fn golden_image_xobject_page() {
// A form XObject painted through the device. The rasteriser cannot
// resolve a named resource itself, so this asserts the request is
// *recorded by name* — the Phase 7 state of "the host resolves it" —
// rather than pretending an image appeared.
let raster = rasterise_page("images/xobject.pdf", 16, 16);
let unresolved = raster.unresolved().join(",");
assert!(
unresolved.contains("XObject"),
"the Do operator must be reported, got {unresolved:?}"
);
}