//! Shadings, end to end from a corpus page. //! //! `shading.rs`'s unit tests build dictionaries directly. These start from //! a real PDF, so they also cover the part that was actually broken: the //! `sh` operator reaching the device at all. //! //! See `REVIEWS/adr/0028-pdf-shading.md`. use std::path::PathBuf; use nigig_pdf_cos::object::PdfObj; use nigig_pdf_document::PdfDocument; use nigig_pdf_graphics::content::parse_content_stream; use nigig_pdf_graphics::recording::{RecordingDevice, RenderCommand}; use nigig_pdf_graphics::shading::{Shading, ShadingError, ShadingGeometry}; 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())) } /// Render a page and return its commands. fn render(data: &[u8]) -> Vec { let mut doc = PdfDocument::parse(data).expect("parses"); let page = doc.page(0).expect("page 0"); let ops = parse_content_stream(&page.content_data).expect("content parses"); RecordingDevice::from_ops(&ops) } /// Pull the named shading out of a document's page resources. fn shading_from(data: &[u8], name: &str) -> Result { let mut doc = PdfDocument::parse(data).expect("parses"); let page = doc.page(0).expect("page 0"); // `PdfPage` exposes resolved resource sub-dictionaries; /Shading is // read the same way as /ColorSpace. let entry = page .shadings .get(name) .unwrap_or_else(|| panic!("no /Shading named {name}")) .clone(); let (dict, data_bytes) = match &entry { PdfObj::Dict(d) => (d.clone(), None), PdfObj::Stream(s) => (s.dict.clone(), Some(s.data.clone())), other => panic!("a shading must be a dict or a stream, got {other:?}"), }; 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 }; Shading::parse(&dict, data_bytes.as_deref(), &mut resolve, &mut named) } // ------------------------------------------------- the operator reaches the device #[test] fn the_sh_operator_reaches_the_device() { // The regression. `content.rs` had `PdfOp::Shading(_name) => {}`, so // this command never existed and a gradient page rendered blank. let commands = render(&corpus("shading/axial.pdf")); let painted: Vec<&String> = commands .iter() .filter_map(|c| match c { RenderCommand::PaintShading(name) => Some(name), _ => None, }) .collect(); assert_eq!( painted, vec!["Sh0"], "the sh operator did not reach the device: {commands:?}" ); } #[test] fn a_shading_page_records_its_clip_and_its_shading() { // The fixture clips to a rectangle and then paints. Both must arrive, // in that order — painting before the clip would cover the page. let commands = render(&corpus("shading/axial.pdf")); let clip = commands .iter() .position(|c| matches!(c, RenderCommand::ClipWinding)); let paint = commands .iter() .position(|c| matches!(c, RenderCommand::PaintShading(_))); assert!(clip.is_some(), "the clip was lost"); assert!(paint.is_some(), "the shading was lost"); assert!(clip < paint, "the shading must be painted inside the clip"); } // ------------------------------------------------------------ geometry #[test] fn an_axial_shading_from_a_real_page_ramps_red_to_blue() { let data = corpus("shading/axial.pdf"); let shading = shading_from(&data, "Sh0").expect("parses"); match &shading.geometry { ShadingGeometry::Axial { from, to, extend, .. } => { assert_eq!(*from, [0.0, 0.0]); assert_eq!(*to, [300.0, 0.0]); assert_eq!(*extend, [true, true]); } other => panic!("expected an axial shading, got {other:?}"), } // Red at the left, blue at the right — asserted as values, because // "some colour came out" is what a broken gradient also produces. let left = shading.color_at_point(0.0, 100.0).expect("left"); let right = shading.color_at_point(300.0, 100.0).expect("right"); assert!( left[0] > 0.99 && left[2] < 0.01, "left should be red: {left:?}" ); assert!( right[2] > 0.99 && right[0] < 0.01, "right should be blue: {right:?}" ); // And the midpoint is genuinely between them, not one end repeated. let middle = shading.color_at_point(150.0, 100.0).expect("middle"); assert!( middle[0] > 0.4 && middle[0] < 0.6 && middle[2] > 0.4 && middle[2] < 0.6, "the midpoint should be halfway: {middle:?}" ); } #[test] fn a_radial_shading_from_a_real_page_is_symmetric() { let data = corpus("shading/radial.pdf"); let shading = shading_from(&data, "Sh0").expect("parses"); let centre = shading.color_at_point(100.0, 100.0).expect("centre"); // Four points at the same radius must agree. let samples: Vec<[f64; 3]> = [(150.0, 100.0), (50.0, 100.0), (100.0, 150.0), (100.0, 50.0)] .iter() .map(|(x, y)| shading.color_at_point(*x, *y).expect("on the circle")) .collect(); for sample in &samples[1..] { assert!( (sample[0] - samples[0][0]).abs() < 1e-9, "the radial shading is not symmetric: {samples:?}" ); } assert!( centre[0] < samples[0][0], "the centre should be darker than the ring" ); } #[test] fn an_unsupported_shading_type_is_refused_by_number() { // Refused, not approximated. A mesh drawn as a flat fill is a // plausible-looking wrong answer. let data = corpus("shading/unsupported_type.pdf"); match shading_from(&data, "Sh0") { Err(ShadingError::Unsupported(42)) => {} other => panic!( "expected Unsupported(42), got {:?}", other.map(|_| "a shading") ), } } #[test] fn the_unsupported_shading_still_reaches_the_device() { // The operator must be recorded even when the resource cannot be // drawn: a host that wants to warn the user needs to know it was // asked for. let commands = render(&corpus("shading/unsupported_type.pdf")); assert!(commands .iter() .any(|c| matches!(c, RenderCommand::PaintShading(_)))); } // ------------------------------------------------------------ sampling #[test] fn sampling_a_real_shading_produces_a_left_to_right_ramp() { let data = corpus("shading/axial.pdf"); let shading = shading_from(&data, "Sh0").expect("parses"); let pixels = shading.sample_grid([0.0, 0.0, 300.0, 200.0], 8, 4); assert_eq!(pixels.len(), 32); for row in 0..4 { let mut previous_blue = -1.0; for col in 0..8 { let rgb = pixels[row * 8 + col].expect("inside an extended shading"); assert!( rgb[2] >= previous_blue, "blue decreased along row {row} at column {col}" ); previous_blue = rgb[2]; } } } #[test] fn every_sample_of_an_extended_shading_has_a_colour() { // /Extend [true true] means the whole plane is covered, so no sample // may come back as "nothing here". let data = corpus("shading/axial.pdf"); let shading = shading_from(&data, "Sh0").expect("parses"); // Deliberately sample well outside the axis. let pixels = shading.sample_grid([-500.0, -500.0, 800.0, 800.0], 6, 6); assert!( pixels.iter().all(|p| p.is_some()), "an extended shading left a hole" ); } #[test] fn an_unextended_shading_leaves_holes_outside_its_axis() { // The radial fixture does not set /Extend, so points outside the // outer circle have no colour — and must not be painted black. let data = corpus("shading/radial.pdf"); let shading = shading_from(&data, "Sh0").expect("parses"); assert!( shading.color_at_point(1000.0, 1000.0).is_none(), "a point far outside an unextended shading was given a colour" ); } #[test] fn the_corpus_shadings_all_parse_or_refuse_by_name() { // A corpus-wide invariant in the shape of ADR 0017: every shading a // fixture declares must either parse or produce a named refusal. // Neither a panic nor a silent empty is acceptable. for fixture in [ "shading/axial.pdf", "shading/radial.pdf", "shading/unsupported_type.pdf", ] { let data = corpus(fixture); match shading_from(&data, "Sh0") { Ok(shading) => { // A parsed shading must be able to produce a colour // somewhere, or it is decorative nothing. let found = shading .sample_grid([0.0, 0.0, 300.0, 200.0], 8, 8) .into_iter() .any(|p| p.is_some()); assert!(found, "{fixture} parsed but produced no colour anywhere"); } Err(ShadingError::Unsupported(_)) => {} Err(other) => panic!("{fixture} failed unexpectedly: {other}"), } } } // ------------------------------------------------------------- mesh shadings // // Types 4-7 were written without a fixture that parsed a real vertex // stream: `shading.rs` sat at 68% and the uncovered lines were exactly // `parse_mesh`. That is the position `image.rs` was in before the JPEG // decoder turned out to be a stub, so these tests assert the triangles and // their colours, not that parsing returned Ok. // // Every fixture uses 16-bit coordinates over a [0 100] decode range and // 8-bit colour components, so each expected number below is a value the // generator wrote deliberately. /// The triangles of a mesh shading, or a panic naming what it was instead. fn triangles_of(data: &[u8]) -> (Vec, bool) { let shading = shading_from(data, "Sh0").expect("the mesh parses"); match shading.geometry { ShadingGeometry::Mesh { triangles, is_approximate, } => (triangles, is_approximate), other => panic!("expected a mesh, got {other:?}"), } } /// Coordinates decode through a 16-bit field, so they are exact to about /// 100/65535. fn near(a: f64, b: f64) -> bool { (a - b).abs() < 0.01 } fn assert_point(actual: [f64; 2], expected: [f64; 2], what: &str) { assert!( near(actual[0], expected[0]) && near(actual[1], expected[1]), "{what}: expected {expected:?}, got {actual:?}" ); } fn assert_color(actual: [f64; 3], expected: [f64; 3], what: &str) { for channel in 0..3 { assert!( (actual[channel] - expected[channel]).abs() < 0.01, "{what}: expected {expected:?}, got {actual:?}" ); } } #[test] fn a_free_form_mesh_reads_its_vertices_and_its_strip_flag() { let (triangles, approximate) = triangles_of(&corpus("shading/mesh_free_form.pdf")); // Four vertices: three with flag 0 forming one triangle, then one with // flag 1 continuing the strip. A reader that ignored the flag would // produce one triangle, or three. assert_eq!(triangles.len(), 2, "a flag-1 vertex must extend the strip"); assert!(!approximate, "type 4 triangles are exact, not approximated"); let first = triangles[0]; assert_point(first.points[0], [0.0, 0.0], "v0"); assert_point(first.points[1], [100.0, 0.0], "v1"); assert_point(first.points[2], [0.0, 100.0], "v2"); assert_color(first.colors[0], [1.0, 0.0, 0.0], "v0 colour"); assert_color(first.colors[1], [0.0, 1.0, 0.0], "v1 colour"); assert_color(first.colors[2], [0.0, 0.0, 1.0], "v2 colour"); // Flag 1 keeps the previous two vertices: v1, v2, v3. let second = triangles[1]; assert_point(second.points[0], [100.0, 0.0], "strip v1"); assert_point(second.points[1], [0.0, 100.0], "strip v2"); assert_point(second.points[2], [100.0, 100.0], "strip v3"); assert_color(second.colors[2], [1.0, 1.0, 1.0], "v3 colour"); } #[test] fn a_free_form_mesh_interpolates_across_a_triangle() { let (triangles, _) = triangles_of(&corpus("shading/mesh_free_form.pdf")); let first = triangles[0]; // At a corner the colour is that corner's colour exactly. assert_color( first.color_at(0.0, 0.0).expect("inside at the corner"), [1.0, 0.0, 0.0], "corner v0", ); // At the centroid, the mean of the three - which is what distinguishes // Gouraud interpolation from painting the first colour flat. let centroid = first .color_at(100.0 / 3.0, 100.0 / 3.0) .expect("inside at the centroid"); assert_color(centroid, [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0], "centroid"); // Outside the triangle is None, not black: black is a colour a mesh can // legitimately produce. assert!( first.color_at(90.0, 90.0).is_none(), "a point outside the triangle must not be given a colour" ); } #[test] fn a_lattice_mesh_has_no_per_vertex_flag() { // The bug this fixture exists for: a type 5 stream carries no // /BitsPerFlag field, so a reader that consumes one desynchronises // every vertex after the first and the corners land nowhere near the // unit square. let (triangles, approximate) = triangles_of(&corpus("shading/mesh_lattice.pdf")); assert!(!approximate, "a lattice is triangles already"); assert_eq!(triangles.len(), 2, "a 2x2 lattice is two triangles"); let corners: Vec<[f64; 2]> = triangles.iter().flat_map(|t| t.points).collect(); for expected in [[0.0, 0.0], [100.0, 0.0], [0.0, 100.0], [100.0, 100.0]] { assert!( corners .iter() .any(|p| near(p[0], expected[0]) && near(p[1], expected[1])), "lattice corner {expected:?} is missing; got {corners:?}" ); } // The fourth vertex is yellow, which only decodes correctly if the // stream stayed in step. let yellow = triangles .iter() .flat_map(|t| t.colors) .any(|c| near(c[0], 1.0) && near(c[1], 1.0) && near(c[2], 0.0)); assert!(yellow, "the last lattice colour decoded wrong"); } #[test] fn a_coons_patch_reads_four_corner_colours_not_one_per_control_point() { // The other real bug: types 6 and 7 carry 12 or 16 control points and // then FOUR colours, not a colour per point. Reading a colour per point // consumed three times too many components and ran off the stream. let (triangles, approximate) = triangles_of(&corpus("shading/mesh_coons.pdf")); assert!( approximate, "a Coons patch flattened to triangles is an approximation and must say so" ); assert_eq!(triangles.len(), 2, "one patch, flattened to two triangles"); // Corners are control points 0, 3, 6, 9 of the boundary. assert_point(triangles[0].points[0], [0.0, 0.0], "patch corner p1"); assert_point(triangles[0].points[1], [0.0, 100.0], "patch corner p2"); assert_point(triangles[0].points[2], [100.0, 100.0], "patch corner p3"); assert_point(triangles[1].points[2], [100.0, 0.0], "patch corner p4"); assert_color(triangles[0].colors[0], [1.0, 0.0, 0.0], "corner colour 1"); assert_color(triangles[0].colors[1], [0.0, 1.0, 0.0], "corner colour 2"); assert_color(triangles[0].colors[2], [0.0, 0.0, 1.0], "corner colour 3"); assert_color(triangles[1].colors[2], [1.0, 1.0, 0.0], "corner colour 4"); } #[test] fn a_tensor_patch_reads_sixteen_control_points() { // A tensor patch adds four interior points. A reader that used the // Coons stride would treat those as the next patch's boundary. let (triangles, approximate) = triangles_of(&corpus("shading/mesh_tensor.pdf")); assert!(approximate); assert_eq!( triangles.len(), 2, "sixteen points is one patch, not one and a fragment" ); assert_point(triangles[0].points[0], [0.0, 0.0], "tensor corner p1"); assert_point(triangles[0].points[2], [100.0, 100.0], "tensor corner p3"); assert_color( triangles[1].colors[2], [1.0, 1.0, 0.0], "tensor corner colour 4", ); } #[test] fn a_mesh_with_a_function_carries_one_parametric_value_per_vertex() { // With /Function present a vertex carries a single value, not n colour // components, so the component count changes and with it every bit // offset in the stream. let (triangles, _) = triangles_of(&corpus("shading/mesh_function.pdf")); assert_eq!(triangles.len(), 1); let t = triangles[0]; assert_point(t.points[0], [0.0, 0.0], "v0"); assert_point(t.points[1], [100.0, 0.0], "v1"); assert_point(t.points[2], [0.0, 100.0], "v2"); // The function ramps black to white, so t = 0, 0.5, 1 becomes those // three greys. assert_color(t.colors[0], [0.0, 0.0, 0.0], "t=0"); assert_color(t.colors[1], [0.5, 0.5, 0.5], "t=0.5"); assert_color(t.colors[2], [1.0, 1.0, 1.0], "t=1"); } #[test] fn a_mesh_shading_can_be_sampled_as_a_grid() { // The end the caller actually uses: a mesh must produce pixels, and // must produce None outside its triangles rather than black. let data = corpus("shading/mesh_free_form.pdf"); let shading = shading_from(&data, "Sh0").expect("parses"); let grid = shading.sample_grid([0.0, 0.0, 100.0, 100.0], 16, 16); assert_eq!(grid.len(), 256); let painted = grid.iter().filter(|p| p.is_some()).count(); assert!( painted > 200, "two triangles cover most of the unit square; only {painted} of 256 pixels were painted" ); // The lower-left pixel is inside the first triangle and near its red // corner; the upper-right is inside the second and near white. let lower_left = grid[0].expect("lower-left is inside the mesh"); assert!( lower_left[0] > 0.8 && lower_left[1] < 0.2, "the lower-left corner should be near red, got {lower_left:?}" ); let upper_right = grid[255].expect("upper-right is inside the mesh"); assert!( upper_right.iter().all(|c| *c > 0.8), "the upper-right corner should be near white, got {upper_right:?}" ); } #[test] fn a_truncated_mesh_stream_stops_rather_than_inventing_vertices() { // Half a vertex must not become a vertex at the origin. let data = corpus("shading/mesh_free_form.pdf"); let shading = shading_from(&data, "Sh0").expect("parses"); let ShadingGeometry::Mesh { triangles, .. } = &shading.geometry else { panic!("expected a mesh"); }; for triangle in triangles { for point in triangle.points { assert!( (0.0..=100.0).contains(&point[0]) && (0.0..=100.0).contains(&point[1]), "a vertex escaped the decode range: {point:?}" ); } } }