//! 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 { 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>)> { match o { PdfObj::Stream(s) => Some((o.clone(), Some(s.data.clone()))), other => Some((other.clone(), None)), } }; let mut named = |_: &str| -> Option { 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:?}" ); }