ADR 0028 shipped types 4-7 and said honestly that they were unproven: the uncovered lines of `shading.rs` were exactly `parse_mesh`, "the position `image.rs` was in before ADR 0016 found the JPEG decoder was a stub". Writing the fixtures found three real bugs. - Type 5 has no per-vertex flag; `/VerticesPerRow` delimits it. Reading 8 phantom bits shifted every vertex after the first, decoding plausible coordinates that were entirely wrong. - Types 6 and 7 are patches: 12 or 16 control points carrying no colour, then four corner colours. The old loop read a colour per point, consumed three times too many components, ran off the stream, and the None-on-truncation path swallowed it as "the mesh ended". - A flag-0 triangle is three vertices whose second and third flags are ignored (§8.7.4.5.5). Acting on them cleared the strip every time and produced no triangles at all. Caught in new code, before it shipped. And one omission: `color_at_point` returned None for a mesh, so a mesh that parsed perfectly still painted nothing — indistinguishable from one that failed. `MeshTriangle::color_at` now interpolates the corner colours by barycentric coordinates, None outside, because black is a colour a mesh can legitimately produce. Shared-edge patches (flags 1-3) inherit the previous patch's edge rather than being read as fresh patches, which desynchronised the rest of the stream. Five corpus fixtures, generated from named coordinates and colours so every expected value in the tests is one the generator wrote deliberately. Eight tests, five mutations, all killed. Coons flattening is still an approximation and still reports `is_approximate`. ADR 0029.
491 lines
19 KiB
Rust
491 lines
19 KiB
Rust
//! 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<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()))
|
|
}
|
|
|
|
/// Render a page and return its commands.
|
|
fn render(data: &[u8]) -> Vec<RenderCommand> {
|
|
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<Shading, ShadingError> {
|
|
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<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 };
|
|
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<nigig_pdf_graphics::shading::MeshTriangle>, 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:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|