oxedyne/fe2o3/fe2o3_graphics/tests/svg_oracle.rs
7.1 KiB, 34 runs
created by r1870400018:14492, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Rasterise path data a browser has already drawn, and check the ink lands in the same places. |
| 2 | //! |
| 3 | //! The unit tests in `svg.rs` assert on the segments the parser emitted. That is a fair check of the |
| 4 | //! grammar and almost no check of the geometry: an arc conversion that is subtly wrong -- a centre a |
| 5 | //! little off, a sweep taken the long way round, a rotation applied in the wrong direction -- |
| 6 | //! produces perfectly well-formed cubics, and every one of those tests still passes. The parser and |
| 7 | //! the tests share a hand, so they share any misreading of what the numbers mean. |
| 8 | //! |
| 9 | //! The fixtures in `tests/svg/` close that gap. Each `.path` file holds a viewBox and path data, and |
| 10 | //! the `.png` beside it is Chromium's rendering of exactly those bytes. Four of the fixtures are |
| 11 | //! lifted verbatim from a real drawing program's output rather than composed here, so they carry the |
| 12 | //! forms a person writing fixtures would not think to write: exponents (`-1.22e-4`), numbers run |
| 13 | //! together with their signs (`-45.975-1.22e-4`), arc flags with no separator, and two-subpath |
| 14 | //! donuts that only come out as rings under the non-zero rule. |
| 15 | //! |
| 16 | //! Nothing in the expected output originates here, so agreement means agreement with an independent |
| 17 | //! implementation of the same specification. |
| 18 | //! |
| 19 | //! The page is drawn with a black fill on a transparent background, so the alpha channel of the PNG |
| 20 | //! *is* the coverage Chromium computed, and the comparison is coverage against coverage with no |
| 21 | //! conversion in between. |
| 22 | //! |
| 23 | //! To regenerate the PNGs: see `tests/svg/gen.sh`. |
| 24 | //! |
| 25 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 26 | //! Anthropic Claude |
| 27 | |
| 28 | use oxedyne_fe2o3_core::prelude::*; |
| 29 | use oxedyne_fe2o3_graphics::{ |
| 30 | path::TOLERANCE, |
| 31 | png, |
| 32 | raster::Raster, |
| 33 | svg, |
| 34 | transform::Transform, |
| 35 | }; |
| 36 | |
| 37 | use std::{ |
| 38 | fs, |
| 39 | path::PathBuf, |
| 40 | }; |
| 41 | |
| 42 | const SIZE: usize = 256; // the side of every fixture, in pixels; gen.sh renders at this size |
| 43 | |
| 44 | // How far a pixel's coverage may sit from Chromium's before it is counted as disagreeing. |
| 45 | // |
| 46 | // Two rasterisers will never agree to the bit along an edge: they weigh a partly covered pixel |
| 47 | /// differently, and a half-covered pixel is genuinely ambiguous. A quarter of full coverage is well |
| 48 | /// inside that noise and far outside anything a geometry error could hide in -- a wrong centre or a |
| 49 | // reversed sweep moves whole regions from 0 to 1, not by a fifth. |
| 50 | const NEAR: f32 = 0.25; |
| 51 | |
| 52 | // How much of the frame may disagree by more than NEAR. Disagreement is confined to the |
| 53 | // anti-aliased band along an edge, which for these shapes runs to a few hundred pixels of the |
| 54 | // 65536. One percent leaves room for a longer edge without leaving room for a shape in the wrong |
| 55 | // place. |
| 56 | const MAX_OFF: f32 = 0.01; |
| 57 | |
| 58 | // The mean absolute difference across the whole frame may not pass this. It is the guard a small, |
| 59 | // systematic shift cannot get past: an edge band contributes almost nothing to a mean over the |
| 60 | // whole frame, but a shape displaced by a pixel contributes everywhere along its perimeter. |
| 61 | const MAX_MEAN: f32 = 0.004; |
| 62 | |
| 63 | const CASES: &[&str] = &[ |
| 64 | // The four paths of one drawing program's output, verbatim. |
| 65 | "mark_cross", |
| 66 | "mark_lens", |
| 67 | "mark_ring", |
| 68 | "mark_outer", |
| 69 | // Aimed at the arc conversion, which is the only part with real arithmetic in it. |
| 70 | "arc_circle", |
| 71 | "arc_flags", |
| 72 | "arc_rotated", |
| 73 | "arc_grown", |
| 74 | // The curve commands that carry state from the command before them. |
| 75 | "smooth_cubic", |
| 76 | "smooth_quad", |
| 77 | // The grammar written as tightly as it is allowed to be. |
| 78 | "compact", |
| 79 | ]; |
| 80 | |
| 81 | fn dir() -> PathBuf { |
| 82 | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("svg") |
| 83 | } |
| 84 | |
| 85 | /// Reads a fixture and rasterises it through our own parser, returning per-pixel coverage. |
| 86 | fn ours(name: &str) -> Outcome<Vec<f32>> { |
| 87 | let txt = res!(fs::read_to_string(dir().join(format!("{}.path", name)))); |
| 88 | let mut lines = txt.lines(); |
| 89 | // The first line is the viewBox, as `# minx miny width height`. |
| 90 | let head = match lines.next() { |
| 91 | Some(h) => h, |
| 92 | None => return Err(err!("Fixture '{}' is empty.", name; Test, Input)), |
| 93 | }; |
| 94 | let mut vb = Vec::new(); |
| 95 | for tok in head.trim_start_matches('#').split_whitespace() { |
| 96 | match tok.parse::<f32>() { |
| 97 | Ok(v) => vb.push(v), |
| 98 | Err(e) => return Err(err!(e, |
| 99 | "Fixture '{}' has '{}' in its viewBox line.", name, tok; Test, Input)), |
| 100 | } |
| 101 | } |
| 102 | if vb.len() != 4 { |
| 103 | return Err(err!( |
| 104 | "Fixture '{}' names {} viewBox numbers, wanted 4.", name, vb.len(); Test, Input)); |
| 105 | } |
| 106 | let d = lines.collect::<Vec<_>>().join(" "); |
| 107 | let p = res!(svg::path_data(&d)); |
| 108 | // The viewBox onto the frame, which is what the browser does with the same two numbers. |
| 109 | let t = Transform::translate(-vb[0], -vb[1]) |
| 110 | .then(&Transform::scale(SIZE as f32 / vb[2], SIZE as f32 / vb[3])); |
| 111 | let mut r = Raster::new(SIZE, SIZE); |
| 112 | for c in p.flatten(&t, TOLERANCE) { |
| 113 | r.add_contour(&c); |
| 114 | } |
| 115 | Ok(r.coverage()) |
| 116 | } |
| 117 | |
| 118 | /// Reads Chromium's rendering of a fixture, returning per-pixel coverage from its alpha. |
| 119 | fn theirs(name: &str) -> Outcome<Vec<f32>> { |
| 120 | let buf = res!(fs::read(dir().join(format!("{}.png", name)))); |
| 121 | let pm = res!(png::decode(&buf)); |
| 122 | if pm.width() != SIZE || pm.height() != SIZE { |
| 123 | return Err(err!("Fixture '{}' is {}x{}, wanted {}x{}.", |
| 124 | name, pm.width(), pm.height(), SIZE, SIZE; Test, Input)); |
| 125 | } |
| 126 | let mut out = Vec::with_capacity(SIZE * SIZE); |
| 127 | for y in 0..SIZE { |
| 128 | for x in 0..SIZE { |
| 129 | match pm.pixel(x, y) { |
| 130 | Some(px) => out.push(px.a as f32 / 255.0), |
| 131 | None => return Err(err!( |
| 132 | "Fixture '{}' has no pixel at ({}, {}).", name, x, y; Test, Input)), |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | Ok(out) |
| 137 | } |
| 138 | |
| 139 | #[test] |
| 140 | fn test_our_ink_lands_where_chromiums_does_00() -> Outcome<()> { |
| 141 | let mut worst = String::new(); |
| 142 | let mut worst_mean = 0.0f32; |
| 143 | for name in CASES { |
| 144 | let a = res!(ours(name)); |
| 145 | let b = res!(theirs(name)); |
| 146 | let mut off = 0usize; |
| 147 | let mut sum = 0.0f64; |
| 148 | for i in 0..a.len() { |
| 149 | let d = (a[i] - b[i]).abs(); |
| 150 | sum += d as f64; |
| 151 | if d > NEAR { |
| 152 | off += 1; |
| 153 | } |
| 154 | } |
| 155 | let frac = off as f32 / a.len() as f32; |
| 156 | let mean = (sum / a.len() as f64) as f32; |
| 157 | if mean > worst_mean { |
| 158 | worst_mean = mean; |
| 159 | worst = (*name).to_string(); |
| 160 | } |
| 161 | assert!(frac <= MAX_OFF, |
| 162 | "'{}': {:.3}% of the frame differs from Chromium by more than {}, allowed {:.3}%", |
| 163 | name, frac * 100.0, NEAR, MAX_OFF * 100.0); |
| 164 | assert!(mean <= MAX_MEAN, |
| 165 | "'{}': mean coverage difference from Chromium is {:.5}, allowed {:.5}", |
| 166 | name, mean, MAX_MEAN); |
| 167 | } |
| 168 | // Not an assertion: worth seeing which fixture sits closest to the line. |
| 169 | println!("closest to the limit: {} at mean {:.5} of {:.5}", worst, worst_mean, MAX_MEAN); |
| 170 | Ok(()) |
| 171 | } |
| 172 | |
| 173 | #[test] |
| 174 | fn test_the_fixtures_are_not_blank_01() -> Outcome<()> { |
| 175 | // A guard on the oracle rather than the code. If `gen.sh` silently rendered nothing -- a browser |
| 176 | // that failed to load the file, a viewBox that framed empty space -- every comparison above |
| 177 | // would agree perfectly on a pair of empty frames and the suite would pass having tested |
| 178 | // nothing. |
| 179 | for name in CASES { |
| 180 | let b = res!(theirs(name)); |
| 181 | let ink = b.iter().filter(|v| **v > 0.5).count() as f32 / b.len() as f32; |
| 182 | assert!(ink > 0.02, |
| 183 | "Chromium's '{}' is {:.2}% covered: the fixture rendered blank", name, ink * 100.0); |
| 184 | assert!(ink < 0.98, |
| 185 | "Chromium's '{}' is {:.2}% covered: the fixture rendered solid", name, ink * 100.0); |
| 186 | } |
| 187 | Ok(()) |
| 188 | } |