1use super::{from_hsl, to_hsl};
2use 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)]
10pub struct Palette {
11 pub accent: Option<[f32; 3]>,
12 pub highlight: Option<[f32; 3]>,
13 pub badge: Option<[f32; 3]>,
14}
15
16impl 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.
41pub(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.
181pub 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.
198fn 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`.
230fn 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
237fn 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`.
245fn 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
252enum 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
263impl 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.
363struct Shift([f32; 3]);
364
365impl 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.
401fn 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)]
464mod 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}