| 1 | use super::{from_hsl, to_hsl}; |
| 2 | use swash::{ |
| 3 | scale::image::{Content, Image}, |
| 4 | zeno::{self, Cap, Fill, Join, Mask, PathBuilder, Placement, Point, Stroke, Style, Transform}, |
| 5 | }; |
| 6 | |
| 7 | /// Colours for an icon's slots, linear RGB: paths of `class="accent"`, `"highlight"` or |
| 8 | /// `"badge"` are recoloured to their slot's colour; an empty slot keeps the art's colours. |
| 9 | #[derive(Clone, Copy, Debug, Default, PartialEq)] |
| 10 | pub struct Palette { |
| 11 | pub accent: Option<[f32; 3]>, |
| 12 | pub highlight: Option<[f32; 3]>, |
| 13 | pub badge: Option<[f32; 3]>, |
| 14 | } |
| 15 | |
| 16 | impl Palette { |
| 17 | fn slot(&self, class: &str) -> Option<[f32; 3]> { |
| 18 | match class { |
| 19 | "accent" => self.accent, |
| 20 | "highlight" => self.highlight, |
| 21 | "badge" => self.badge, |
| 22 | _ => None, |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | /// The palette as hashable bits, for caching icons per distinct palette. |
| 27 | pub(crate) fn bits(&self) -> [Option<[u32; 3]>; 3] { |
| 28 | [self.accent, self.highlight, self.badge] |
| 29 | .map(|slot| slot.map(|color| color.map(f32::to_bits))) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | /// Rasterizes 16×16 SVG sources over each other at a whole `size` of device pixels. Paths are filled and stroked with `#rrggbb`, `currentColor`, |
| 34 | /// which paints the linear `ink`, or two-stop `linearGradient`s with SVG's defaults |
| 35 | /// (`objectBoundingBox` unless `userSpaceOnUse`, `x1`..`y2`, stop offsets, pad spread; no |
| 36 | /// `gradientTransform`). `fill-opacity`, `stroke-opacity` and `stop-opacity` apply, and strokes have round |
| 37 | /// caps and joins. A slot's paths keep the shading among their colours: the hue turn, |
| 38 | /// saturation scale and lightness shift that carry the mean of the slot's colours in a |
| 39 | /// source onto the palette's colour apply to each of them. A source's `image`s of PNG |
| 40 | /// `data:` URLs, as `picture_icon` writes them, paint beneath its paths. |
| 41 | pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Palette) -> Image { |
| 42 | let side = size; |
| 43 | let size = size as f32; |
| 44 | let mut pixels = vec![[0.0_f32; 4]; (side * side) as usize]; |
| 45 | for source in sources { |
| 46 | let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid"); |
| 47 | assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16")); |
| 48 | for image in svg.descendants().filter(|node| node.has_tag_name("image")) { |
| 49 | picture(image, side, &mut pixels); |
| 50 | } |
| 51 | let paint = |value: &str| -> Option<Paint> { |
| 52 | match value { |
| 53 | "none" => None, |
| 54 | "currentColor" => Some(Paint::Flat([ink[0], ink[1], ink[2], 1.0])), |
| 55 | _ => Some(match value.strip_prefix("url(#") { |
| 56 | Some(id) => { |
| 57 | let id = id.strip_suffix(')').unwrap(); |
| 58 | let gradient = svg |
| 59 | .descendants() |
| 60 | .find(|node| node.attribute("id") == Some(id)) |
| 61 | .unwrap(); |
| 62 | Paint::linear(gradient) |
| 63 | } |
| 64 | None => Paint::Flat(hex(value)), |
| 65 | }), |
| 66 | } |
| 67 | }; |
| 68 | let paths = || svg.descendants().filter(|node| node.has_tag_name("path")); |
| 69 | let shifts: Vec<_> = ["accent", "highlight", "badge"] |
| 70 | .into_iter() |
| 71 | .filter_map(|class| { |
| 72 | let target = palette.slot(class)?; |
| 73 | let colors: Vec<_> = paths() |
| 74 | .filter(|path| path.attribute("class") == Some(class)) |
| 75 | .flat_map(|path| { |
| 76 | [ |
| 77 | path.attribute("fill").unwrap_or("#000000"), |
| 78 | path.attribute("stroke").unwrap_or("none"), |
| 79 | ] |
| 80 | }) |
| 81 | .filter(|value| *value != "currentColor") |
| 82 | .filter_map(paint) |
| 83 | .flat_map(|paint| paint.colors()) |
| 84 | .collect(); |
| 85 | Some((class, Shift::new(&colors, target)?)) |
| 86 | }) |
| 87 | .collect(); |
| 88 | for path in paths() { |
| 89 | let data = path.attribute("d").unwrap(); |
| 90 | let shift = shifts |
| 91 | .iter() |
| 92 | .find(|(class, _)| path.attribute("class") == Some(*class)) |
| 93 | .map(|(_, shift)| shift); |
| 94 | let fill = match path.attribute("fill-rule") { |
| 95 | Some("evenodd") => Fill::EvenOdd, |
| 96 | _ => Fill::NonZero, |
| 97 | }; |
| 98 | let pen = path |
| 99 | .attribute("stroke-width") |
| 100 | .map_or(1.0, |width| width.parse().unwrap()); |
| 101 | let opacity = |name| { |
| 102 | path.attribute(name) |
| 103 | .map_or(1.0, |value| value.parse().unwrap()) |
| 104 | }; |
| 105 | let layers = [ |
| 106 | ( |
| 107 | path.attribute("fill").unwrap_or("#000000"), |
| 108 | opacity("fill-opacity"), |
| 109 | Style::from(fill), |
| 110 | ), |
| 111 | ( |
| 112 | path.attribute("stroke").unwrap_or("none"), |
| 113 | opacity("stroke-opacity"), |
| 114 | Style::from(Stroke { |
| 115 | start_cap: Cap::Round, |
| 116 | end_cap: Cap::Round, |
| 117 | join: Join::Round, |
| 118 | ..Stroke::new(pen) |
| 119 | }), |
| 120 | ), |
| 121 | ]; |
| 122 | for (value, opacity, style) in layers { |
| 123 | let Some(mut paint) = paint(value) else { |
| 124 | continue; |
| 125 | }; |
| 126 | if let Some(shift) = shift.filter(|_| value != "currentColor") { |
| 127 | paint.recolor(|color| shift.apply(color)); |
| 128 | } |
| 129 | let color = paint.shader(data); |
| 130 | let (mask, _) = Mask::new(data) |
| 131 | .style(style) |
| 132 | .transform(Some(Transform::scale(size / 16.0, size / 16.0))) |
| 133 | .size(side, side) |
| 134 | .render(); |
| 135 | for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { |
| 136 | if coverage == 0 { |
| 137 | continue; |
| 138 | } |
| 139 | let alpha = f32::from(coverage) / 255.0 * opacity; |
| 140 | // The pixel's centre in icon units. |
| 141 | let [x, y] = [index % side as usize, index / side as usize] |
| 142 | .map(|device| device as f32 + 0.5) |
| 143 | .map(|device| device * 16.0 / size); |
| 144 | let color = color([x, y]); |
| 145 | over(pixel, color, alpha * color[3]); |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | let data = pixels |
| 151 | .into_iter() |
| 152 | .flat_map(|pixel| { |
| 153 | let alpha = pixel[3]; |
| 154 | let channel = |value: f32| { |
| 155 | let value = if alpha > 0.0 { value / alpha } else { 0.0 }; |
| 156 | super::srgb_byte(value) |
| 157 | }; |
| 158 | [ |
| 159 | channel(pixel[0]), |
| 160 | channel(pixel[1]), |
| 161 | channel(pixel[2]), |
| 162 | (alpha * 255.0).round() as u8, |
| 163 | ] |
| 164 | }) |
| 165 | .collect(); |
| 166 | Image { |
| 167 | content: Content::Color, |
| 168 | placement: Placement { |
| 169 | left: 0, |
| 170 | top: 0, |
| 171 | width: side, |
| 172 | height: side, |
| 173 | }, |
| 174 | data, |
| 175 | ..Image::default() |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | /// An icon source drawing the PNG `png` centred in the icon at its own proportions; none |
| 180 | /// where it is not a picture. |
| 181 | pub fn picture_icon(png: &[u8]) -> Option<String> { |
| 182 | use base64::Engine; |
| 183 | let [width, height] = super::RasterImage::measure(png) |
| 184 | .ok()? |
| 185 | .map(|side| side as f32); |
| 186 | let scale = 16.0 / width.max(height); |
| 187 | let [width, height] = [width * scale, height * scale]; |
| 188 | Some(format!( |
| 189 | r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><image x="{}" y="{}" width="{width}" height="{height}" href="data:image/png;base64,{}"/></svg>"#, |
| 190 | (16.0 - width) / 2.0, |
| 191 | (16.0 - height) / 2.0, |
| 192 | base64::engine::general_purpose::STANDARD.encode(png), |
| 193 | )) |
| 194 | } |
| 195 | |
| 196 | /// Paints `node`, a picture of `picture_icon`'s, over `pixels`, an icon `side` pixels |
| 197 | /// square, filtered to the whole pixels its box covers. |
| 198 | fn picture(node: roxmltree::Node, side: u32, pixels: &mut [[f32; 4]]) { |
| 199 | use base64::Engine; |
| 200 | let scale = side as f32 / 16.0; |
| 201 | let at = |name| { |
| 202 | let value: f32 = node.attribute(name).unwrap().parse().unwrap(); |
| 203 | value * scale |
| 204 | }; |
| 205 | let [x, y] = [at("x"), at("y")]; |
| 206 | let [left, top, right, bottom] = [x, y, x + at("width"), y + at("height")] |
| 207 | .map(|edge| edge.round().clamp(0.0, side as f32) as u32); |
| 208 | let decoded = node |
| 209 | .attribute("href") |
| 210 | .and_then(|href| href.strip_prefix("data:image/png;base64,")) |
| 211 | .and_then(|data| base64::engine::general_purpose::STANDARD.decode(data).ok()) |
| 212 | .and_then(|png| image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok()); |
| 213 | let Some(decoded) = decoded.filter(|_| right > left && bottom > top) else { |
| 214 | return; |
| 215 | }; |
| 216 | let shown = image::imageops::resize( |
| 217 | &decoded.into_rgba8(), |
| 218 | right - left, |
| 219 | bottom - top, |
| 220 | image::imageops::FilterType::Triangle, |
| 221 | ); |
| 222 | for (column, row, color) in shown.enumerate_pixels() { |
| 223 | let pixel = &mut pixels[((top + row) * side + left + column) as usize]; |
| 224 | let [r, g, b, a] = color.0; |
| 225 | over(pixel, super::srgb(r, g, b), f32::from(a) / 255.0); |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | /// Paints `color` at `alpha` over premultiplied `pixel`. |
| 230 | fn over(pixel: &mut [f32; 4], color: [f32; 4], alpha: f32) { |
| 231 | for channel in 0..3 { |
| 232 | pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha); |
| 233 | } |
| 234 | pixel[3] = alpha + pixel[3] * (1.0 - alpha); |
| 235 | } |
| 236 | |
| 237 | fn hex(value: &str) -> [f32; 4] { |
| 238 | let [_, r, g, b] = u32::from_str_radix(value.strip_prefix('#').unwrap(), 16) |
| 239 | .unwrap() |
| 240 | .to_be_bytes(); |
| 241 | super::srgb(r, g, b) |
| 242 | } |
| 243 | |
| 244 | /// A number or a percentage of `whole`. |
| 245 | fn number(value: &str, whole: f32) -> f32 { |
| 246 | match value.strip_suffix('%') { |
| 247 | Some(percent) => percent.parse::<f32>().unwrap() / 100.0 * whole, |
| 248 | None => value.parse().unwrap(), |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | enum Paint { |
| 253 | Flat([f32; 4]), |
| 254 | Linear { |
| 255 | /// Colour and offset of each stop. |
| 256 | stops: [([f32; 4], f32); 2], |
| 257 | /// Where offsets 0 and 1 lie, in icon units or fractions of the shape's box. |
| 258 | line: [[f32; 2]; 2], |
| 259 | bounding: bool, |
| 260 | }, |
| 261 | } |
| 262 | |
| 263 | impl Paint { |
| 264 | fn colors(&self) -> Vec<[f32; 4]> { |
| 265 | match self { |
| 266 | Self::Flat(color) => vec![*color], |
| 267 | Self::Linear { stops, .. } => stops.iter().map(|(color, _)| *color).collect(), |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | fn recolor(&mut self, change: impl Fn([f32; 4]) -> [f32; 4]) { |
| 272 | match self { |
| 273 | Self::Flat(color) => *color = change(*color), |
| 274 | Self::Linear { stops, .. } => { |
| 275 | for (color, _) in stops { |
| 276 | *color = change(*color); |
| 277 | } |
| 278 | } |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | fn linear(gradient: roxmltree::Node) -> Self { |
| 283 | assert!( |
| 284 | gradient.attribute("gradientTransform").is_none(), |
| 285 | "Icon gradients have no gradientTransform" |
| 286 | ); |
| 287 | let stops: Vec<_> = gradient |
| 288 | .children() |
| 289 | .filter(|node| node.has_tag_name("stop")) |
| 290 | .map(|node| { |
| 291 | let [red, green, blue, _] = hex(node.attribute("stop-color").unwrap()); |
| 292 | let opacity = node |
| 293 | .attribute("stop-opacity") |
| 294 | .map_or(1.0, |value| value.parse().unwrap()); |
| 295 | ( |
| 296 | [red, green, blue, opacity], |
| 297 | node.attribute("offset") |
| 298 | .map_or(0.0, |offset| number(offset, 1.0)), |
| 299 | ) |
| 300 | }) |
| 301 | .collect(); |
| 302 | let bounding = gradient.attribute("gradientUnits") != Some("userSpaceOnUse"); |
| 303 | // Percentages are of the shape's box or of the icon. |
| 304 | let whole = if bounding { 1.0 } else { 16.0 }; |
| 305 | let at = |name, default| { |
| 306 | gradient |
| 307 | .attribute(name) |
| 308 | .map_or(default * whole, |value| number(value, whole)) |
| 309 | }; |
| 310 | Self::Linear { |
| 311 | stops: stops.try_into().expect("Icon gradients have two stops"), |
| 312 | line: [ |
| 313 | [at("x1", 0.0), at("y1", 0.0)], |
| 314 | [at("x2", 1.0), at("y2", 0.0)], |
| 315 | ], |
| 316 | bounding, |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | /// The colour at each point of `data` in icon units. |
| 321 | fn shader(&self, data: &str) -> impl Fn([f32; 2]) -> [f32; 4] { |
| 322 | let (stops, [from, to], frame) = match *self { |
| 323 | Self::Flat(color) => ([(color, 0.0), (color, 1.0)], [[0.0; 2], [1.0, 0.0]], None), |
| 324 | Self::Linear { |
| 325 | stops, |
| 326 | line, |
| 327 | bounding, |
| 328 | } => (stops, line, bounding.then(|| bounds(data))), |
| 329 | }; |
| 330 | let direction = [to[0] - from[0], to[1] - from[1]]; |
| 331 | let length = direction[0] * direction[0] + direction[1] * direction[1]; |
| 332 | move |point| { |
| 333 | let [x, y] = match frame { |
| 334 | // A flat box has no extent to spread across on that axis. |
| 335 | Some([min, max]) => std::array::from_fn(|axis| { |
| 336 | let extent = max[axis] - min[axis]; |
| 337 | if extent > 0.0 { |
| 338 | (point[axis] - min[axis]) / extent |
| 339 | } else { |
| 340 | 0.0 |
| 341 | } |
| 342 | }), |
| 343 | None => point, |
| 344 | }; |
| 345 | // A gradient of no length paints its last stop. |
| 346 | let along = if length > 0.0 { |
| 347 | ((x - from[0]) * direction[0] + (y - from[1]) * direction[1]) / length |
| 348 | } else { |
| 349 | 1.0 |
| 350 | }; |
| 351 | let [(first, start), (last, end)] = stops; |
| 352 | let t = if end > start { |
| 353 | ((along - start) / (end - start)).clamp(0.0, 1.0) |
| 354 | } else { |
| 355 | f32::from(along >= start) |
| 356 | }; |
| 357 | std::array::from_fn(|channel| first[channel] + (last[channel] - first[channel]) * t) |
| 358 | } |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | /// A turn of hue, scale of saturation and shift of lightness in sRGB's HSL. |
| 363 | struct Shift([f32; 3]); |
| 364 | |
| 365 | impl Shift { |
| 366 | /// The shift carrying the mean of linear `colors` onto linear `target`. |
| 367 | fn new(colors: &[[f32; 4]], target: [f32; 3]) -> Option<Self> { |
| 368 | let count = colors.len() as f32; |
| 369 | let mean: [f32; 3] = std::array::from_fn(|channel| { |
| 370 | colors |
| 371 | .iter() |
| 372 | .map(|color| crate::encode(color[channel])) |
| 373 | .sum::<f32>() |
| 374 | / count |
| 375 | }); |
| 376 | let [from, to] = [mean, target.map(crate::encode)].map(to_hsl); |
| 377 | (count > 0.0).then(|| { |
| 378 | Self([ |
| 379 | to[0] - from[0], |
| 380 | if from[1] > 0.0 { to[1] / from[1] } else { 0.0 }, |
| 381 | to[2] - from[2], |
| 382 | ]) |
| 383 | }) |
| 384 | } |
| 385 | |
| 386 | fn apply(&self, color: [f32; 4]) -> [f32; 4] { |
| 387 | let [hue, saturation, lightness] = |
| 388 | to_hsl([color[0], color[1], color[2]].map(crate::encode)); |
| 389 | let [turn, scale, lift] = self.0; |
| 390 | let [r, g, b] = from_hsl([ |
| 391 | (hue + turn).rem_euclid(360.0), |
| 392 | (saturation * scale).clamp(0.0, 1.0), |
| 393 | (lightness + lift).clamp(0.0, 1.0), |
| 394 | ]) |
| 395 | .map(crate::linear); |
| 396 | [r, g, b, color[3]] |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | /// The tight box around a path's geometry, curves included, as SVG's bounding box is. |
| 401 | fn bounds(data: &str) -> [[f32; 2]; 2] { |
| 402 | struct Sampled { |
| 403 | current: Point, |
| 404 | min: [f32; 2], |
| 405 | max: [f32; 2], |
| 406 | } |
| 407 | impl Sampled { |
| 408 | fn add(&mut self, point: Point) { |
| 409 | self.min = [self.min[0].min(point.x), self.min[1].min(point.y)]; |
| 410 | self.max = [self.max[0].max(point.x), self.max[1].max(point.y)]; |
| 411 | } |
| 412 | } |
| 413 | impl PathBuilder for Sampled { |
| 414 | fn current_point(&self) -> Point { |
| 415 | self.current |
| 416 | } |
| 417 | fn move_to(&mut self, to: impl Into<Point>) -> &mut Self { |
| 418 | self.current = to.into(); |
| 419 | self.add(self.current); |
| 420 | self |
| 421 | } |
| 422 | fn line_to(&mut self, to: impl Into<Point>) -> &mut Self { |
| 423 | self.move_to(to) |
| 424 | } |
| 425 | fn quad_to(&mut self, control: impl Into<Point>, to: impl Into<Point>) -> &mut Self { |
| 426 | let [start, control, to] = [self.current, control.into(), to.into()]; |
| 427 | let lift = |end: Point| end + (control - end) * (2.0 / 3.0); |
| 428 | self.curve_to(lift(start), lift(to), to) |
| 429 | } |
| 430 | fn curve_to( |
| 431 | &mut self, |
| 432 | first: impl Into<Point>, |
| 433 | second: impl Into<Point>, |
| 434 | to: impl Into<Point>, |
| 435 | ) -> &mut Self { |
| 436 | let [start, first, second, to] = [self.current, first.into(), second.into(), to.into()]; |
| 437 | for step in 1..=32 { |
| 438 | let t = step as f32 / 32.0; |
| 439 | let u = 1.0 - t; |
| 440 | self.add( |
| 441 | start * (u * u * u) |
| 442 | + first * (3.0 * u * u * t) |
| 443 | + second * (3.0 * u * t * t) |
| 444 | + to * (t * t * t), |
| 445 | ); |
| 446 | } |
| 447 | self.current = to; |
| 448 | self |
| 449 | } |
| 450 | fn close(&mut self) -> &mut Self { |
| 451 | self |
| 452 | } |
| 453 | } |
| 454 | let mut sampled = Sampled { |
| 455 | current: Point::ZERO, |
| 456 | min: [f32::INFINITY; 2], |
| 457 | max: [f32::NEG_INFINITY; 2], |
| 458 | }; |
| 459 | zeno::apply(data, Fill::NonZero, None, &mut sampled); |
| 460 | [sampled.min, sampled.max] |
| 461 | } |
| 462 | |
| 463 | #[cfg(test)] |
| 464 | mod tests { |
| 465 | use super::*; |
| 466 | |
| 467 | const BOX: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#8090a0"/></linearGradient></defs><path fill="url(#a)" d="M2 2h12v12H2z"/></svg>"##; |
| 468 | const MARK: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="b" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff2000"/><stop offset="1" stop-color="#a00000"/></linearGradient></defs><path fill="url(#b)" d="M4 8l3 3 5-7-1-1-4 5-2-2z"/></svg>"##; |
| 469 | |
| 470 | fn gradient(attributes: &str, stops: &str) -> String { |
| 471 | format!( |
| 472 | r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="g" {attributes}>{stops}</linearGradient></defs><path fill="url(#g)" d="M0 4H16V12H0Z"/></svg>"## |
| 473 | ) |
| 474 | } |
| 475 | |
| 476 | const BLACK_WHITE: &str = |
| 477 | r##"<stop offset="0" stop-color="#000000"/><stop offset="1" stop-color="#ffffff"/>"##; |
| 478 | |
| 479 | /// The grey painted at a pixel of `source` rasterized at 16 pixels, and the grey a |
| 480 | /// black-to-white ramp paints at fraction `t`. |
| 481 | fn grey(source: &str, x: usize, y: usize) -> u8 { |
| 482 | rasterize(&[source], 16, [1.0; 3], &Palette::default()).data[(y * 16 + x) * 4] |
| 483 | } |
| 484 | |
| 485 | fn ramp(t: f32) -> u8 { |
| 486 | super::super::srgb_byte(t) |
| 487 | } |
| 488 | |
| 489 | #[test] |
| 490 | fn gradients_span_the_shape_unless_in_user_space() { |
| 491 | let vertical = gradient(r#"x2="0" y2="1""#, BLACK_WHITE); |
| 492 | // Pixel centres 4.5 and 11.5 lie 0.5 inside the shape's 8-unit box. |
| 493 | assert_eq!(grey(&vertical, 8, 4), ramp(0.5 / 8.0)); |
| 494 | assert_eq!(grey(&vertical, 8, 11), ramp(7.5 / 8.0)); |
| 495 | let user = gradient( |
| 496 | r#"gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="16""#, |
| 497 | BLACK_WHITE, |
| 498 | ); |
| 499 | assert_eq!(grey(&user, 8, 4), ramp(4.5 / 16.0)); |
| 500 | assert_eq!(grey(&user, 8, 11), ramp(11.5 / 16.0)); |
| 501 | } |
| 502 | |
| 503 | #[test] |
| 504 | fn gradients_default_to_horizontal_and_pad_past_their_stops() { |
| 505 | let across = gradient("", BLACK_WHITE); |
| 506 | assert_eq!(grey(&across, 3, 4), ramp(3.5 / 16.0)); |
| 507 | assert_eq!(grey(&across, 3, 11), ramp(3.5 / 16.0)); |
| 508 | let inner = gradient( |
| 509 | r#"x2="0" y2="1""#, |
| 510 | r##"<stop offset="25%" stop-color="#000000"/><stop offset="75%" stop-color="#ffffff"/>"##, |
| 511 | ); |
| 512 | assert_eq!(grey(&inner, 8, 4), 0); |
| 513 | assert_eq!(grey(&inner, 8, 11), 255); |
| 514 | assert_eq!(grey(&inner, 8, 7), ramp((3.5 / 8.0 - 0.25) / 0.5)); |
| 515 | } |
| 516 | |
| 517 | #[test] |
| 518 | fn bounds_follow_curves_not_their_control_points() { |
| 519 | let [min, max] = bounds("M2 8A6 6 0 0 1 14 8Q8 14 2 8Z"); |
| 520 | assert!((min[1] - 2.0).abs() < 0.01, "{min:?}"); |
| 521 | assert!((max[1] - 11.0).abs() < 0.01, "{max:?}"); |
| 522 | assert!((min[0] - 2.0).abs() < 0.01 && (max[0] - 14.0).abs() < 0.01); |
| 523 | } |
| 524 | |
| 525 | #[test] |
| 526 | fn slots_take_the_palette_and_keep_their_shading() { |
| 527 | // Two blues of one hue in the accent slot, a red outside it. |
| 528 | const ART: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path class="accent" d="M0 0H16V4H0Z" fill="#80a0ff"/><path class="accent" d="M0 4H16V8H0Z" fill="#0040e0"/><path d="M0 8H16V12H0Z" fill="#ff0000"/><path class="accent" d="M0 12H16V16H0Z" fill="currentColor"/></svg>"##; |
| 529 | let pixel = |palette: &Palette, y: usize| { |
| 530 | let image = rasterize(&[ART], 16, [0.0; 3], palette); |
| 531 | let at = (y * 16 + 8) * 4; |
| 532 | let [r, g, b] = [0, 1, 2].map(|channel| f32::from(image.data[at + channel]) / 255.0); |
| 533 | to_hsl([r, g, b]) |
| 534 | }; |
| 535 | let plain = Palette::default(); |
| 536 | let green = Palette { |
| 537 | accent: Some([0.0, 1.0, 0.0].map(crate::linear)), |
| 538 | ..plain |
| 539 | }; |
| 540 | let [light, dark] = [2, 6].map(|y| pixel(&green, y)); |
| 541 | // Both turn green, the lighter above the darker, centred on the palette's colour. |
| 542 | for [hue, ..] in [light, dark] { |
| 543 | assert!((hue - 120.0).abs() < 2.0, "{hue}"); |
| 544 | } |
| 545 | assert!(light[2] > dark[2]); |
| 546 | assert!(((light[2] + dark[2]) / 2.0 - 0.5).abs() < 0.02); |
| 547 | assert_eq!(pixel(&green, 10), pixel(&plain, 10)); |
| 548 | assert_eq!(pixel(&green, 14), [0.0, 0.0, 0.0]); |
| 549 | assert!((pixel(&plain, 2)[0] - to_hsl([0.5, 0.627, 1.0])[0]).abs() < 2.0); |
| 550 | } |
| 551 | |
| 552 | #[test] |
| 553 | fn current_color_paints_the_ink_and_fixed_colours_keep_theirs() { |
| 554 | const LINE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 8H14" fill="none" stroke="currentColor" stroke-width="2"/><path d="M2 12H14" fill="none" stroke="#ff0000" stroke-width="2"/></svg>"##; |
| 555 | let ink = super::super::srgb(0x20, 0x60, 0xa0); |
| 556 | let image = rasterize(&[LINE], 16, [ink[0], ink[1], ink[2]], &Palette::default()); |
| 557 | let pixel = |x: usize, y: usize| &image.data[(y * 16 + x) * 4..][..4]; |
| 558 | assert_eq!(pixel(8, 7), [0x20, 0x60, 0xa0, 255]); |
| 559 | assert_eq!(pixel(8, 11), [255, 0, 0, 255]); |
| 560 | assert_eq!(pixel(8, 2)[3], 0); |
| 561 | // Round caps reach past the path's ends. |
| 562 | assert!(pixel(1, 7)[3] > 0); |
| 563 | } |
| 564 | |
| 565 | #[test] |
| 566 | fn opacity_scales_each_paint_on_its_own() { |
| 567 | const HALF: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 2H14V14H2Z" fill="#ffffff" fill-opacity="0.5" stroke="#000000" stroke-width="2" stroke-opacity="0.25"/></svg>"##; |
| 568 | let image = rasterize(&[HALF], 16, [1.0; 3], &Palette::default()); |
| 569 | let alpha = |x: usize, y: usize| image.data[(y * 16 + x) * 4 + 3]; |
| 570 | assert_eq!(alpha(8, 8), 128); |
| 571 | // The stroke's outer half covers only what the fill leaves. |
| 572 | assert_eq!(alpha(1, 8), 64); |
| 573 | } |
| 574 | |
| 575 | #[test] |
| 576 | fn stop_opacity_fades_a_gradient() { |
| 577 | const FADE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="g" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></linearGradient></defs><path d="M0 0H16V16H0Z" fill="url(#g)"/></svg>"##; |
| 578 | let image = rasterize(&[FADE], 16, [1.0; 3], &Palette::default()); |
| 579 | let alpha = |y: usize| image.data[(y * 16 + 8) * 4 + 3]; |
| 580 | assert!( |
| 581 | alpha(0) > 240 && alpha(15) < 15, |
| 582 | "{} {}", |
| 583 | alpha(0), |
| 584 | alpha(15) |
| 585 | ); |
| 586 | assert!((i32::from(alpha(8)) - 120).abs() < 10, "{}", alpha(8)); |
| 587 | } |
| 588 | |
| 589 | /// A 2×1 PNG, red then half-transparent blue, fits the icon's width, centred, and paints |
| 590 | /// beneath the paths of later sources. |
| 591 | #[test] |
| 592 | fn pictures_fill_their_box_at_their_proportions() { |
| 593 | let mut png = Vec::new(); |
| 594 | let mut encoder = png::Encoder::new(&mut png, 2, 1); |
| 595 | encoder.set_color(png::ColorType::Rgba); |
| 596 | encoder |
| 597 | .write_header() |
| 598 | .unwrap() |
| 599 | .write_image_data(&[255, 0, 0, 255, 0, 0, 255, 128]) |
| 600 | .unwrap(); |
| 601 | let source = picture_icon(&png).unwrap(); |
| 602 | let image = rasterize(&[&source, MARK], 32, [1.0; 3], &Palette::default()); |
| 603 | let pixel = |x: usize, y: usize| &image.data[(y * 32 + x) * 4..][..4]; |
| 604 | // The picture spans rows 8 to 24 of 32. |
| 605 | assert_eq!(pixel(2, 4)[3], 0); |
| 606 | assert_eq!(pixel(2, 12), [255, 0, 0, 255]); |
| 607 | assert_eq!(pixel(30, 20), [0, 0, 255, 128]); |
| 608 | assert_eq!(pixel(30, 28)[3], 0); |
| 609 | let marked = rasterize(&[MARK], 32, [1.0; 3], &Palette::default()); |
| 610 | let covered: Vec<_> = (0..32 * 32) |
| 611 | .filter(|at| marked.data[at * 4 + 3] == 255) |
| 612 | .collect(); |
| 613 | assert!(!covered.is_empty()); |
| 614 | for at in covered { |
| 615 | assert_eq!(image.data[at * 4..][..4], marked.data[at * 4..][..4]); |
| 616 | } |
| 617 | assert!(picture_icon(b"not a picture").is_none()); |
| 618 | } |
| 619 | |
| 620 | #[test] |
| 621 | fn icons_keep_transparency_and_overlays_across_scales() { |
| 622 | for size in [8, 16, 32, 57] { |
| 623 | let plain = rasterize(&[BOX], size, [1.0; 3], &Palette::default()); |
| 624 | let marked = rasterize(&[BOX, MARK], size, [1.0; 3], &Palette::default()); |
| 625 | for image in [&plain, &marked] { |
| 626 | assert_eq!( |
| 627 | image.data.len(), |
| 628 | (image.placement.width * image.placement.height * 4) as usize |
| 629 | ); |
| 630 | assert!(image.data.chunks_exact(4).any(|p| p[3] > 0)); |
| 631 | assert!(image.data.chunks_exact(4).any(|p| p[3] < 255)); |
| 632 | assert!( |
| 633 | image |
| 634 | .data |
| 635 | .chunks_exact(4) |
| 636 | .filter(|p| p[3] == 0) |
| 637 | .all(|p| p[..3] == [0; 3]) |
| 638 | ); |
| 639 | } |
| 640 | assert_ne!(plain.data, marked.data); |
| 641 | assert!(marked.data.chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); |
| 642 | } |
| 643 | } |
| 644 | } |