1//! Coloured pixel art for the canvas, read from the SVG files themselves. 2//! 3//! The three LLM logos are shuqikhor's pixel icons (MIT), vendored unchanged 4//! in `third-party/pixel-icons`. They are included here with `include_str!` 5//! and are the one source of the logos: the page embeds the very same text 6//! (`lmjtfy::meatproxy` uses `CLAUDE`, `CHATGPT` and `GEMINI`), and the card 7//! rasterises it with `Icon::parse`, which reads exactly what they use: 8//! `<rect>`s, and `<path>`s of absolute `M`, `H`, `V` and `z`, so every edge 9//! is axis-aligned and a pixel is in or out by its centre. A file that uses 10//! anything else is refused, not drawn wrong. 11 12pub const CLAUDE: &str = include_str!("../../../third-party/pixel-icons/claude.svg"); 13pub const CHATGPT: &str = include_str!("../../../third-party/pixel-icons/chatgpt.svg"); 14pub const GEMINI: &str = include_str!("../../../third-party/pixel-icons/gemini.svg"); 15 16/// One filled pixel: its column and row, and its colour. 17#[derive(Clone, Copy, Debug, PartialEq, Eq)] 18pub struct Cell { 19 pub x: usize, 20 pub y: usize, 21 pub rgb: [u8; 3], 22} 23 24/// An icon as the pixels it fills, later shapes over earlier. 25#[derive(Clone, Debug, PartialEq, Eq)] 26pub struct Icon { 27 /// The side of its square grid. 28 pub size: usize, 29 pub cells: Vec<Cell>, 30} 31 32impl Icon { 33 /// The icon in `svg`, or why it cannot be read. 34 pub fn parse(svg: &str) -> Result<Icon, String> { 35 let size = attribute(svg, "viewBox") 36 .and_then(|view| view.split_whitespace().nth(2).and_then(|side| side.parse::<usize>().ok())) 37 .ok_or("no square viewBox")?; 38 let mut grid: Vec<Option<[u8; 3]>> = vec![None; size * size]; 39 let mut rest = svg; 40 while let Some(open) = rest.find('<') { 41 rest = &rest[open + 1..]; 42 let end = rest.find('>').ok_or("an unclosed tag")?; 43 let tag = &rest[..end]; 44 rest = &rest[end..]; 45 let name = tag.split_whitespace().next().unwrap_or_default(); 46 if name != "rect" && name != "path" { 47 continue; 48 } 49 let rgb = colour(attribute(tag, "fill").ok_or("a shape without a fill")?)?; 50 let covers: Box<dyn Fn(usize, usize) -> bool> = if name == "rect" { 51 let number = |key: &str| attribute(tag, key).and_then(|value| value.parse::<f64>().ok()).ok_or(format!("a rect without {key}")); 52 let (x, y, w, h) = (number("x")?, number("y")?, number("width")?, number("height")?); 53 Box::new(move |column, row| { 54 let (cx, cy) = (column as f64 + 0.5, row as f64 + 0.5); 55 cx > x && cx < x + w && cy > y && cy < y + h 56 }) 57 } else { 58 let edges = edges(attribute(tag, "d").ok_or("a path without d")?)?; 59 Box::new(move |column, row| winding(&edges, column as f64 + 0.5, row as f64 + 0.5) != 0) 60 }; 61 for row in 0..size { 62 for column in 0..size { 63 if covers(column, row) { 64 grid[row * size + column] = Some(rgb); 65 } 66 } 67 } 68 } 69 let cells: Vec<Cell> = grid.iter().enumerate().filter_map(|(at, rgb)| rgb.map(|rgb| Cell { x: at % size, y: at / size, rgb })).collect(); 70 if cells.is_empty() { 71 return Err("nothing is drawn".into()); 72 } 73 Ok(Icon { size, cells }) 74 } 75 76 /// Every colour it uses, in the order first met. 77 pub fn colours(&self) -> Vec<[u8; 3]> { 78 let mut seen: Vec<[u8; 3]> = Vec::new(); 79 for cell in &self.cells { 80 if !seen.contains(&cell.rgb) { 81 seen.push(cell.rgb); 82 } 83 } 84 seen 85 } 86} 87 88/// The value of `key="..."` in `text`. 89fn attribute<'a>(text: &'a str, key: &str) -> Option<&'a str> { 90 let at = text.find(&format!(" {key}=\""))? + key.len() + 3; 91 text[at..].split('"').next() 92} 93 94/// `#rrggbb`. 95fn colour(text: &str) -> Result<[u8; 3], String> { 96 let hex = text.strip_prefix('#').filter(|hex| hex.len() == 6).ok_or_else(|| format!("fill {text} is not #rrggbb"))?; 97 let byte = |at: usize| u8::from_str_radix(&hex[at..at + 2], 16).map_err(|_| format!("fill {text} is not #rrggbb")); 98 Ok([byte(0)?, byte(2)?, byte(4)?]) 99} 100 101/// A vertical edge, `x` from `from` to `to`: the only edges that cross a 102/// horizontal ray. 103type Edge = (f64, f64, f64); 104 105/// The vertical edges of a path of `M`, `H`, `V` and `z`. 106fn edges(d: &str) -> Result<Vec<Edge>, String> { 107 let mut edges = Vec::new(); 108 let (mut start, mut at) = ((0.0, 0.0), (0.0, 0.0)); 109 let mut chars = d.chars().peekable(); 110 let mut open = false; 111 let mut close = |edges: &mut Vec<Edge>, at: (f64, f64), start: (f64, f64)| -> Result<(), String> { 112 if at.0 != start.0 && at.1 != start.1 { 113 return Err("a path closes on a diagonal".into()); 114 } 115 if at.0 == start.0 && at.1 != start.1 { 116 edges.push((at.0, at.1, start.1)); 117 } 118 Ok(()) 119 }; 120 while let Some(command) = chars.next() { 121 if command.is_whitespace() || command == ',' { 122 continue; 123 } 124 let mut numbers = Vec::new(); 125 loop { 126 while chars.peek().is_some_and(|c| c.is_whitespace() || *c == ',') { 127 chars.next(); 128 } 129 let mut text = String::new(); 130 while chars.peek().is_some_and(|c| c.is_ascii_digit() || *c == '.' || *c == '-') { 131 text.push(chars.next().expect("peeked")); 132 } 133 if text.is_empty() { 134 break; 135 } 136 numbers.push(text.parse::<f64>().map_err(|_| format!("{text} is not a number"))?); 137 } 138 match (command, numbers.as_slice()) { 139 ('M', [x, y]) => { 140 if open { 141 close(&mut edges, at, start)?; 142 } 143 (start, at, open) = ((*x, *y), (*x, *y), true); 144 } 145 ('H', [x]) => at.0 = *x, 146 ('V', [y]) => { 147 edges.push((at.0, at.1, *y)); 148 at.1 = *y; 149 } 150 ('z' | 'Z', []) => { 151 close(&mut edges, at, start)?; 152 at = start; 153 open = false; 154 } 155 _ => return Err(format!("the path command {command} {numbers:?} is not M, H, V or z")), 156 } 157 } 158 if open { 159 close(&mut edges, at, start)?; 160 } 161 Ok(edges) 162} 163 164/// The winding number of the path around `(px, py)`: edges to its right that 165/// cross its row, up counting one way and down the other. Non-zero is 166/// inside, which is SVG's default fill rule. 167fn winding(edges: &[Edge], px: f64, py: f64) -> i32 { 168 edges 169 .iter() 170 .filter(|(x, _, _)| *x > px) 171 .map(|(_, from, to)| { 172 let (low, high) = (from.min(*to), from.max(*to)); 173 if py > low && py < high { if to > from { 1 } else { -1 } } else { 0 } 174 }) 175 .sum() 176} 177 178#[cfg(test)] 179#[path = "pixel_tests.rs"] 180mod tests;