1use std::path::{Path, PathBuf};
11
12pub const NAME: &str = "App Icon & Top Shelf Image";
15
16#[derive(Clone, Debug, PartialEq)]
18pub struct Raster {
19 pub width: u32,
20 pub height: u32,
21 pub pixels: Vec<u8>,
22}
23
24pub fn decode(bytes: &[u8]) -> Result<Raster, String> {
26 let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
27 decoder.set_transformations(png::Transformations::normalize_to_color8());
28 let mut reader = decoder
29 .read_info()
30 .map_err(|error| format!("is not a PNG image: {error}"))?;
31 let size = reader
32 .output_buffer_size()
33 .ok_or_else(|| "is too large".to_string())?;
34 let mut buffer = vec![0; size];
35 let info = reader
36 .next_frame(&mut buffer)
37 .map_err(|error| format!("cannot be decoded: {error}"))?;
38 buffer.truncate(info.buffer_size());
39 let pixels = match info.color_type {
40 png::ColorType::Rgba => buffer,
41 png::ColorType::Rgb => buffer
42 .as_chunks::<3>()
43 .0
44 .iter()
45 .flat_map(|&[r, g, b]| [r, g, b, 255])
46 .collect(),
47 png::ColorType::GrayscaleAlpha => buffer
48 .as_chunks::<2>()
49 .0
50 .iter()
51 .flat_map(|&[g, a]| [g, g, g, a])
52 .collect(),
53 png::ColorType::Grayscale => buffer.iter().flat_map(|&g| [g, g, g, 255]).collect(),
54 png::ColorType::Indexed => return Err("kept its palette after decoding".into()),
55 };
56 Ok(Raster {
57 width: info.width,
58 height: info.height,
59 pixels,
60 })
61}
62
63pub fn encode(raster: &Raster) -> Vec<u8> {
65 let mut out = Vec::new();
66 let mut encoder = png::Encoder::new(&mut out, raster.width, raster.height);
67 encoder.set_color(png::ColorType::Rgba);
68 encoder.set_depth(png::BitDepth::Eight);
69 let mut writer = encoder.write_header().expect("PNG header");
70 writer
71 .write_image_data(&raster.pixels)
72 .expect("PNG image data");
73 writer.finish().expect("PNG end");
74 out
75}
76
77struct Linear {
79 width: usize,
80 height: usize,
81 pixels: Vec<[f32; 4]>,
82}
83
84impl Linear {
85 fn from(raster: &Raster) -> Linear {
86 let pixels = raster
87 .pixels
88 .as_chunks::<4>()
89 .0
90 .iter()
91 .map(|p| {
92 let a = p[3] as f32 / 255.0;
93 [
94 p[0] as f32 / 255.0 * a,
95 p[1] as f32 / 255.0 * a,
96 p[2] as f32 / 255.0 * a,
97 a,
98 ]
99 })
100 .collect();
101 Linear {
102 width: raster.width as usize,
103 height: raster.height as usize,
104 pixels,
105 }
106 }
107
108 fn resize_axis(&self, size: usize, horizontal: bool) -> Linear {
111 let (from, other) = if horizontal {
112 (self.width, self.height)
113 } else {
114 (self.height, self.width)
115 };
116 let at = |along: usize, across: usize| {
117 if horizontal {
118 self.pixels[across * self.width + along]
119 } else {
120 self.pixels[along * self.width + across]
121 }
122 };
123 let scale = from as f32 / size as f32;
124 let weights: Vec<Vec<(usize, f32)>> = (0..size)
126 .map(|i| {
127 if scale >= 1.0 {
128 let start = i as f32 * scale;
129 let end = start + scale;
130 let mut list = Vec::new();
131 let mut x = start.floor() as usize;
132 while (x as f32) < end && x < from {
133 let cover = (end.min(x as f32 + 1.0) - start.max(x as f32)).max(0.0);
134 if cover > 0.0 {
135 list.push((x, cover / scale));
136 }
137 x += 1;
138 }
139 list
140 } else {
141 let center = (i as f32 + 0.5) * scale - 0.5;
142 let left = center.floor().clamp(0.0, (from - 1) as f32) as usize;
143 let right = (left + 1).min(from - 1);
144 let t = (center - left as f32).clamp(0.0, 1.0);
145 vec![(left, 1.0 - t), (right, t)]
146 }
147 })
148 .collect();
149 let (width, height) = if horizontal {
150 (size, other)
151 } else {
152 (other, size)
153 };
154 let mut pixels = vec![[0.0; 4]; width * height];
155 for across in 0..other {
156 for (i, list) in weights.iter().enumerate() {
157 let mut sum = [0.0f32; 4];
158 for &(x, w) in list {
159 let p = at(x, across);
160 for c in 0..4 {
161 sum[c] += p[c] * w;
162 }
163 }
164 let index = if horizontal {
165 across * width + i
166 } else {
167 i * width + across
168 };
169 pixels[index] = sum;
170 }
171 }
172 Linear {
173 width,
174 height,
175 pixels,
176 }
177 }
178
179 fn resize(&self, width: usize, height: usize) -> Linear {
180 self.resize_axis(width, true).resize_axis(height, false)
181 }
182}
183
184fn ground(raster: &Raster) -> ([u8; 3], bool) {
188 let mut buckets = std::collections::HashMap::<u16, (u32, [u64; 3])>::new();
189 for &[r, g, b, a] in raster.pixels.as_chunks::<4>().0 {
190 if a < 230 {
191 continue;
192 }
193 let key = ((r as u16 >> 3) << 10) | ((g as u16 >> 3) << 5) | (b as u16 >> 3);
194 let entry = buckets.entry(key).or_insert((0, [0; 3]));
195 entry.0 += 1;
196 for (sum, channel) in entry.1.iter_mut().zip([r, g, b]) {
197 *sum += channel as u64;
198 }
199 }
200 let color = buckets
201 .values()
202 .max_by_key(|(count, _)| *count)
203 .map(|(count, sum)| sum.map(|c| (c / *count as u64) as u8))
204 .unwrap_or([0x2C, 0x2C, 0x2E]);
205 let (w, h) = (raster.width as usize, raster.height as usize);
206 let ring = (w.min(h) / 50).max(1);
207 let (mut opaque, mut total) = (0u64, 0u64);
208 for y in 0..h {
209 for x in 0..w {
210 if x < ring || y < ring || x >= w - ring || y >= h - ring {
211 opaque += raster.pixels[(y * w + x) * 4 + 3] as u64;
212 total += 255;
213 }
214 }
215 }
216 (color, total > 0 && opaque * 100 >= total * 95)
217}
218
219fn layer(art: Option<(&Linear, usize)>, fill: Option<[u8; 3]>, width: u32, height: u32) -> Raster {
222 let (w, h) = (width as usize, height as usize);
223 let mut pixels = vec![0u8; w * h * 4];
224 if let Some(color) = fill {
225 for p in pixels.as_chunks_mut::<4>().0 {
226 *p = [color[0], color[1], color[2], 255];
227 }
228 }
229 if let Some((art, side)) = art {
230 let scaled = art.resize(side, side);
231 let left = (w as isize - side as isize) / 2;
232 let top = (h as isize - side as isize) / 2;
233 for y in 0..side {
234 for x in 0..side {
235 let (tx, ty) = (left + x as isize, top + y as isize);
236 if tx < 0 || ty < 0 || tx >= w as isize || ty >= h as isize {
237 continue;
238 }
239 let s = scaled.pixels[y * side + x];
240 let index = (ty as usize * w + tx as usize) * 4;
241 let d = &mut pixels[index..index + 4];
242 let da = d[3] as f32 / 255.0;
244 let out_a = s[3] + da * (1.0 - s[3]);
245 for c in 0..3 {
246 let dc = d[c] as f32 / 255.0 * da;
247 let oc = s[c] + dc * (1.0 - s[3]);
248 d[c] = if out_a > 0.0 {
249 (oc / out_a * 255.0).round().clamp(0.0, 255.0) as u8
250 } else {
251 0
252 };
253 }
254 d[3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
255 }
256 }
257 }
258 Raster {
259 width,
260 height,
261 pixels,
262 }
263}
264
265const INFO: &str = "\"info\" : {\n \"author\" : \"bern\",\n \"version\" : 1\n }";
266
267fn image_set(images: &[(&str, &str)]) -> String {
269 let list: Vec<String> = images
270 .iter()
271 .map(|(file, scale)| {
272 format!(
273 " {{\n \"filename\" : \"{file}\",\n \"idiom\" : \"tv\",\n \"scale\" : \"{scale}\"\n }}"
274 )
275 })
276 .collect();
277 format!(
278 "{{\n \"images\" : [\n{}\n ],\n {INFO}\n}}\n",
279 list.join(",\n")
280 )
281}
282
283pub fn generate(icon: &[u8]) -> Result<Vec<(PathBuf, Vec<u8>)>, String> {
287 let raster = decode(icon)?;
288 if raster.width != raster.height {
289 return Err(format!(
290 "is {} × {} pixels; an icon is square",
291 raster.width, raster.height
292 ));
293 }
294 let (color, full) = ground(&raster);
295 let art = Linear::from(&raster);
296 let share = if full { 1.0 } else { 0.8 };
299 let mut files: Vec<(PathBuf, Vec<u8>)> = Vec::new();
300 let text = |path: &str, contents: String| (PathBuf::from(path), contents.into_bytes());
301 files.push(text(
302 "Contents.json",
303 format!(
304 "{{\n \"assets\" : [\n {{\n \"filename\" : \"App Icon - App Store.imagestack\",\n \"idiom\" : \"tv\",\n \"role\" : \"primary-app-icon\",\n \"size\" : \"1280x768\"\n }},\n {{\n \"filename\" : \"App Icon.imagestack\",\n \"idiom\" : \"tv\",\n \"role\" : \"primary-app-icon\",\n \"size\" : \"400x240\"\n }},\n {{\n \"filename\" : \"Top Shelf Image Wide.imageset\",\n \"idiom\" : \"tv\",\n \"role\" : \"top-shelf-image-wide\",\n \"size\" : \"2320x720\"\n }},\n {{\n \"filename\" : \"Top Shelf Image.imageset\",\n \"idiom\" : \"tv\",\n \"role\" : \"top-shelf-image\",\n \"size\" : \"1920x720\"\n }}\n ],\n {INFO}\n}}\n"
305 ),
306 ));
307 for (stack, width, height, scales) in [
310 ("App Icon.imagestack", 400u32, 240u32, &[1u32, 2][..]),
311 ("App Icon - App Store.imagestack", 1280, 768, &[1][..]),
312 ] {
313 files.push(text(
314 &format!("{stack}/Contents.json"),
315 format!(
316 "{{\n {INFO},\n \"layers\" : [\n {{\n \"filename\" : \"Front.imagestacklayer\"\n }},\n {{\n \"filename\" : \"Back.imagestacklayer\"\n }}\n ]\n}}\n"
317 ),
318 ));
319 for name in ["Front", "Back"] {
320 let dir = format!("{stack}/{name}.imagestacklayer");
321 files.push(text(
322 &format!("{dir}/Contents.json"),
323 format!("{{\n {INFO}\n}}\n"),
324 ));
325 let mut images = Vec::new();
326 for &scale in scales {
327 let (w, h) = (width * scale, height * scale);
328 let file = if scale == 1 {
329 format!("{}.png", name.to_lowercase())
330 } else {
331 format!("{}@{scale}x.png", name.to_lowercase())
332 };
333 let raster = if name == "Front" {
334 let side = (h as f32 * share).round() as usize;
335 layer(Some((&art, side)), None, w, h)
336 } else {
337 layer(None, Some(color), w, h)
338 };
339 files.push((
340 PathBuf::from(format!("{dir}/Content.imageset/{file}")),
341 encode(&raster),
342 ));
343 images.push((file, format!("{scale}x")));
344 }
345 let images: Vec<(&str, &str)> = images
346 .iter()
347 .map(|(file, scale)| (file.as_str(), scale.as_str()))
348 .collect();
349 files.push(text(
350 &format!("{dir}/Content.imageset/Contents.json"),
351 image_set(&images),
352 ));
353 }
354 }
355 for (set, width, height) in [
357 ("Top Shelf Image.imageset", 1920u32, 720u32),
358 ("Top Shelf Image Wide.imageset", 2320, 720),
359 ] {
360 let mut images = Vec::new();
361 for scale in [1u32, 2] {
362 let (w, h) = (width * scale, height * scale);
363 let file = if scale == 1 {
364 "top-shelf.png".to_string()
365 } else {
366 format!("top-shelf@{scale}x.png")
367 };
368 let side = (h as f32 * 0.5 * share).round() as usize;
369 files.push((
370 PathBuf::from(format!("{set}/{file}")),
371 encode(&layer(Some((&art, side)), Some(color), w, h)),
372 ));
373 images.push((file, format!("{scale}x")));
374 }
375 let images: Vec<(&str, &str)> = images
376 .iter()
377 .map(|(file, scale)| (file.as_str(), scale.as_str()))
378 .collect();
379 files.push(text(&format!("{set}/Contents.json"), image_set(&images)));
380 }
381 Ok(files)
382}
383
384fn contents(dir: &Path, relative: &str) -> Result<serde_json::Value, String> {
386 let path = dir.join(relative).join("Contents.json");
387 let bytes = std::fs::read(&path).map_err(|_| {
388 format!(
389 "has no {}/Contents.json",
390 relative.trim_end_matches('/').trim_start_matches("./")
391 )
392 })?;
393 serde_json::from_slice(&bytes)
394 .map_err(|error| format!("has a {relative}/Contents.json that is not JSON: {error}"))
395}
396
397fn check_image_set(dir: &Path, set: &str) -> Result<(), String> {
399 let contents = contents(dir, set)?;
400 let images = contents
401 .get("images")
402 .and_then(|images| images.as_array())
403 .ok_or_else(|| format!("has a {set}/Contents.json without an `images` list"))?;
404 let mut named = 0;
405 for image in images {
406 if let Some(name) = image.get("filename").and_then(|name| name.as_str()) {
407 if !dir.join(set).join(name).is_file() {
408 return Err(format!("names {set}/{name}, which is not there"));
409 }
410 named += 1;
411 }
412 }
413 if named == 0 {
414 return Err(format!("has no images in {set}"));
415 }
416 Ok(())
417}
418
419pub fn check(dir: &Path) -> Result<Vec<PathBuf>, String> {
425 let files = crate::icon::files_in(dir)?;
426 let top = contents(dir, ".")?;
427 let assets = top
428 .get("assets")
429 .and_then(|assets| assets.as_array())
430 .ok_or_else(|| "has a Contents.json without an `assets` list".to_string())?;
431 let mut home = false;
432 for asset in assets {
433 let Some(name) = asset.get("filename").and_then(|name| name.as_str()) else {
434 continue;
435 };
436 if !dir.join(name).is_dir() {
437 return Err(format!("names {name} in Contents.json, which is not there"));
438 }
439 let role = asset.get("role").and_then(|role| role.as_str());
440 let size = asset.get("size").and_then(|size| size.as_str());
441 if name.ends_with(".imagestack") {
442 let stack = contents(dir, name)?;
443 let layers = stack
444 .get("layers")
445 .and_then(|layers| layers.as_array())
446 .ok_or_else(|| format!("has a {name}/Contents.json without `layers`"))?;
447 if !(2..=5).contains(&layers.len()) {
448 return Err(format!(
449 "has {} layers in {name}; an image stack has two to five",
450 layers.len()
451 ));
452 }
453 for layer in layers {
454 let file = layer
455 .get("filename")
456 .and_then(|file| file.as_str())
457 .ok_or_else(|| format!("has a layer without a file name in {name}"))?;
458 check_image_set(dir, &format!("{name}/{file}/Content.imageset"))?;
459 }
460 if role == Some("primary-app-icon") && size == Some("400x240") {
461 home = true;
462 }
463 } else if name.ends_with(".imageset") {
464 check_image_set(dir, name)?;
465 }
466 }
467 if !home {
468 return Err(
469 "lists no home screen icon: a `primary-app-icon` image stack of size 400x240".into(),
470 );
471 }
472 Ok(files)
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 fn square(size: u32, color: [u8; 4], disc: Option<[u8; 4]>) -> Vec<u8> {
480 let mut pixels = Vec::new();
481 let r = size as f32 / 2.0;
482 for y in 0..size {
483 for x in 0..size {
484 let inside =
485 ((x as f32 + 0.5 - r).powi(2) + (y as f32 + 0.5 - r).powi(2)).sqrt() < r * 0.5;
486 pixels.extend_from_slice(&match disc {
487 Some(disc) if inside => disc,
488 _ => color,
489 });
490 }
491 }
492 encode(&Raster {
493 width: size,
494 height: size,
495 pixels,
496 })
497 }
498
499 #[test]
500 fn a_full_square_floats_over_its_own_ground() {
501 let icon = square(256, [10, 120, 200, 255], Some([255, 255, 255, 255]));
502 let files = generate(&icon).unwrap();
503 let back = files
504 .iter()
505 .find(|(path, _)| {
506 path.ends_with("App Icon.imagestack/Back.imagestacklayer/Content.imageset/back.png")
507 })
508 .unwrap();
509 let back = decode(&back.1).unwrap();
510 assert_eq!((back.width, back.height), (400, 240));
511 assert_eq!(back.pixels[3], 255);
514 assert!(back.pixels[0].abs_diff(10) <= 8 && back.pixels[2].abs_diff(200) <= 8);
515 let front = files
516 .iter()
517 .find(|(path, _)| {
518 path.ends_with(
519 "App Icon.imagestack/Front.imagestacklayer/Content.imageset/front@2x.png",
520 )
521 })
522 .unwrap();
523 let front = decode(&front.1).unwrap();
524 assert_eq!((front.width, front.height), (800, 480));
525 assert_eq!(front.pixels[3], 0);
527 let center = ((240 * 800 + 400) * 4) as usize;
528 assert_eq!(&front.pixels[center..center + 4], &[255, 255, 255, 255]);
529 let store = files
530 .iter()
531 .find(|(path, _)| path.ends_with("App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/front.png"))
532 .unwrap();
533 assert_eq!(png_size(&store.1), (1280, 768));
534 for (set, size) in [
535 ("Top Shelf Image.imageset/top-shelf@2x.png", (3840, 1440)),
536 ("Top Shelf Image Wide.imageset/top-shelf.png", (2320, 720)),
537 ] {
538 let file = files.iter().find(|(path, _)| path.ends_with(set)).unwrap();
539 assert_eq!(png_size(&file.1), size);
540 }
541 }
542
543 fn png_size(bytes: &[u8]) -> (u32, u32) {
544 crate::icon::png_size(bytes).unwrap()
545 }
546
547 #[test]
548 fn a_shape_on_transparency_keeps_a_margin() {
549 let icon = square(256, [0, 0, 0, 0], Some([200, 40, 60, 255]));
550 let files = generate(&icon).unwrap();
551 let back = files
552 .iter()
553 .find(|(path, _)| {
554 path.ends_with("App Icon.imagestack/Back.imagestacklayer/Content.imageset/back.png")
555 })
556 .unwrap();
557 let back = decode(&back.1).unwrap();
558 assert_eq!(&back.pixels[..4], &[200, 40, 60, 255]);
560 }
561
562 #[test]
563 fn generated_brand_assets_pass_the_check() {
564 let dir = std::env::temp_dir().join(format!("bern-brandassets-{}", std::process::id()));
565 let _ = std::fs::remove_dir_all(&dir);
566 for (path, contents) in generate(&square(256, [10, 120, 200, 255], None)).unwrap() {
567 std::fs::create_dir_all(dir.join(&path).parent().unwrap()).unwrap();
568 std::fs::write(dir.join(&path), contents).unwrap();
569 }
570 let files = check(&dir).unwrap();
571 assert!(files.iter().any(|file| file.ends_with("Contents.json")));
572 std::fs::remove_file(dir.join("Top Shelf Image.imageset/top-shelf.png")).unwrap();
573 assert!(check(&dir).unwrap_err().contains("not there"));
574 std::fs::remove_dir_all(&dir).unwrap();
575 }
576}