nitai/projects
noxstrap / src / crates / nox-gfx / tests / png_roundtrip.rs
149 lines · 5.2 KB Raw
1//! Our PNG writer is only correct if a decoder we did not write accepts it.
2//! This drives python3's zlib as an independent inflater, unfilters the rows by
3//! the spec, and requires an exact pixel match. Skips if python3 is unavailable.
4
5use nox_gfx::canvas::{Canvas, Paint, Stroke};
6use nox_gfx::color::{hex, rgba};
7use nox_gfx::geom::{pt, Rect, IDENTITY};
8use nox_gfx::path::Path;
9use std::process::Command;
10
11const DECODER: &str = r#"
12import sys, zlib, struct
13png = open(sys.argv[1],'rb').read()
14assert png[:8] == b'\x89PNG\r\n\x1a\n', 'bad signature'
15i, idat, ihdr = 8, b'', None
16while i < len(png):
17 n = struct.unpack_from('>I', png, i)[0]
18 tag = png[i+4:i+8]
19 body = png[i+8:i+8+n]
20 want = struct.unpack_from('>I', png, i+8+n)[0]
21 got = zlib.crc32(tag + body) & 0xffffffff
22 assert got == want, 'crc mismatch on %s' % tag
23 if tag == b'IHDR': ihdr = body
24 if tag == b'IDAT': idat += body
25 i += 12 + n
26w, h, depth, ctype = struct.unpack('>IIBB', ihdr[:10])
27assert (depth, ctype) == (8, 6), 'expected 8-bit RGBA'
28raw = zlib.decompress(idat)
29bpp, stride = 4, w*4
30out = bytearray()
31prev = bytearray(stride)
32p = 0
33for y in range(h):
34 f = raw[p]; p += 1
35 line = bytearray(raw[p:p+stride]); p += stride
36 for x in range(stride):
37 a = line[x-bpp] if x >= bpp else 0
38 b = prev[x]
39 c = prev[x-bpp] if x >= bpp else 0
40 if f == 1: line[x] = (line[x] + a) & 0xff
41 elif f == 2: line[x] = (line[x] + b) & 0xff
42 elif f == 3: line[x] = (line[x] + (a+b)//2) & 0xff
43 elif f == 4:
44 pp = a + b - c
45 pa, pb, pc = abs(pp-a), abs(pp-b), abs(pp-c)
46 pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
47 line[x] = (line[x] + pr) & 0xff
48 elif f != 0:
49 raise SystemExit('bad filter %d' % f)
50 out += line
51 prev = line
52assert p == len(raw), 'trailing data'
53open(sys.argv[2],'wb').write(bytes(out))
54print('%d %d' % (w, h))
55"#;
56
57fn scene() -> Canvas {
58 let mut c = Canvas::new(233, 149);
59 c.clear(hex(0x101018));
60 let mut p = Path::new();
61 p.rrect(Rect::new(10.5, 8.25, 220.0, 120.75), 14.0);
62 c.fill(&p, &IDENTITY, &Paint::vgrad(Rect::new(0.0, 0.0, 1.0, 149.0), hex(0x7c5cff), hex(0x35d0ff)));
63 c.stroke(&p, &IDENTITY, &Paint::Solid(rgba(255, 255, 255, 90)), &Stroke::new(1.5));
64 p.clear();
65 p.circle(pt(70.0, 60.0), 33.0);
66 c.shadow(&p, &IDENTITY, 9.0, rgba(0, 0, 0, 200), pt(2.0, 5.0));
67 c.fill(&p, &IDENTITY, &Paint::glow(pt(70.0, 60.0), 33.0, hex(0xffd166), rgba(255, 122, 89, 120)));
68 for i in 0..24 {
69 let x = 8.0 + i as f32 * 9.3;
70 c.fill_rect(Rect::new(x, 128.0, x + 6.0, 128.0 + (i % 7) as f32 * 2.5 + 3.0), hex(0x3ddc97));
71 }
72 c
73}
74
75#[test]
76fn png_decodes_byte_identically_in_an_independent_decoder() {
77 if Command::new("python3").arg("--version").output().is_err() {
78 eprintln!("python3 not available, skipping");
79 return;
80 }
81 let dir = std::env::temp_dir().join("nox-png-test");
82 std::fs::create_dir_all(&dir).unwrap();
83 let png_path = dir.join("scene.png");
84 let out_path = dir.join("scene.raw");
85 let script = dir.join("decode.py");
86 std::fs::write(&script, DECODER).unwrap();
87
88 let c = scene();
89 let png = c.to_png();
90 std::fs::write(&png_path, &png).unwrap();
91 let expected = c.to_rgba();
92
93 let raw_bytes = c.w * c.h * 4;
94 assert!(
95 png.len() * 3 < raw_bytes,
96 "png should be at least 3x smaller than raw ({} vs {raw_bytes})",
97 png.len()
98 );
99
100 let out = Command::new("python3")
101 .arg(&script)
102 .arg(&png_path)
103 .arg(&out_path)
104 .output()
105 .expect("run decoder");
106 assert!(
107 out.status.success(),
108 "decoder rejected our png:\n{}",
109 String::from_utf8_lossy(&out.stderr)
110 );
111 assert_eq!(
112 String::from_utf8_lossy(&out.stdout).trim(),
113 format!("{} {}", c.w, c.h)
114 );
115
116 let decoded = std::fs::read(&out_path).unwrap();
117 assert_eq!(decoded.len(), expected.len(), "decoded size mismatch");
118 for (i, (a, b)) in decoded.iter().zip(expected.iter()).enumerate() {
119 assert_eq!(a, b, "pixel byte {i} differs: decoded {a} vs encoded {b}");
120 }
121}
122
123#[test]
124fn png_survives_awkward_sizes() {
125 if Command::new("python3").arg("--version").output().is_err() {
126 return;
127 }
128 let dir = std::env::temp_dir().join("nox-png-test");
129 std::fs::create_dir_all(&dir).unwrap();
130 let script = dir.join("decode.py");
131 std::fs::write(&script, DECODER).unwrap();
132
133 for (w, h) in [(1usize, 1usize), (1, 40), (40, 1), (3, 7), (255, 2), (2, 255)] {
134 let mut c = Canvas::new(w, h);
135 c.clear(rgba(9, 200, 30, 255));
136 c.fill_rect(Rect::new(0.0, 0.0, w as f32 / 2.0, h as f32 / 2.0), rgba(255, 0, 0, 255));
137 let png = c.to_png();
138 let p = dir.join(format!("s{w}x{h}.png"));
139 let o = dir.join(format!("s{w}x{h}.raw"));
140 std::fs::write(&p, &png).unwrap();
141 let out = Command::new("python3").arg(&script).arg(&p).arg(&o).output().unwrap();
142 assert!(
143 out.status.success(),
144 "{w}x{h} rejected:\n{}",
145 String::from_utf8_lossy(&out.stderr)
146 );
147 assert_eq!(std::fs::read(&o).unwrap(), c.to_rgba(), "{w}x{h} pixels differ");
148 }
149}