diff --git a/Cargo.lock b/Cargo.lock index 3a5bb5c..84ab13f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,7 +6,11 @@ version = 4 name = "generate-icon" version = "0.1.0" dependencies = [ + "we-dom", + "we-html", "we-image", + "we-svg", + "we-text", ] [[package]] diff --git a/build.sh b/build.sh index ed0e1ac..ce48032 100755 --- a/build.sh +++ b/build.sh @@ -24,6 +24,7 @@ ICONSET_DIR="$ICON_CACHE_DIR/AppIcon.iconset" ICON_PNG="$ICON_CACHE_DIR/icon_1024.png" ICON_ICNS="$ICON_CACHE_DIR/AppIcon.icns" ICON_TOOL_SRC="$PROJECT_DIR/tools/generate_icon/src/main.rs" +ICON_SVG_SRC="$PROJECT_DIR/tools/generate_icon/assets/app_icon.svg" ENTITLEMENTS="$PROJECT_DIR/tools/we.entitlements" BROWSER_BIN="$PROJECT_DIR/target/aarch64-apple-darwin/release/browser" VERSION="$(awk ' @@ -52,7 +53,7 @@ rm -rf "$APP_DIR" mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" cp "$BROWSER_BIN" "$APP_DIR/Contents/MacOS/we" -if [ ! -f "$ICON_ICNS" ] || [ "$ICON_TOOL_SRC" -nt "$ICON_ICNS" ]; then +if [ ! -f "$ICON_ICNS" ] || [ "$ICON_TOOL_SRC" -nt "$ICON_ICNS" ] || [ "$ICON_SVG_SRC" -nt "$ICON_ICNS" ]; then echo "Generating app icon..." rm -rf "$ICONSET_DIR" mkdir -p "$ICONSET_DIR" diff --git a/crates/svg/src/render.rs b/crates/svg/src/render.rs index fe21e92..a1a99a1 100644 --- a/crates/svg/src/render.rs +++ b/crates/svg/src/render.rs @@ -42,9 +42,30 @@ impl SvgColor { } } +#[derive(Debug, Clone)] +struct GradientStop { + offset: f32, + color: SvgColor, +} + +#[derive(Debug, Clone)] +struct LinearGradient { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + stops: Vec, +} + +#[derive(Debug, Clone)] +enum Paint { + Solid(SvgColor), + LinearGradient(LinearGradient), +} + /// SVG presentation attributes for a single element. struct PresentationAttrs { - fill: Option, + fill: Option, stroke: Option, stroke_width: f32, opacity: f32, @@ -56,7 +77,7 @@ struct PresentationAttrs { impl Default for PresentationAttrs { fn default() -> Self { PresentationAttrs { - fill: Some(SvgColor::black()), + fill: Some(Paint::Solid(SvgColor::black())), stroke: None, stroke_width: 1.0, opacity: 1.0, @@ -337,9 +358,8 @@ fn render_text( let text = doc.deep_text_content(node); - let fill_color = attrs.fill.unwrap_or_else(SvgColor::black); - let alpha = (attrs.opacity * attrs.fill_opacity * 255.0) as u8; - let color = SvgColor::new(fill_color.r, fill_color.g, fill_color.b, alpha); + let default_fill = Paint::Solid(SvgColor::black()); + let fill = attrs.fill.as_ref().unwrap_or(&default_fill); let glyphs = font.shape_text(&text, font_size); for glyph in &glyphs { @@ -358,6 +378,8 @@ fn render_text( let py = by + row as f32; let (tx, ty) = transform.apply(px, py); + let color = + sample_paint(fill, tx, ty, attrs.opacity * attrs.fill_opacity, transform); let a = (coverage as u32 * color.a as u32) / 255; if a > 0 { canvas.blend_pixel( @@ -770,10 +792,14 @@ fn fill_and_stroke( let segments = flatten_path(path); // Fill. - if let Some(fill_color) = attrs.fill { - let alpha = (attrs.opacity * attrs.fill_opacity * 255.0) as u8; - let color = SvgColor::new(fill_color.r, fill_color.g, fill_color.b, alpha); - fill_segments(&segments, &color, transform, canvas); + if let Some(fill) = attrs.fill.as_ref() { + fill_segments( + &segments, + fill, + attrs.opacity * attrs.fill_opacity, + transform, + canvas, + ); } // Stroke. @@ -787,7 +813,8 @@ fn fill_and_stroke( /// Fill a set of segments using scanline rasterization with the even-odd rule. fn fill_segments( segments: &[Segment], - color: &SvgColor, + paint: &Paint, + alpha: f32, transform: &Transform, canvas: &mut Canvas, ) { @@ -847,13 +874,73 @@ fn fill_segments( let x_start = (intersections[i].ceil() as i32).max(0); let x_end = (intersections[i + 1].floor() as i32 + 1).min(canvas.width as i32); for x in x_start..x_end { - canvas.blend_pixel(x, y, *color); + let color = sample_paint(paint, x as f32 + 0.5, scan_y, alpha, transform); + canvas.blend_pixel(x, y, color); } i += 2; } } } +fn sample_paint(paint: &Paint, x: f32, y: f32, alpha: f32, transform: &Transform) -> SvgColor { + let color = match paint { + Paint::Solid(color) => *color, + Paint::LinearGradient(gradient) => sample_linear_gradient(gradient, x, y, transform), + }; + + let out_alpha = (color.a as f32 * alpha).clamp(0.0, 255.0) as u8; + SvgColor::new(color.r, color.g, color.b, out_alpha) +} + +fn sample_linear_gradient( + gradient: &LinearGradient, + x: f32, + y: f32, + transform: &Transform, +) -> SvgColor { + if gradient.stops.is_empty() { + return SvgColor::transparent(); + } + + let (x1, y1) = transform.apply(gradient.x1, gradient.y1); + let (x2, y2) = transform.apply(gradient.x2, gradient.y2); + let dx = x2 - x1; + let dy = y2 - y1; + let len_sq = dx * dx + dy * dy; + let t = if len_sq <= 0.0001 { + 0.0 + } else { + (((x - x1) * dx + (y - y1) * dy) / len_sq).clamp(0.0, 1.0) + }; + + let mut previous = &gradient.stops[0]; + for stop in &gradient.stops { + if t <= stop.offset { + let span = (stop.offset - previous.offset).max(0.0001); + let local_t = ((t - previous.offset) / span).clamp(0.0, 1.0); + return lerp_color(previous.color, stop.color, local_t); + } + previous = stop; + } + + previous.color +} + +fn lerp_color(a: SvgColor, b: SvgColor, t: f32) -> SvgColor { + SvgColor::new( + lerp_u8(a.r, b.r, t), + lerp_u8(a.g, b.g, t), + lerp_u8(a.b, b.b, t), + lerp_u8(a.a, b.a, t), + ) +} + +fn lerp_u8(a: u8, b: u8, t: f32) -> u8 { + (a as f32 + (b as f32 - a as f32) * t) + .round() + .clamp(0.0, 255.0) as u8 +} + /// Stroke segments by expanding each segment into a thick line. fn stroke_segments( segments: &[Segment], @@ -919,7 +1006,13 @@ fn stroke_segments( ]; // Use identity transform since we already transformed. - fill_segments(&quad_segs, color, &Transform::identity(), canvas); + fill_segments( + &quad_segs, + &Paint::Solid(*color), + 1.0, + &Transform::identity(), + canvas, + ); } } @@ -978,7 +1071,7 @@ fn parse_presentation_attrs(doc: &Document, node: NodeId) -> PresentationAttrs { if fill == "none" { attrs.fill = None; } else { - attrs.fill = parse_svg_color(fill); + attrs.fill = parse_svg_paint(doc, fill); } } @@ -1042,6 +1135,82 @@ fn parse_view_box(s: Option<&str>) -> Option<(f32, f32, f32, f32)> { } } +fn parse_svg_paint(doc: &Document, s: &str) -> Option { + let s = s.trim(); + if let Some(id) = parse_url_id(s) { + return find_element_by_id(doc, doc.root(), id) + .and_then(|node| parse_linear_gradient(doc, node)); + } + + parse_svg_color(s).map(Paint::Solid) +} + +fn parse_url_id(s: &str) -> Option<&str> { + let inner = s.strip_prefix("url(")?.strip_suffix(')')?.trim(); + inner.strip_prefix('#') +} + +fn find_element_by_id(doc: &Document, node: NodeId, id: &str) -> Option { + if doc.get_attribute(node, "id") == Some(id) { + return Some(node); + } + + for child in doc.children(node) { + if let Some(found) = find_element_by_id(doc, child, id) { + return Some(found); + } + } + + None +} + +fn parse_linear_gradient(doc: &Document, node: NodeId) -> Option { + let tag = doc.tag_name(node)?; + if !tag.eq_ignore_ascii_case("lineargradient") { + return None; + } + + let mut stops = Vec::new(); + for child in doc.children(node) { + if doc + .tag_name(child) + .is_some_and(|tag| tag.eq_ignore_ascii_case("stop")) + { + let offset = parse_gradient_offset(doc.get_attribute(child, "offset").unwrap_or("0")); + let mut color = + parse_svg_color(doc.get_attribute(child, "stop-color").unwrap_or("black"))?; + if let Some(opacity) = doc.get_attribute(child, "stop-opacity") { + color.a = (color.a as f32 * parse_length(opacity).clamp(0.0, 1.0)) as u8; + } + stops.push(GradientStop { offset, color }); + } + } + if stops.is_empty() { + return None; + } + stops.sort_by(|a, b| { + a.offset + .partial_cmp(&b.offset) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + Some(Paint::LinearGradient(LinearGradient { + x1: parse_length(doc.get_attribute(node, "x1").unwrap_or("0")), + y1: parse_length(doc.get_attribute(node, "y1").unwrap_or("0")), + x2: parse_length(doc.get_attribute(node, "x2").unwrap_or("0")), + y2: parse_length(doc.get_attribute(node, "y2").unwrap_or("1")), + stops, + })) +} + +fn parse_gradient_offset(s: &str) -> f32 { + let s = s.trim(); + if let Some(percent) = s.strip_suffix('%') { + return (parse_length(percent) / 100.0).clamp(0.0, 1.0); + } + parse_length(s).clamp(0.0, 1.0) +} + /// Parse an SVG color string. pub fn parse_svg_color(s: &str) -> Option { let s = s.trim(); @@ -1142,6 +1311,46 @@ mod tests { assert_eq!(data[center + 3], 255); // A } + #[test] + fn render_rect_with_linear_gradient() { + let mut doc = Document::new(); + let root = doc.root(); + let svg = doc.create_element_ns("svg", Some("http://www.w3.org/2000/svg")); + doc.set_attribute(svg, "width", "100"); + doc.set_attribute(svg, "height", "100"); + doc.append_child(root, svg); + + let gradient = doc.create_element_ns("linearGradient", Some("http://www.w3.org/2000/svg")); + doc.set_attribute(gradient, "id", "bg"); + doc.set_attribute(gradient, "x1", "0"); + doc.set_attribute(gradient, "y1", "0"); + doc.set_attribute(gradient, "x2", "0"); + doc.set_attribute(gradient, "y2", "100"); + doc.append_child(svg, gradient); + + let stop1 = doc.create_element_ns("stop", Some("http://www.w3.org/2000/svg")); + doc.set_attribute(stop1, "offset", "0%"); + doc.set_attribute(stop1, "stop-color", "#ff0000"); + doc.append_child(gradient, stop1); + + let stop2 = doc.create_element_ns("stop", Some("http://www.w3.org/2000/svg")); + doc.set_attribute(stop2, "offset", "100%"); + doc.set_attribute(stop2, "stop-color", "#0000ff"); + doc.append_child(gradient, stop2); + + let rect = doc.create_element_ns("rect", Some("http://www.w3.org/2000/svg")); + doc.set_attribute(rect, "width", "100"); + doc.set_attribute(rect, "height", "100"); + doc.set_attribute(rect, "fill", "url(#bg)"); + doc.append_child(svg, rect); + + let (_w, _h, data) = render_svg(&doc, svg, None).expect("render"); + let top = ((10 * 100 + 50) * 4) as usize; + let bottom = ((90 * 100 + 50) * 4) as usize; + assert!(data[top] > data[top + 2]); + assert!(data[bottom + 2] > data[bottom]); + } + #[test] fn render_blue_circle() { let mut doc = Document::new(); diff --git a/tools/generate_icon/Cargo.toml b/tools/generate_icon/Cargo.toml index 07fed80..30113fa 100644 --- a/tools/generate_icon/Cargo.toml +++ b/tools/generate_icon/Cargo.toml @@ -4,4 +4,8 @@ version = "0.1.0" edition.workspace = true [dependencies] +we-dom = { path = "../../crates/dom" } +we-html = { path = "../../crates/html" } we-image = { path = "../../crates/image" } +we-svg = { path = "../../crates/svg" } +we-text = { path = "../../crates/text" } diff --git a/tools/generate_icon/assets/app_icon.svg b/tools/generate_icon/assets/app_icon.svg new file mode 100644 index 0000000..e811252 --- /dev/null +++ b/tools/generate_icon/assets/app_icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + we + diff --git a/tools/generate_icon/src/main.rs b/tools/generate_icon/src/main.rs index 0d7b13a..699e98f 100644 --- a/tools/generate_icon/src/main.rs +++ b/tools/generate_icon/src/main.rs @@ -3,10 +3,15 @@ use std::fs; use std::io; use std::path::Path; +use we_dom::{Document, NodeId}; +use we_html::parse_html; use we_image::png::encode_png_rgba; +use we_svg::render_svg; +use we_text::font::{load_system_font, Font}; const SIZE: u32 = 1024; -const RADIUS: f32 = 220.0; +const RENDER_SCALE: u32 = 1; +const ICON_SVG: &str = include_str!("../assets/app_icon.svg"); fn main() -> Result<(), Box> { let mut args = env::args_os(); @@ -21,7 +26,7 @@ fn main() -> Result<(), Box> { std::process::exit(2); } - let pixels = render_icon(); + let pixels = render_icon().map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; let png = encode_png_rgba(&pixels, SIZE, SIZE); if let Some(parent) = Path::new(&output).parent() { fs::create_dir_all(parent)?; @@ -31,213 +36,183 @@ fn main() -> Result<(), Box> { Ok(()) } -fn render_icon() -> Vec { - let mut pixels = vec![0; (SIZE * SIZE * 4) as usize]; - let size = SIZE as usize; - - for y in 0..size { - for x in 0..size { - let idx = (y * size + x) * 4; - let fx = x as f32 + 0.5; - let fy = y as f32 + 0.5; - let mask = rounded_rect_coverage( - fx, - fy, - 58.0, - 58.0, - SIZE as f32 - 116.0, - SIZE as f32 - 116.0, - RADIUS, - ); - - if mask <= 0.0 { - pixels[idx + 3] = 0; - continue; - } +fn render_icon() -> Result, String> { + let mut doc = parse_html(ICON_SVG); + let svg = + find_svg_node(&doc, doc.root()).ok_or("app icon SVG source does not contain ")?; + if doc.get_attribute(svg, "viewBox").is_none() { + if let Some(view_box) = doc.get_attribute(svg, "viewbox").map(str::to_string) { + doc.set_attribute(svg, "viewBox", &view_box); + } + } - let u = fx / SIZE as f32; - let v = fy / SIZE as f32; - let glow = (1.0 - ((u - 0.68).powi(2) + (v - 0.24).powi(2)).sqrt()).clamp(0.0, 1.0); - let bg = [ - lerp(18.0, 26.0, v) + 42.0 * glow, - lerp(86.0, 178.0, u) + 20.0 * glow, - lerp(142.0, 212.0, (u + v) * 0.5) + 22.0 * glow, - ]; - let shade = if fy > 760.0 { 0.88 } else { 1.0 }; - - pixels[idx] = (bg[0] * shade).clamp(0.0, 255.0) as u8; - pixels[idx + 1] = (bg[1] * shade).clamp(0.0, 255.0) as u8; - pixels[idx + 2] = (bg[2] * shade).clamp(0.0, 255.0) as u8; - pixels[idx + 3] = (mask * 255.0) as u8; + let render_size = SIZE * RENDER_SCALE; + let render_size_attr = render_size.to_string(); + doc.set_attribute(svg, "width", &render_size_attr); + doc.set_attribute(svg, "height", &render_size_attr); + + let font = load_logo_font()?; + center_logo_text(&mut doc, svg, &font)?; + let (width, height, pixels) = + render_svg(&doc, svg, Some(&font)).ok_or("failed to rasterize app icon SVG")?; + if width != render_size || height != render_size { + return Err(format!( + "app icon SVG rendered at {width}x{height}, expected {render_size}x{render_size}" + )); + } + + downsample_rgba(&pixels, width, height, SIZE, SIZE) +} + +fn load_logo_font() -> Result { + for path in [ + "/System/Library/Fonts/Supplemental/Arial Rounded Bold.ttf", + "/System/Library/Fonts/SFNSRounded.ttf", + "/System/Library/Fonts/Supplemental/Arial Bold.ttf", + ] { + let path = Path::new(path); + if path.exists() { + if let Ok(font) = Font::from_file(path) { + return Ok(font); + } } } - draw_wordmark(&mut pixels); - pixels + load_system_font().map_err(|err| format!("failed to load logo font: {err}")) } -fn draw_wordmark(pixels: &mut [u8]) { - let white = [244, 249, 255, 255]; - let shadow = [4, 24, 42, 96]; +fn center_logo_text(doc: &mut Document, svg: NodeId, font: &Font) -> Result<(), String> { + let text_node = + find_text_node(doc, svg).ok_or("app icon SVG source does not contain ")?; + let font_size = parse_attr_f32(doc, text_node, "font-size").unwrap_or(510.0); + let text = doc.deep_text_content(text_node); + let Some((min_x, min_y, max_x, max_y)) = text_bounds(font, &text, font_size) else { + return Err("app icon text did not produce any painted glyphs".to_string()); + }; - for offset in [(0, 18), (0, 10)] { - draw_w(pixels, 160 + offset.0, 312 + offset.1, shadow); - draw_e(pixels, 560 + offset.0, 320 + offset.1, 300, 360, shadow); - } + let text_width = max_x - min_x; + let text_height = max_y - min_y; + let x = (SIZE as f32 - text_width) * 0.5 - min_x; + let y = (SIZE as f32 - text_height) * 0.5 - min_y; - draw_w(pixels, 160, 292, white); - draw_e(pixels, 560, 300, 300, 360, white); + doc.set_attribute(text_node, "x", &format!("{x:.0}")); + doc.set_attribute(text_node, "y", &format!("{y:.0}")); + Ok(()) } -fn draw_w(pixels: &mut [u8], x: i32, y: i32, color: [u8; 4]) { - let points = [ - (x + 12, y + 18), - (x + 112, y + 362), - (x + 210, y + 154), - (x + 308, y + 362), - (x + 408, y + 18), - ]; - - for pair in points.windows(2) { - let [(x1, y1), (x2, y2)] = pair else { - continue; - }; - draw_round_line(pixels, *x1, *y1, *x2, *y2, 78, color); +fn text_bounds(font: &Font, text: &str, font_size: f32) -> Option<(f32, f32, f32, f32)> { + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + + for glyph in font.shape_text(text, font_size) { + let bitmap = font.rasterize_glyph(glyph.glyph_id, font_size)?; + let left = glyph.x_offset + bitmap.bearing_x as f32; + let top = glyph.y_offset - bitmap.bearing_y as f32; + let right = left + bitmap.width as f32; + let bottom = top + bitmap.height as f32; + + min_x = min_x.min(left); + min_y = min_y.min(top); + max_x = max_x.max(right); + max_y = max_y.max(bottom); } -} -fn draw_e(pixels: &mut [u8], x: i32, y: i32, width: i32, height: i32, color: [u8; 4]) { - let stroke = 82; - draw_round_rect(pixels, x, y, stroke, height, 34, color); - draw_round_rect(pixels, x, y, width, stroke, 34, color); - draw_round_rect( - pixels, - x, - y + height / 2 - stroke / 2, - width - 36, - stroke, - 34, - color, - ); - draw_round_rect(pixels, x, y + height - stroke, width, stroke, 34, color); + min_x.is_finite().then_some((min_x, min_y, max_x, max_y)) } -fn draw_round_rect( - pixels: &mut [u8], - x: i32, - y: i32, - width: i32, - height: i32, - radius: i32, - color: [u8; 4], -) { - let min_x = x.max(0) as usize; - let min_y = y.max(0) as usize; - let max_x = (x + width).min(SIZE as i32) as usize; - let max_y = (y + height).min(SIZE as i32) as usize; - - for py in min_y..max_y { - for px in min_x..max_x { - let coverage = rounded_rect_coverage( - px as f32 + 0.5, - py as f32 + 0.5, - x as f32, - y as f32, - width as f32, - height as f32, - radius as f32, - ); - if coverage > 0.0 { - blend_pixel(pixels, px, py, color, coverage); - } - } +fn find_svg_node(doc: &Document, node: NodeId) -> Option { + if doc.tag_name(node) == Some("svg") { + return Some(node); } -} -fn draw_round_line( - pixels: &mut [u8], - x1: i32, - y1: i32, - x2: i32, - y2: i32, - width: i32, - color: [u8; 4], -) { - let radius = width as f32 / 2.0; - let min_x = (x1.min(x2) - width).max(0) as usize; - let min_y = (y1.min(y2) - width).max(0) as usize; - let max_x = (x1.max(x2) + width).min(SIZE as i32) as usize; - let max_y = (y1.max(y2) + width).min(SIZE as i32) as usize; - - for py in min_y..max_y { - for px in min_x..max_x { - let dist = distance_to_segment( - px as f32 + 0.5, - py as f32 + 0.5, - x1 as f32, - y1 as f32, - x2 as f32, - y2 as f32, - ); - let coverage = (radius + 0.5 - dist).clamp(0.0, 1.0); - if coverage > 0.0 { - blend_pixel(pixels, px, py, color, coverage); - } + for child in doc.children(node) { + if let Some(svg) = find_svg_node(doc, child) { + return Some(svg); } } + + None } -fn blend_pixel(pixels: &mut [u8], x: usize, y: usize, color: [u8; 4], coverage: f32) { - let idx = (y * SIZE as usize + x) * 4; - let src_a = (color[3] as f32 / 255.0) * coverage; - let dst_a = pixels[idx + 3] as f32 / 255.0; - let out_a = src_a + dst_a * (1.0 - src_a); - if out_a <= 0.0 { - return; +fn find_text_node(doc: &Document, node: NodeId) -> Option { + if doc.tag_name(node) == Some("text") { + return Some(node); } - for channel in 0..3 { - let src = color[channel] as f32 / 255.0; - let dst = pixels[idx + channel] as f32 / 255.0; - let out = (src * src_a + dst * dst_a * (1.0 - src_a)) / out_a; - pixels[idx + channel] = (out * 255.0).round() as u8; + for child in doc.children(node) { + if let Some(text) = find_text_node(doc, child) { + return Some(text); + } } - pixels[idx + 3] = (out_a * 255.0).round() as u8; + + None +} + +fn parse_attr_f32(doc: &Document, node: NodeId, name: &str) -> Option { + doc.get_attribute(node, name).and_then(|value| { + value + .trim() + .strip_suffix("px") + .unwrap_or(value.trim()) + .parse() + .ok() + }) } -fn distance_to_segment(px: f32, py: f32, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 { - let vx = x2 - x1; - let vy = y2 - y1; - let len_sq = vx * vx + vy * vy; - if len_sq == 0.0 { - return ((px - x1).powi(2) + (py - y1).powi(2)).sqrt(); +fn downsample_rgba( + src: &[u8], + src_width: u32, + src_height: u32, + dst_width: u32, + dst_height: u32, +) -> Result, String> { + if src_width % dst_width != 0 || src_height % dst_height != 0 { + return Err("downsample target must divide source dimensions exactly".to_string()); + } + if src.len() != (src_width * src_height * 4) as usize { + return Err("source RGBA buffer has the wrong length".to_string()); } - let t = (((px - x1) * vx + (py - y1) * vy) / len_sq).clamp(0.0, 1.0); - let cx = x1 + t * vx; - let cy = y1 + t * vy; - ((px - cx).powi(2) + (py - cy).powi(2)).sqrt() -} + let scale_x = src_width / dst_width; + let scale_y = src_height / dst_height; + let samples = scale_x * scale_y; + let mut dst = vec![0; (dst_width * dst_height * 4) as usize]; + + for y in 0..dst_height { + for x in 0..dst_width { + let mut sum_r = 0u32; + let mut sum_g = 0u32; + let mut sum_b = 0u32; + let mut sum_a = 0u32; + + for sample_y in 0..scale_y { + for sample_x in 0..scale_x { + let sx = x * scale_x + sample_x; + let sy = y * scale_y + sample_y; + let src_idx = ((sy * src_width + sx) * 4) as usize; + let alpha = src[src_idx + 3] as u32; + sum_r += src[src_idx] as u32 * alpha; + sum_g += src[src_idx + 1] as u32 * alpha; + sum_b += src[src_idx + 2] as u32 * alpha; + sum_a += alpha; + } + } -fn rounded_rect_coverage( - px: f32, - py: f32, - x: f32, - y: f32, - width: f32, - height: f32, - radius: f32, -) -> f32 { - let cx = px.clamp(x + radius, x + width - radius); - let cy = py.clamp(y + radius, y + height - radius); - let dx = px - cx; - let dy = py - cy; - let dist = (dx * dx + dy * dy).sqrt(); - (radius + 0.5 - dist).clamp(0.0, 1.0) -} + let dst_idx = ((y * dst_width + x) * 4) as usize; + if sum_a == 0 { + continue; + } + + dst[dst_idx] = (sum_r / sum_a) as u8; + dst[dst_idx + 1] = (sum_g / sum_a) as u8; + dst[dst_idx + 2] = (sum_b / sum_a) as u8; + dst[dst_idx + 3] = (sum_a / samples) as u8; + } + } -fn lerp(a: f32, b: f32, t: f32) -> f32 { - a + (b - a) * t + Ok(dst) } #[cfg(test)] @@ -246,19 +221,26 @@ mod tests { #[test] fn render_icon_has_expected_size_and_visible_wordmark() { - let pixels = render_icon(); + let pixels = render_icon().expect("render app icon from SVG"); assert_eq!(pixels.len(), (SIZE * SIZE * 4) as usize); - let stroke = (340 * SIZE as usize + 600) * 4; - assert_eq!(pixels[stroke + 3], 255); - assert!(pixels[stroke] > 180); - assert!(pixels[stroke + 1] > 180); - assert!(pixels[stroke + 2] > 180); + let bright_pixels = pixels + .chunks_exact(4) + .filter(|px| px[3] == 255 && px[0] > 180 && px[1] > 180 && px[2] > 180) + .count(); + assert!(bright_pixels > 20_000); } #[test] fn render_icon_has_transparent_corners() { - let pixels = render_icon(); + let pixels = render_icon().expect("render app icon from SVG"); assert_eq!(pixels[3], 0); } + + #[test] + fn downsample_preserves_alpha_and_unpremultiplies_color() { + let src = vec![200, 100, 50, 255, 0, 0, 0, 0, 200, 100, 50, 255, 0, 0, 0, 0]; + let dst = downsample_rgba(&src, 2, 2, 1, 1).unwrap(); + assert_eq!(dst, vec![200, 100, 50, 127]); + } }