use falsegreen_ui_core::{PaintPrimitive, Rect, UiTree};
use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
pub const SOFTWARE_BACKEND_ID: &str = "falsegreen-ui-render/software-raster-v1";
pub const QUALIFIED_FONT_FAMILY: &str = "DejaVu Sans";
pub const QUALIFIED_FONT_ASSET: &str = "assets/fonts/DejaVuSans.ttf";
pub const QUALIFIED_FONT_SHA256: &str =
"b4c632e3cdf9acc7f28758fb5a323c8524d7fc6660d46904d9b6cbe2809c419c";
pub const QUALIFIED_FONT_BYTES: &[u8] = include_bytes!("../assets/fonts/DejaVuSans.ttf");
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FontIdentity {
pub family: String,
pub asset: String,
pub sha256: String,
pub shaping: String,
}
pub fn qualified_font_identity() -> FontIdentity {
FontIdentity {
family: QUALIFIED_FONT_FAMILY.into(),
asset: QUALIFIED_FONT_ASSET.into(),
sha256: QUALIFIED_FONT_SHA256.into(),
shaping: "semantic-text-only; glyph rasterization not authoritative".into(),
}
}
pub fn validate_qualified_font_asset() -> Result<FontIdentity, RenderError> {
validate_font_bytes(QUALIFIED_FONT_BYTES)
}
fn validate_font_bytes(bytes: &[u8]) -> Result<FontIdentity, RenderError> {
let identity = qualified_font_identity();
let actual = falsegreen_ui_core::sha256_hex(bytes);
if actual != identity.sha256 {
return Err(RenderError::FontDigestMismatch {
expected: identity.sha256,
actual,
});
}
Ok(identity)
}
pub fn validate_font_asset(path: &Path) -> Result<FontIdentity, RenderError> {
let bytes = std::fs::read(path)?;
validate_font_bytes(&bytes)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PixelAuthority {
Supplemental,
Authoritative,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RendererQualification {
pub backend: String,
pub runs: u32,
pub unique_rgba_digests: Vec<String>,
pub pixel_authority: PixelAuthority,
pub vello: Option<String>,
pub wgpu: Option<String>,
pub notes: Vec<String>,
}
pub fn run_repeatability_experiment(
tree: &UiTree,
runs: u32,
) -> Result<RendererQualification, RenderError> {
let runs = runs.max(1);
let mut digests = Vec::with_capacity(runs as usize);
for _ in 0..runs {
digests.push(render(tree)?.rgba_sha256);
}
digests.sort();
digests.dedup();
Ok(RendererQualification {
backend: SOFTWARE_BACKEND_ID.into(),
runs,
unique_rgba_digests: digests,
pixel_authority: PixelAuthority::Supplemental,
vello: None,
wgpu: None,
notes: vec![
"Vello/wgpu production qualification is deferred; neither is linked or executed by UI V1".into(),
"PNG pixels are supplemental; normalized semantics and geometry remain authoritative"
.into(),
],
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RenderIdentity {
pub backend: String,
pub pixel_format: String,
pub dpr_milli: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RenderedFrame {
pub width: u32,
pub height: u32,
pub rgba_sha256: String,
pub identity: RenderIdentity,
#[serde(skip)]
pub rgba: Vec<u8>,
}
impl RenderedFrame {
pub fn write_png(&self, path: &Path) -> Result<(), RenderError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(RenderError::Io)?;
}
let file = std::fs::File::create(path).map_err(RenderError::Io)?;
let writer = std::io::BufWriter::new(file);
let mut encoder = png::Encoder::new(writer, self.width, self.height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut stream = encoder
.write_header()
.map_err(|error| RenderError::Png(error.to_string()))?;
stream
.write_image_data(&self.rgba)
.map_err(|error| RenderError::Png(error.to_string()))?;
Ok(())
}
}
pub fn render(tree: &UiTree) -> Result<RenderedFrame, RenderError> {
tree.validate().map_err(RenderError::InvalidTree)?;
let width = tree.viewport.width;
let height = tree.viewport.height;
let mut rgba = vec![255_u8; width as usize * height as usize * 4];
let mut nodes = tree.nodes.iter().collect::<Vec<_>>();
nodes.sort_by_key(|node| {
(
node.z_index,
tree.nodes
.iter()
.position(|candidate| candidate.id == node.id)
.unwrap_or(0),
)
});
for node in nodes {
if !node.state.visible {
continue;
}
for primitive in &node.paint {
raster_primitive(&mut rgba, width, height, primitive, node.clip)?;
}
if node.paint.is_empty() && node.text.is_some() {
fill_rect(
&mut rgba,
width,
height,
node.bounds,
[55, 65, 81, 255],
node.clip,
);
}
}
let rgba_sha256 = falsegreen_ui_core::sha256_hex(&rgba);
Ok(RenderedFrame {
width,
height,
rgba_sha256,
identity: RenderIdentity {
backend: SOFTWARE_BACKEND_ID.into(),
pixel_format: "RGBA8-srgb".into(),
dpr_milli: tree.viewport.dpr_milli,
},
rgba,
})
}
fn raster_primitive(
pixels: &mut [u8],
width: u32,
height: u32,
primitive: &PaintPrimitive,
clip: Option<Rect>,
) -> Result<(), RenderError> {
match primitive {
PaintPrimitive::Fill { rect, color, .. } => {
fill_rect(pixels, width, height, *rect, *color, clip)
}
PaintPrimitive::Stroke {
rect,
color,
width: stroke_width,
..
} => {
fill_rect(
pixels,
width,
height,
Rect::new(rect.x, rect.y, rect.width, *stroke_width),
*color,
clip,
);
fill_rect(
pixels,
width,
height,
Rect::new(
rect.x,
rect.bottom() - *stroke_width,
rect.width,
*stroke_width,
),
*color,
clip,
);
fill_rect(
pixels,
width,
height,
Rect::new(rect.x, rect.y, *stroke_width, rect.height),
*color,
clip,
);
fill_rect(
pixels,
width,
height,
Rect::new(
rect.right() - *stroke_width,
rect.y,
*stroke_width,
rect.height,
),
*color,
clip,
);
}
PaintPrimitive::Text {
rect, color, text, ..
} => {
let text_width = (text.chars().count() as f32 * 5.0).min(rect.width.max(0.0));
fill_rect(
pixels,
width,
height,
Rect::new(rect.x, rect.y, text_width, rect.height.min(3.0)),
*color,
clip,
);
}
PaintPrimitive::Asset { rect, asset } => {
let digest = asset.sha256.as_bytes();
let color = [
digest.first().copied().unwrap_or(0),
digest.get(1).copied().unwrap_or(0),
digest.get(2).copied().unwrap_or(0),
255,
];
fill_rect(pixels, width, height, *rect, color, clip);
}
}
Ok(())
}
fn fill_rect(
pixels: &mut [u8],
width: u32,
height: u32,
rect: Rect,
color: [u8; 4],
clip: Option<Rect>,
) {
let clipped = clip
.and_then(|clip| rect.intersection(clip))
.unwrap_or(rect);
let x0 = clipped.x.floor().max(0.0) as u32;
let y0 = clipped.y.floor().max(0.0) as u32;
let x1 = clipped.right().ceil().min(width as f32).max(0.0) as u32;
let y1 = clipped.bottom().ceil().min(height as f32).max(0.0) as u32;
for y in y0.min(height)..y1.min(height) {
for x in x0.min(width)..x1.min(width) {
let index = ((y * width + x) * 4) as usize;
if color[3] == 255 {
pixels[index..index + 4].copy_from_slice(&color);
} else if color[3] != 0 {
let alpha = color[3] as u16;
let inverse = 255_u16 - alpha;
for channel in 0..3 {
pixels[index + channel] = ((color[channel] as u16 * alpha
+ pixels[index + channel] as u16 * inverse)
/ 255) as u8;
}
pixels[index + 3] =
(alpha + pixels[index + 3] as u16 * inverse / 255).min(255) as u8;
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrameDiff {
pub differing_pixels: u64,
pub total_pixels: u64,
pub max_channel_delta: u8,
pub tolerance: u8,
}
impl FrameDiff {
pub fn passes(&self) -> bool {
self.differing_pixels == 0
}
}
pub fn diff(
left: &RenderedFrame,
right: &RenderedFrame,
tolerance: u8,
) -> Result<FrameDiff, RenderError> {
if left.width != right.width || left.height != right.height {
return Err(RenderError::SizeMismatch);
}
let mut differing_pixels = 0;
let mut max_channel_delta = 0;
for channels in left.rgba.chunks_exact(4).zip(right.rgba.chunks_exact(4)) {
let delta = channels
.0
.iter()
.zip(channels.1.iter())
.map(|(a, b)| a.abs_diff(*b))
.max()
.unwrap_or(0);
max_channel_delta = max_channel_delta.max(delta);
if delta > tolerance {
differing_pixels += 1;
}
}
Ok(FrameDiff {
differing_pixels,
total_pixels: (left.width * left.height) as u64,
max_channel_delta,
tolerance,
})
}
#[derive(Debug, Error)]
pub enum RenderError {
#[error("normalized tree is invalid: {0}")]
InvalidTree(falsegreen_ui_core::ValidationError),
#[error("frame sizes do not match")]
SizeMismatch,
#[error("PNG error: {0}")]
Png(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("font digest mismatch: expected {expected}, got {actual}")]
FontDigestMismatch { expected: String, actual: String },
}
#[cfg(test)]
mod tests {
use super::*;
use falsegreen_ui_core::{Role, UiNode, Viewport};
fn tree() -> UiTree {
let root = UiNode::new("root", Role::Application, Rect::new(0.0, 0.0, 16.0, 16.0)).paint(
PaintPrimitive::Fill {
rect: Rect::new(0.0, 0.0, 16.0, 16.0),
color: [10, 20, 30, 255],
radius: 0.0,
},
);
UiTree::new(Viewport::new(16, 16), "root", vec![root])
}
#[test]
fn software_capture_is_repeatable() {
let a = render(&tree()).unwrap();
let b = render(&tree()).unwrap();
assert_eq!(a.rgba_sha256, b.rgba_sha256);
assert!(diff(&a, &b, 0).unwrap().passes());
}
#[test]
fn repeatability_experiment_records_supplemental_authority() {
let qualification = run_repeatability_experiment(&tree(), 3).unwrap();
assert_eq!(qualification.runs, 3);
assert_eq!(qualification.unique_rgba_digests.len(), 1);
assert_eq!(qualification.pixel_authority, PixelAuthority::Supplemental);
assert_eq!(qualified_font_identity().sha256, QUALIFIED_FONT_SHA256);
}
#[test]
fn missing_or_changed_font_cannot_be_silent() {
let missing = validate_font_asset(Path::new("work/missing-font.ttf"));
assert!(matches!(missing, Err(RenderError::Io(_))));
let identity = validate_qualified_font_asset().unwrap();
assert_eq!(
falsegreen_ui_core::sha256_hex(QUALIFIED_FONT_BYTES),
QUALIFIED_FONT_SHA256
);
assert_eq!(identity.sha256, QUALIFIED_FONT_SHA256);
let changed =
std::env::temp_dir().join(format!("falsegreen-ui-changed-font-{}", std::process::id()));
std::fs::write(&changed, b"changed-font").unwrap();
assert!(matches!(
validate_font_asset(&changed),
Err(RenderError::FontDigestMismatch { .. })
));
std::fs::remove_file(changed).unwrap();
}
}