lmjtfy.git / packages / card / src / pixel.rs
pixel.rsannotatedpixel.rssource180 lines · 6.9 KB · raw
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;