use bern_ir::Application;
use std::borrow::Cow;
use std::path::{Path, PathBuf};
pub const PLATFORMS: [&str; 7] = [
"macos", "ios", "tvos", "windows", "linux", "android", "browser",
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Source {
Logo,
Png(PathBuf),
Native(PathBuf),
}
pub fn declared<'a>(app: &'a Application, platform: &str) -> Option<&'a str> {
app.app
.platform_icons
.get(platform)
.or(app.app.icon.as_ref())
.map(String::as_str)
}
pub fn source(app: &Application, platform: &str, project_dir: &Path) -> Source {
match declared(app, platform) {
None => Source::Logo,
Some(name) if is_png_name(name) => Source::Png(project_dir.join(name)),
Some(name) => Source::Native(project_dir.join(name)),
}
}
pub fn alternate_name(variant: &str) -> String {
let mut name = String::new();
for (index, c) in variant.chars().enumerate() {
if c.is_ascii_uppercase() && index > 0 {
name.push('_');
}
name.push(c.to_ascii_lowercase());
}
name
}
pub fn choices(app: &Application) -> Vec<(String, Option<String>)> {
let Some(bern_protocol::Type::Enum { name }) = app.windows.icon.as_ref().map(|icon| &icon.ty)
else {
return Vec::new();
};
app.types
.enum_def(name)
.map(|def| {
def.variants
.iter()
.map(|variant| {
let alternate =
(variant.name != "Default").then(|| alternate_name(&variant.name));
(variant.name.clone(), alternate)
})
.collect()
})
.unwrap_or_default()
}
pub fn alternate_source(
app: &Application,
name: &str,
platform: &str,
project_dir: &Path,
) -> Option<Source> {
let entries = app.app.alternate_icons.get(name)?;
let declared = entries.get(platform).or_else(|| entries.get("default"))?;
Some(if is_png_name(declared) {
Source::Png(project_dir.join(declared))
} else {
Source::Native(project_dir.join(declared))
})
}
pub fn alternates(
app: &Application,
platform: &str,
project_dir: &Path,
) -> Result<Vec<(String, Source)>, String> {
choices(app)
.into_iter()
.filter_map(|(_, alternate)| alternate)
.map(|name| match alternate_source(app, &name, platform, project_dir) {
Some(source) => Ok((name, source)),
None => Err(format!(
"[app.alternate_icons.{name}] has no `{platform}` or `default` entry; the {platform} app cannot switch to it"
)),
})
.collect()
}
pub fn is_png_name(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".png")
}
pub fn is_icon_document(path: &Path) -> bool {
path.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("icon"))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Shape {
AsDrawn,
Masked,
}
#[derive(Clone, Debug)]
pub struct Image {
pub size: u32,
pub png: Cow<'static, [u8]>,
}
pub const DEFAULT_SVG: &str = include_str!("../assets/icon/bern.svg");
const ROUND: &[(u32, &[u8])] = &[
(16, include_bytes!("../assets/icon/round-16.png")),
(20, include_bytes!("../assets/icon/round-20.png")),
(24, include_bytes!("../assets/icon/round-24.png")),
(32, include_bytes!("../assets/icon/round-32.png")),
(40, include_bytes!("../assets/icon/round-40.png")),
(48, include_bytes!("../assets/icon/round-48.png")),
(64, include_bytes!("../assets/icon/round-64.png")),
(128, include_bytes!("../assets/icon/round-128.png")),
(256, include_bytes!("../assets/icon/round-256.png")),
(512, include_bytes!("../assets/icon/round-512.png")),
];
const SQUARE: &[(u32, &[u8])] = &[
(16, include_bytes!("../assets/icon/square-16.png")),
(32, include_bytes!("../assets/icon/square-32.png")),
(64, include_bytes!("../assets/icon/square-64.png")),
(128, include_bytes!("../assets/icon/square-128.png")),
(256, include_bytes!("../assets/icon/square-256.png")),
(512, include_bytes!("../assets/icon/square-512.png")),
(1024, include_bytes!("../assets/icon/square-1024.png")),
];
pub const DECLARED_SIZES: [u32; 3] = [256, 512, 1024];
pub fn is_default(app: &Application, platform: &str) -> bool {
declared(app, platform).is_none()
}
pub fn default_images(shape: Shape) -> Vec<Image> {
let set = match shape {
Shape::AsDrawn => ROUND,
Shape::Masked => SQUARE,
};
set.iter()
.map(|(size, png)| Image {
size: *size,
png: Cow::Borrowed(*png),
})
.collect()
}
pub fn images(
app: &Application,
platform: &str,
project_dir: &Path,
shape: Shape,
) -> Result<Vec<Image>, String> {
match source(app, platform, project_dir) {
Source::Logo => Ok(default_images(shape)),
Source::Png(path) => {
let png = std::fs::read(&path)
.map_err(|error| format!("cannot read the icon {}: {error}", path.display()))?;
let size =
check(&png).map_err(|message| format!("icon {}: {message}", path.display()))?;
Ok(vec![Image {
size,
png: Cow::Owned(png),
}])
}
Source::Native(path) => Err(format!(
"the {platform} icon {} is not a PNG",
path.display()
)),
}
}
pub fn largest(images: &[Image]) -> &Image {
images
.iter()
.max_by_key(|image| image.size)
.expect("an icon has at least one image")
}
pub fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
if bytes.len() < 24 || &bytes[..8] != SIGNATURE || &bytes[12..16] != b"IHDR" {
return None;
}
let read =
|at: usize| u32::from_be_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]);
Some((read(16), read(20)))
}
pub fn check(bytes: &[u8]) -> Result<u32, String> {
let (width, height) = png_size(bytes).ok_or_else(|| "is not a PNG image".to_string())?;
if width != height {
return Err(format!("is {width} × {height} pixels; an icon is square"));
}
if !DECLARED_SIZES.contains(&width) {
return Err(format!(
"is {width} × {width} pixels; use 256, 512 or 1024 (1024 is best)"
));
}
Ok(width)
}
pub const ADAPTIVE_SIDE: u32 = 432;
pub fn adaptive_layers(png: &[u8]) -> Result<(Vec<u8>, [u8; 3]), String> {
let source = resvg::tiny_skia::Pixmap::decode_png(png).map_err(|error| error.to_string())?;
let corner = source
.pixel(0, 0)
.map(|pixel| pixel.demultiply())
.filter(|pixel| pixel.alpha() == 255)
.map_or([255, 255, 255], |pixel| {
[pixel.red(), pixel.green(), pixel.blue()]
});
let side = ADAPTIVE_SIDE as f32;
let shown = side * 72.0 / 108.0;
let scale = shown / source.width().max(1) as f32;
let offset = (side - shown) / 2.0;
let mut layer = resvg::tiny_skia::Pixmap::new(ADAPTIVE_SIDE, ADAPTIVE_SIDE)
.ok_or("cannot allocate the icon's layer")?;
layer.draw_pixmap(
0,
0,
source.as_ref(),
&resvg::tiny_skia::PixmapPaint {
quality: resvg::tiny_skia::FilterQuality::Bicubic,
..Default::default()
},
resvg::tiny_skia::Transform::from_row(scale, 0.0, 0.0, scale, offset, offset),
None,
);
Ok((
layer.encode_png().map_err(|error| error.to_string())?,
corner,
))
}
pub fn status_image(app: &Application) -> Option<&str> {
app.windows.status_item.as_ref()?.resource("image")
}
pub const ON_LIGHT: [u8; 3] = [0, 0, 0];
pub const ON_DARK: [u8; 3] = [255, 255, 255];
pub fn status_picture(path: &Path, side: u32, color: [u8; 3]) -> Result<Vec<u8>, String> {
let data =
std::fs::read(path).map_err(|error| format!("cannot read {}: {error}", path.display()))?;
let options = resvg::usvg::Options {
resources_dir: path.parent().map(|parent| parent.to_owned()),
..Default::default()
};
let tree = resvg::usvg::Tree::from_data(&data, &options)
.map_err(|error| format!("{}: {error}", path.display()))?;
let size = tree.size();
let scale = side as f32 / size.width().max(size.height());
let dx = (side as f32 - size.width() * scale) / 2.0;
let dy = (side as f32 - size.height() * scale) / 2.0;
let mut pixmap =
resvg::tiny_skia::Pixmap::new(side, side).ok_or("cannot allocate the picture")?;
resvg::render(
&tree,
resvg::tiny_skia::Transform::from_row(scale, 0.0, 0.0, scale, dx, dy),
&mut pixmap.as_mut(),
);
for pixel in pixmap.data_mut().as_chunks_mut::<4>().0 {
let alpha = pixel[3] as u16;
for (channel, value) in pixel[..3].iter_mut().zip(color) {
*channel = ((value as u16 * alpha + 127) / 255) as u8;
}
}
pixmap.encode_png().map_err(|error| error.to_string())
}
pub fn status_pictures(path: &Path, sides: &[u32], color: [u8; 3]) -> Result<Vec<Image>, String> {
sides
.iter()
.map(|&side| {
Ok(Image {
size: side,
png: Cow::Owned(status_picture(path, side, color)?),
})
})
.collect()
}
pub fn ico(images: &[Image]) -> Vec<u8> {
let mut chosen: Vec<&Image> = images.iter().filter(|image| image.size <= 256).collect();
if chosen.is_empty() {
chosen.push(largest(images));
}
let count = chosen.len() as u16;
let mut out = Vec::new();
out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&count.to_le_bytes());
let mut offset = 6 + 16 * chosen.len() as u32;
for image in &chosen {
let side = if image.size >= 256 {
0
} else {
image.size as u8
};
out.push(side);
out.push(side);
out.push(0); out.push(0); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&32u16.to_le_bytes()); out.extend_from_slice(&(image.png.len() as u32).to_le_bytes());
out.extend_from_slice(&offset.to_le_bytes());
offset += image.png.len() as u32;
}
for image in &chosen {
out.extend_from_slice(&image.png);
}
out
}
pub fn icns(images: &[Image]) -> Vec<u8> {
let types = |size: u32| -> &'static [&'static [u8; 4]] {
match size {
16 => &[b"icp4"],
32 => &[b"icp5", b"ic11"],
64 => &[b"icp6", b"ic12"],
128 => &[b"ic07"],
256 => &[b"ic08", b"ic13"],
512 => &[b"ic09", b"ic14"],
1024 => &[b"ic10"],
_ => &[],
}
};
let mut body = Vec::new();
for image in images {
for kind in types(image.size) {
body.extend_from_slice(*kind);
body.extend_from_slice(&(8 + image.png.len() as u32).to_be_bytes());
body.extend_from_slice(&image.png);
}
}
let mut out = Vec::with_capacity(8 + body.len());
out.extend_from_slice(b"icns");
out.extend_from_slice(&(8 + body.len() as u32).to_be_bytes());
out.extend_from_slice(&body);
out
}
pub fn accepted(platform: &str) -> &'static str {
match platform {
"macos" => "a square PNG, an `.icns` file or an Icon Composer `.icon` folder",
"ios" => "a square PNG, an `.appiconset` folder or an Icon Composer `.icon` folder",
"tvos" => "a square PNG or a `.brandassets` folder",
"windows" => "a square PNG or an `.ico` file",
"linux" => "a square PNG or an icon theme folder laid out like `hicolor`",
"android" => "a square PNG or a `res` folder with `mipmap-*/ic_launcher`",
"browser" => "a square PNG, an SVG or a folder of page icons",
_ => "a square PNG",
}
}
pub fn check_native(platform: &str, path: &Path) -> Result<(), String> {
let extension = path
.extension()
.map(|extension| extension.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let folder = path.is_dir();
let read = || std::fs::read(path).map_err(|error| format!("cannot be read: {error}"));
match (platform, folder, extension.as_str()) {
("macos", false, "icns") => check_icns(&read()?),
("windows", false, "ico") => check_ico(&read()?),
("browser", false, "svg") => {
if String::from_utf8_lossy(&read()?).contains("<svg") {
Ok(())
} else {
Err("is not an SVG image".into())
}
}
("macos" | "ios", true, "icon") => icon_document(path).map(drop),
("ios", true, _) => app_icon_set(path).map(drop),
("tvos", true, _) => crate::brand_assets::check(path).map(drop),
("android", true, _) => android_res(path).map(drop),
("linux", true, _) => theme_icons(path).map(drop),
("browser", true, _) => page_icons(path).map(drop),
_ => Err(format!("is not {}", accepted(platform))),
}
}
pub fn check_icns(bytes: &[u8]) -> Result<(), String> {
if bytes.len() < 8 || &bytes[..4] != b"icns" {
return Err("is not an .icns file".into());
}
let length = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
if length != bytes.len() {
return Err(format!(
"says it is {length} bytes long but is {}",
bytes.len()
));
}
let mut at = 8;
let mut entries = 0;
while at < bytes.len() {
let entry = bytes
.get(at + 4..at + 8)
.map(|size| u32::from_be_bytes([size[0], size[1], size[2], size[3]]) as usize)
.filter(|size| *size >= 8 && at + size <= bytes.len())
.ok_or_else(|| format!("has a broken entry at byte {at}"))?;
at += entry;
entries += 1;
}
if entries == 0 {
return Err("holds no images".into());
}
Ok(())
}
pub fn check_ico(bytes: &[u8]) -> Result<(), String> {
if bytes.len() < 6 || bytes[..4] != [0, 0, 1, 0] {
return Err("is not an .ico file".into());
}
let count = u16::from_le_bytes([bytes[4], bytes[5]]) as usize;
if count == 0 {
return Err("holds no images".into());
}
for index in 0..count {
let entry = bytes
.get(6 + 16 * index..6 + 16 * (index + 1))
.ok_or_else(|| "is cut short".to_string())?;
let size = u32::from_le_bytes([entry[8], entry[9], entry[10], entry[11]]) as usize;
let offset = u32::from_le_bytes([entry[12], entry[13], entry[14], entry[15]]) as usize;
if size == 0 || offset + size > bytes.len() {
return Err(format!("has a broken image {}", index + 1));
}
}
Ok(())
}
pub fn files_in(dir: &Path) -> Result<Vec<PathBuf>, String> {
fn walk(dir: &Path, relative: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
let entries = std::fs::read_dir(dir)
.map_err(|error| format!("cannot read {}: {error}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|error| format!("cannot read {}: {error}", dir.display()))?;
let kind = entry
.file_type()
.map_err(|error| format!("cannot read {}: {error}", entry.path().display()))?;
let name = entry.file_name();
if name.to_string_lossy().starts_with('.')
|| name == "Thumbs.db"
|| name == "desktop.ini"
{
continue;
}
let inside = relative.join(&name);
if kind.is_symlink() {
return Err(format!(
"{} is a symbolic link; put the file itself there",
entry.path().display()
));
} else if kind.is_dir() {
walk(&entry.path(), &inside, out)?;
} else {
out.push(inside);
}
}
Ok(())
}
let mut out = Vec::new();
walk(dir, Path::new(""), &mut out)?;
out.sort();
Ok(out)
}
fn slashed(path: &Path) -> String {
path.components()
.map(|part| part.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
pub fn app_icon_set(dir: &Path) -> Result<Vec<PathBuf>, String> {
let files = files_in(dir)?;
if let Some(nested) = files.iter().find(|file| file.components().count() > 1) {
return Err(format!(
"has {}; an .appiconset holds its files at its top",
slashed(nested)
));
}
let contents = std::fs::read(dir.join("Contents.json"))
.map_err(|_| "has no Contents.json; an .appiconset names its images there".to_string())?;
let contents: serde_json::Value = serde_json::from_slice(&contents)
.map_err(|error| format!("has a Contents.json that is not JSON: {error}"))?;
let images = contents
.get("images")
.and_then(|images| images.as_array())
.ok_or_else(|| "has a Contents.json without an `images` list".to_string())?;
let mut named = 0;
for image in images {
let Some(name) = image.get("filename").and_then(|name| name.as_str()) else {
continue;
};
if !files.iter().any(|file| file == Path::new(name)) {
return Err(format!("names {name} in Contents.json, which is not there"));
}
named += 1;
}
if named == 0 {
return Err("names no image in Contents.json".into());
}
Ok(files)
}
pub fn icon_document(dir: &Path) -> Result<Vec<PathBuf>, String> {
let files = files_in(dir)?;
let contents = std::fs::read(dir.join("icon.json"))
.map_err(|_| "has no icon.json; an .icon folder describes its layers there".to_string())?;
let document: serde_json::Value = serde_json::from_slice(&contents)
.map_err(|error| format!("has an icon.json that is not JSON: {error}"))?;
let groups = document
.get("groups")
.and_then(|groups| groups.as_array())
.ok_or_else(|| "has an icon.json without a `groups` list".to_string())?;
let mut named = 0;
let layers = groups
.iter()
.filter_map(|group| group.get("layers")?.as_array())
.flatten();
for layer in layers {
let specialized = layer
.get("image-name-specializations")
.and_then(|values| values.as_array())
.into_iter()
.flatten()
.filter_map(|value| value.get("value"));
for name in layer.get("image-name").into_iter().chain(specialized) {
let Some(name) = name.as_str() else { continue };
if !files
.iter()
.any(|file| *file == Path::new("Assets").join(name))
{
return Err(format!("names {name} in icon.json, which is not in Assets"));
}
named += 1;
}
}
if named == 0 {
return Err("names no layer image in icon.json".into());
}
Ok(files)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AndroidRes {
pub files: Vec<PathBuf>,
pub round: bool,
}
pub fn android_res(dir: &Path) -> Result<AndroidRes, String> {
let files = files_in(dir)?;
let mut launcher = false;
let mut round = false;
for file in &files {
let parts: Vec<String> = file
.components()
.map(|part| part.as_os_str().to_string_lossy().into_owned())
.collect();
let [folder, name] = parts.as_slice() else {
return Err(format!(
"has {}; a res folder holds resource folders such as mipmap-hdpi",
slashed(file)
));
};
let kind = folder.split('-').next().unwrap_or_default();
if !matches!(kind, "mipmap" | "drawable" | "values") {
return Err(format!(
"has {folder}; an icon's res folder holds mipmap, drawable and values folders"
));
}
if kind == "values" && matches!(name.as_str(), "strings.xml" | "themes.xml") {
return Err(format!(
"has {folder}/{name}, which Bern writes itself; keep only the icon's resources"
));
}
let stem = name
.rsplit_once('.')
.map_or(name.as_str(), |(stem, _)| stem);
if kind == "mipmap" && stem == "ic_launcher" {
launcher = true;
}
if kind == "mipmap" && stem == "ic_launcher_round" {
round = true;
}
}
if !launcher {
return Err("has no mipmap-*/ic_launcher; the launcher icon is @mipmap/ic_launcher".into());
}
Ok(AndroidRes { files, round })
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThemeIcon {
pub path: PathBuf,
pub folder: PathBuf,
pub size: Option<u32>,
pub symbolic: bool,
pub extension: String,
}
pub fn theme_icons(dir: &Path) -> Result<Vec<ThemeIcon>, String> {
let mut icons = Vec::new();
let mut name: Option<String> = None;
for file in files_in(dir)? {
let shown = slashed(&file);
let parts: Vec<String> = file
.components()
.map(|part| part.as_os_str().to_string_lossy().into_owned())
.collect();
if parts.len() == 1 && parts[0] == "index.theme" {
continue;
}
let [size, context, file_name] = parts.as_slice() else {
return Err(format!(
"has {shown}; an icon theme holds <size>/apps/<name>.png files"
));
};
if context != "apps" {
return Err(format!(
"has {shown}; an application icon goes in an apps folder"
));
}
let pixels = size
.split('@')
.next()
.and_then(|side| side.split_once('x'))
.and_then(|(width, height)| {
let width: u32 = width.parse().ok()?;
(height.parse::<u32>().ok()? == width).then_some(width)
});
let (stem, extension) = file_name
.rsplit_once('.')
.map(|(stem, extension)| (stem, extension.to_ascii_lowercase()))
.unwrap_or((file_name.as_str(), String::new()));
let symbolic = stem.ends_with("-symbolic");
match (pixels, size.as_str(), extension.as_str()) {
(Some(_), _, "png") | (None, "scalable", "svg") | (None, "symbolic", "svg") => {}
_ => {
return Err(format!(
"has {shown}; sized folders hold PNG files, scalable and symbolic hold SVG"
));
}
}
if symbolic != (size == "symbolic") {
return Err(format!(
"has {shown}; symbolic/apps holds the one <name>-symbolic.svg"
));
}
let base = stem.strip_suffix("-symbolic").unwrap_or(stem);
match &name {
None => name = Some(base.to_string()),
Some(name) if name != base => {
return Err(format!(
"has icons named both {name} and {base}; an application has one icon"
));
}
Some(_) => {}
}
let size = match pixels {
Some(pixels) => {
let scale: u32 = size
.split_once('@')
.map_or(Some(1), |(_, scale)| scale.parse().ok())
.ok_or_else(|| format!("has {shown}; a scaled folder is named like 48x48@2"))?;
let bytes = std::fs::read(dir.join(&file))
.map_err(|error| format!("cannot read {shown}: {error}"))?;
match png_size(&bytes) {
Some((width, height)) if width == height && width == pixels * scale => {
Some(width)
}
Some((width, height)) => {
return Err(format!(
"has {shown}, which is {width} × {height} pixels, not {0} × {0}",
pixels * scale
));
}
None => return Err(format!("has {shown}, which is not a PNG")),
}
}
None => None,
};
icons.push(ThemeIcon {
folder: file.parent().map(Path::to_path_buf).unwrap_or_default(),
path: file,
size,
symbolic,
extension,
});
}
if !icons.iter().any(|icon| icon.size.is_some()) {
return Err("has no PNG icon of a size, such as 256x256/apps/<name>.png".into());
}
Ok(icons)
}
pub fn page_icons(dir: &Path) -> Result<Vec<PathBuf>, String> {
let files = files_in(dir)?;
let mut images = 0;
for file in &files {
let shown = slashed(file);
if file.components().count() > 1 {
return Err(format!(
"has {shown}; page icons sit at the top of their folder"
));
}
bern_protocol::check_resource_name(&shown)?;
let extension = file
.extension()
.map(|extension| extension.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
match extension.as_str() {
"svg" | "ico" | "png" => images += 1,
"webmanifest" => {}
"json" if shown == "manifest.json" => {}
_ => {
return Err(format!(
"has {shown}; page icons are .svg, .ico and .png files and a .webmanifest"
));
}
}
}
if images == 0 {
return Err("has no .svg, .ico or .png icon".into());
}
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_logo_is_rendered_at_every_size() {
for shape in [Shape::AsDrawn, Shape::Masked] {
for image in default_images(shape) {
assert_eq!(png_size(&image.png), Some((image.size, image.size)));
}
}
assert!(DEFAULT_SVG.contains("<svg"));
}
#[test]
fn declared_icons_are_square_pngs_of_a_large_size() {
let png = |size: u32| {
let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
bytes.extend_from_slice(&size.to_be_bytes());
bytes.extend_from_slice(&size.to_be_bytes());
bytes
};
assert_eq!(check(&png(1024)), Ok(1024));
assert!(check(&png(300)).unwrap_err().contains("1024"));
assert!(check(b"GIF89a").unwrap_err().contains("PNG"));
let mut wide = png(512);
wide[16..20].copy_from_slice(&1024u32.to_be_bytes());
assert!(check(&wide).unwrap_err().contains("square"));
}
#[test]
fn ico_holds_the_small_images_as_png() {
let ico = ico(&default_images(Shape::AsDrawn));
assert_eq!(&ico[..4], &[0, 0, 1, 0]);
let count = u16::from_le_bytes([ico[4], ico[5]]) as usize;
assert_eq!(count, 9, "16 to 256 pixels");
let entry = &ico[6 + 16 * (count - 1)..6 + 16 * count];
assert_eq!(entry[0], 0);
let offset = u32::from_le_bytes([entry[12], entry[13], entry[14], entry[15]]) as usize;
assert_eq!(png_size(&ico[offset..]), Some((256, 256)));
}
#[test]
fn icns_files_every_image_under_its_types() {
let icns = icns(&default_images(Shape::Masked));
assert_eq!(&icns[..4], b"icns");
assert_eq!(
u32::from_be_bytes([icns[4], icns[5], icns[6], icns[7]]) as usize,
icns.len()
);
let mut kinds = Vec::new();
let mut at = 8;
while at < icns.len() {
let length =
u32::from_be_bytes([icns[at + 4], icns[at + 5], icns[at + 6], icns[at + 7]])
as usize;
kinds.push(String::from_utf8_lossy(&icns[at..at + 4]).into_owned());
assert!(png_size(&icns[at + 8..]).is_some());
at += length;
}
assert_eq!(
kinds,
[
"icp4", "icp5", "ic11", "icp6", "ic12", "ic07", "ic08", "ic13", "ic09", "ic14",
"ic10"
]
);
}
#[test]
fn a_status_picture_is_its_shape_in_one_colour() {
let dir = std::env::temp_dir().join(format!("bern-status-picture-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("tray.svg");
std::fs::write(
&path,
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 16"><rect width="32" height="16" fill="#e00"/></svg>"##,
)
.unwrap();
let png = super::status_picture(&path, 16, super::ON_DARK).unwrap();
assert_eq!(super::png_size(&png), Some((16, 16)));
let pixmap = resvg::tiny_skia::Pixmap::decode_png(&png).unwrap();
let at = |x: u32, y: u32| pixmap.pixel(x, y).unwrap();
assert_eq!(at(8, 1).alpha(), 0, "above the bar");
let middle = at(8, 8);
assert_eq!(
(middle.red(), middle.green(), middle.blue(), middle.alpha()),
(255, 255, 255, 255)
);
assert_eq!(at(8, 14).alpha(), 0, "below the bar");
let black = super::status_pictures(&path, &[16, 32], super::ON_LIGHT).unwrap();
assert_eq!(
black.iter().map(|image| image.size).collect::<Vec<_>>(),
[16, 32]
);
let _ = std::fs::remove_dir_all(dir);
}
}