1use falsegreen_ui_core::{PaintPrimitive, Rect, UiTree};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use thiserror::Error;
10
11pub const SOFTWARE_BACKEND_ID: &str = "falsegreen-ui-render/software-raster-v1";
12pub const QUALIFIED_FONT_FAMILY: &str = "DejaVu Sans";
13pub const QUALIFIED_FONT_ASSET: &str = "assets/fonts/DejaVuSans.ttf";
14pub const QUALIFIED_FONT_SHA256: &str =
15 "b4c632e3cdf9acc7f28758fb5a323c8524d7fc6660d46904d9b6cbe2809c419c";
16pub const QUALIFIED_FONT_BYTES: &[u8] = include_bytes!("../assets/fonts/DejaVuSans.ttf");
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct FontIdentity {
20 pub family: String,
21 pub asset: String,
22 pub sha256: String,
23 pub shaping: String,
24}
25
26pub fn qualified_font_identity() -> FontIdentity {
27 FontIdentity {
28 family: QUALIFIED_FONT_FAMILY.into(),
29 asset: QUALIFIED_FONT_ASSET.into(),
30 sha256: QUALIFIED_FONT_SHA256.into(),
31 shaping: "semantic-text-only; glyph rasterization not authoritative".into(),
32 }
33}
34
35pub fn validate_qualified_font_asset() -> Result<FontIdentity, RenderError> {
37 validate_font_bytes(QUALIFIED_FONT_BYTES)
38}
39
40fn validate_font_bytes(bytes: &[u8]) -> Result<FontIdentity, RenderError> {
41 let identity = qualified_font_identity();
42 let actual = falsegreen_ui_core::sha256_hex(bytes);
43 if actual != identity.sha256 {
44 return Err(RenderError::FontDigestMismatch {
45 expected: identity.sha256,
46 actual,
47 });
48 }
49 Ok(identity)
50}
51
52pub fn validate_font_asset(path: &Path) -> Result<FontIdentity, RenderError> {
54 let bytes = std::fs::read(path)?;
55 validate_font_bytes(&bytes)
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum PixelAuthority {
61 Supplemental,
62 Authoritative,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct RendererQualification {
67 pub backend: String,
68 pub runs: u32,
69 pub unique_rgba_digests: Vec<String>,
70 pub pixel_authority: PixelAuthority,
71 pub vello: Option<String>,
72 pub wgpu: Option<String>,
73 pub notes: Vec<String>,
74}
75
76pub fn run_repeatability_experiment(
79 tree: &UiTree,
80 runs: u32,
81) -> Result<RendererQualification, RenderError> {
82 let runs = runs.max(1);
83 let mut digests = Vec::with_capacity(runs as usize);
84 for _ in 0..runs {
85 digests.push(render(tree)?.rgba_sha256);
86 }
87 digests.sort();
88 digests.dedup();
89 Ok(RendererQualification {
90 backend: SOFTWARE_BACKEND_ID.into(),
91 runs,
92 unique_rgba_digests: digests,
93 pixel_authority: PixelAuthority::Supplemental,
94 vello: None,
95 wgpu: None,
96 notes: vec![
97 "Vello/wgpu production qualification is deferred; neither is linked or executed by UI V1".into(),
98 "PNG pixels are supplemental; normalized semantics and geometry remain authoritative"
99 .into(),
100 ],
101 })
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct RenderIdentity {
106 pub backend: String,
107 pub pixel_format: String,
108 pub dpr_milli: u32,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct RenderedFrame {
113 pub width: u32,
114 pub height: u32,
115 pub rgba_sha256: String,
116 pub identity: RenderIdentity,
117 #[serde(skip)]
118 pub rgba: Vec<u8>,
119}
120
121impl RenderedFrame {
122 pub fn write_png(&self, path: &Path) -> Result<(), RenderError> {
123 if let Some(parent) = path.parent() {
124 std::fs::create_dir_all(parent).map_err(RenderError::Io)?;
125 }
126 let file = std::fs::File::create(path).map_err(RenderError::Io)?;
127 let writer = std::io::BufWriter::new(file);
128 let mut encoder = png::Encoder::new(writer, self.width, self.height);
129 encoder.set_color(png::ColorType::Rgba);
130 encoder.set_depth(png::BitDepth::Eight);
131 let mut stream = encoder
132 .write_header()
133 .map_err(|error| RenderError::Png(error.to_string()))?;
134 stream
135 .write_image_data(&self.rgba)
136 .map_err(|error| RenderError::Png(error.to_string()))?;
137 Ok(())
138 }
139}
140
141pub fn render(tree: &UiTree) -> Result<RenderedFrame, RenderError> {
142 tree.validate().map_err(RenderError::InvalidTree)?;
143 let width = tree.viewport.width;
144 let height = tree.viewport.height;
145 let mut rgba = vec![255_u8; width as usize * height as usize * 4];
146 let mut nodes = tree.nodes.iter().collect::<Vec<_>>();
147 nodes.sort_by_key(|node| {
148 (
149 node.z_index,
150 tree.nodes
151 .iter()
152 .position(|candidate| candidate.id == node.id)
153 .unwrap_or(0),
154 )
155 });
156 for node in nodes {
157 if !node.state.visible {
158 continue;
159 }
160 for primitive in &node.paint {
161 raster_primitive(&mut rgba, width, height, primitive, node.clip)?;
162 }
163 if node.paint.is_empty() && node.text.is_some() {
166 fill_rect(
167 &mut rgba,
168 width,
169 height,
170 node.bounds,
171 [55, 65, 81, 255],
172 node.clip,
173 );
174 }
175 }
176 let rgba_sha256 = falsegreen_ui_core::sha256_hex(&rgba);
177 Ok(RenderedFrame {
178 width,
179 height,
180 rgba_sha256,
181 identity: RenderIdentity {
182 backend: SOFTWARE_BACKEND_ID.into(),
183 pixel_format: "RGBA8-srgb".into(),
184 dpr_milli: tree.viewport.dpr_milli,
185 },
186 rgba,
187 })
188}
189
190fn raster_primitive(
191 pixels: &mut [u8],
192 width: u32,
193 height: u32,
194 primitive: &PaintPrimitive,
195 clip: Option<Rect>,
196) -> Result<(), RenderError> {
197 match primitive {
198 PaintPrimitive::Fill { rect, color, .. } => {
199 fill_rect(pixels, width, height, *rect, *color, clip)
200 }
201 PaintPrimitive::Stroke {
202 rect,
203 color,
204 width: stroke_width,
205 ..
206 } => {
207 fill_rect(
208 pixels,
209 width,
210 height,
211 Rect::new(rect.x, rect.y, rect.width, *stroke_width),
212 *color,
213 clip,
214 );
215 fill_rect(
216 pixels,
217 width,
218 height,
219 Rect::new(
220 rect.x,
221 rect.bottom() - *stroke_width,
222 rect.width,
223 *stroke_width,
224 ),
225 *color,
226 clip,
227 );
228 fill_rect(
229 pixels,
230 width,
231 height,
232 Rect::new(rect.x, rect.y, *stroke_width, rect.height),
233 *color,
234 clip,
235 );
236 fill_rect(
237 pixels,
238 width,
239 height,
240 Rect::new(
241 rect.right() - *stroke_width,
242 rect.y,
243 *stroke_width,
244 rect.height,
245 ),
246 *color,
247 clip,
248 );
249 }
250 PaintPrimitive::Text {
251 rect, color, text, ..
252 } => {
253 let text_width = (text.chars().count() as f32 * 5.0).min(rect.width.max(0.0));
255 fill_rect(
256 pixels,
257 width,
258 height,
259 Rect::new(rect.x, rect.y, text_width, rect.height.min(3.0)),
260 *color,
261 clip,
262 );
263 }
264 PaintPrimitive::Asset { rect, asset } => {
265 let digest = asset.sha256.as_bytes();
266 let color = [
267 digest.first().copied().unwrap_or(0),
268 digest.get(1).copied().unwrap_or(0),
269 digest.get(2).copied().unwrap_or(0),
270 255,
271 ];
272 fill_rect(pixels, width, height, *rect, color, clip);
273 }
274 }
275 Ok(())
276}
277
278fn fill_rect(
279 pixels: &mut [u8],
280 width: u32,
281 height: u32,
282 rect: Rect,
283 color: [u8; 4],
284 clip: Option<Rect>,
285) {
286 let clipped = clip
287 .and_then(|clip| rect.intersection(clip))
288 .unwrap_or(rect);
289 let x0 = clipped.x.floor().max(0.0) as u32;
290 let y0 = clipped.y.floor().max(0.0) as u32;
291 let x1 = clipped.right().ceil().min(width as f32).max(0.0) as u32;
292 let y1 = clipped.bottom().ceil().min(height as f32).max(0.0) as u32;
293 for y in y0.min(height)..y1.min(height) {
294 for x in x0.min(width)..x1.min(width) {
295 let index = ((y * width + x) * 4) as usize;
296 if color[3] == 255 {
297 pixels[index..index + 4].copy_from_slice(&color);
298 } else if color[3] != 0 {
299 let alpha = color[3] as u16;
300 let inverse = 255_u16 - alpha;
301 for channel in 0..3 {
302 pixels[index + channel] = ((color[channel] as u16 * alpha
303 + pixels[index + channel] as u16 * inverse)
304 / 255) as u8;
305 }
306 pixels[index + 3] =
307 (alpha + pixels[index + 3] as u16 * inverse / 255).min(255) as u8;
308 }
309 }
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct FrameDiff {
315 pub differing_pixels: u64,
316 pub total_pixels: u64,
317 pub max_channel_delta: u8,
318 pub tolerance: u8,
319}
320
321impl FrameDiff {
322 pub fn passes(&self) -> bool {
323 self.differing_pixels == 0
324 }
325}
326
327pub fn diff(
328 left: &RenderedFrame,
329 right: &RenderedFrame,
330 tolerance: u8,
331) -> Result<FrameDiff, RenderError> {
332 if left.width != right.width || left.height != right.height {
333 return Err(RenderError::SizeMismatch);
334 }
335 let mut differing_pixels = 0;
336 let mut max_channel_delta = 0;
337 for channels in left.rgba.chunks_exact(4).zip(right.rgba.chunks_exact(4)) {
338 let delta = channels
339 .0
340 .iter()
341 .zip(channels.1.iter())
342 .map(|(a, b)| a.abs_diff(*b))
343 .max()
344 .unwrap_or(0);
345 max_channel_delta = max_channel_delta.max(delta);
346 if delta > tolerance {
347 differing_pixels += 1;
348 }
349 }
350 Ok(FrameDiff {
351 differing_pixels,
352 total_pixels: (left.width * left.height) as u64,
353 max_channel_delta,
354 tolerance,
355 })
356}
357
358#[derive(Debug, Error)]
359pub enum RenderError {
360 #[error("normalized tree is invalid: {0}")]
361 InvalidTree(falsegreen_ui_core::ValidationError),
362 #[error("frame sizes do not match")]
363 SizeMismatch,
364 #[error("PNG error: {0}")]
365 Png(String),
366 #[error("I/O error: {0}")]
367 Io(#[from] std::io::Error),
368 #[error("font digest mismatch: expected {expected}, got {actual}")]
369 FontDigestMismatch { expected: String, actual: String },
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use falsegreen_ui_core::{Role, UiNode, Viewport};
376
377 fn tree() -> UiTree {
378 let root = UiNode::new("root", Role::Application, Rect::new(0.0, 0.0, 16.0, 16.0)).paint(
379 PaintPrimitive::Fill {
380 rect: Rect::new(0.0, 0.0, 16.0, 16.0),
381 color: [10, 20, 30, 255],
382 radius: 0.0,
383 },
384 );
385 UiTree::new(Viewport::new(16, 16), "root", vec![root])
386 }
387
388 #[test]
389 fn software_capture_is_repeatable() {
390 let a = render(&tree()).unwrap();
391 let b = render(&tree()).unwrap();
392 assert_eq!(a.rgba_sha256, b.rgba_sha256);
393 assert!(diff(&a, &b, 0).unwrap().passes());
394 }
395
396 #[test]
397 fn repeatability_experiment_records_supplemental_authority() {
398 let qualification = run_repeatability_experiment(&tree(), 3).unwrap();
399 assert_eq!(qualification.runs, 3);
400 assert_eq!(qualification.unique_rgba_digests.len(), 1);
401 assert_eq!(qualification.pixel_authority, PixelAuthority::Supplemental);
402 assert_eq!(qualified_font_identity().sha256, QUALIFIED_FONT_SHA256);
403 }
404
405 #[test]
406 fn missing_or_changed_font_cannot_be_silent() {
407 let missing = validate_font_asset(Path::new("work/missing-font.ttf"));
408 assert!(matches!(missing, Err(RenderError::Io(_))));
409
410 let identity = validate_qualified_font_asset().unwrap();
411 assert_eq!(
412 falsegreen_ui_core::sha256_hex(QUALIFIED_FONT_BYTES),
413 QUALIFIED_FONT_SHA256
414 );
415 assert_eq!(identity.sha256, QUALIFIED_FONT_SHA256);
416
417 let changed =
418 std::env::temp_dir().join(format!("falsegreen-ui-changed-font-{}", std::process::id()));
419 std::fs::write(&changed, b"changed-font").unwrap();
420 assert!(matches!(
421 validate_font_asset(&changed),
422 Err(RenderError::FontDigestMismatch { .. })
423 ));
424 std::fs::remove_file(changed).unwrap();
425 }
426}