Oregami
Repositories/oxedyne/fe2o3

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
28use oxedyne_fe2o3_core::prelude::*;
29use oxedyne_fe2o3_graphics::{
30 path::TOLERANCE,
31 png,
32 raster::Raster,
33 svg,
34 transform::Transform,
35};
36
37use std::{
38 fs,
39 path::PathBuf,
40};
41
42const 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.
50const 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.
56const 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.
61const MAX_MEAN: f32 = 0.004;
62
63const 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
81fn 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.
86fn 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.
119fn 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]
140fn 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]
174fn 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}