master
John Lauer v2.0.689 (insiders): Every existing thread's taskbar tile is redrawn with the smoother text from 2.0.688 (the tile cach… d310442 1mo ago
//! pup-identity: taskbar identity POLICY and ART, no OS calls. AUMID naming (identical to the Node
//! build so pinned buttons survive the port), URL categories, the thread-tile and overlay-badge
//! canvas scripts (rendered by the browser pup already drives: no font rasterizer, no image
//! library), a PNG-in-ICO packer, the embedded icon set and font, and the jump-list launcher.
//! The verbs crate applies all of it through ab's identity verbs.

use serde_json::json;
use std::path::{Path, PathBuf};

pub const APP_ID: &str = "Adom.Pup";
pub const DISPLAY_NAME: &str = "Adom Pup";
pub const TILE_GEN: &str = "t11";   // 2.0.689: tiles redrawn smooth (4x supersampled); t10 skipped, older builds left t10 folders behind
pub const ICON_GEN: &str = "9";
pub const TITLE_LABEL: &str = "ai-thread";

pub mod tile;
pub mod badge;
pub static ICONS: &[(&str, &[u8])] = &[
    ("adom-pup.ico", include_bytes!("../../../src/icons/adom-pup.ico")),
    ("pup-cat-default9.ico", include_bytes!("../../../src/icons/pup-cat-default9.ico")),
    ("pup-cat-wiki9.ico", include_bytes!("../../../src/icons/pup-cat-wiki9.ico")),
    ("pup-cat-wiki-in9.ico", include_bytes!("../../../src/icons/pup-cat-wiki-in9.ico")),
    ("pup-cat-web9.ico", include_bytes!("../../../src/icons/pup-cat-web9.ico")),
    ("pup-cat-app9.ico", include_bytes!("../../../src/icons/pup-cat-app9.ico")),
    ("pup-cat-mixed9.ico", include_bytes!("../../../src/icons/pup-cat-mixed9.ico")),
];

/// Write the embedded icons to a folder (skipped when the bytes already match).
pub fn write_icons(dir: &Path) -> std::io::Result<()> {
    std::fs::create_dir_all(dir)?;
    for (name, bytes) in ICONS {
        let p = dir.join(name);
        let same = std::fs::metadata(&p).map(|m| m.len() == bytes.len() as u64).unwrap_or(false);
        if !same { std::fs::write(&p, bytes)?; }
    }
    Ok(())
}

// ── naming (Node: threadSlug / threadAumid / classifyUrl) ──────────────────────────────

pub fn thread_slug(name: &str) -> String {
    let base = name.to_lowercase();
    let mut s = String::new(); let mut last_dash = false;
    for ch in base.chars() { if ch.is_ascii_alphanumeric() { s.push(ch); last_dash = false; } else if !last_dash { s.push('-'); last_dash = true; } }
    let s: String = s.trim_matches('-').chars().take(24).collect();
    let s = if s.is_empty() { "thread".to_string() } else { s };
    format!("{s}-{}", &sha1_hex(name.as_bytes())[..6])
}

/// The tile token folds the tile file's content signature into the AUMID (Windows caches a
/// button's icon per AUMID, so only a virgin AUMID can change art). Same scheme as Node.
pub fn tile_token(ico_path: &Path) -> String {
    match std::fs::metadata(ico_path) {
        Ok(m) => { let mtime = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis()).unwrap_or(0); format!("i{}", &sha1_hex(format!("{}-{}", mtime, m.len()).as_bytes())[..6]) }
        Err(_) => String::new(),
    }
}

pub fn thread_aumid(name: &str, token: &str) -> String { format!("{APP_ID}.thread.{}.{TILE_GEN}{token}", thread_slug(name)) }
pub fn session_aumid(session_id: &str) -> String { format!("{APP_ID}.w.{}", session_id.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect::<String>()) }
pub fn category_aumid(cat: &str) -> String { format!("{APP_ID}.cat.{cat}{ICON_GEN}") }

pub fn is_adom_url(u: &str) -> bool { u.contains("wiki.adom.inc") || u.contains("adom.inc/") }

/// wiki | app | web | None (browser-internal pages)
pub fn classify_url(u: &str) -> Option<&'static str> {
    if u.is_empty() { return None; }
    if is_adom_url(u) { return Some("wiki"); }
    let lower = u.to_ascii_lowercase();
    if lower.starts_with("about:") || lower.starts_with("chrome:") || lower.starts_with("edge:") || lower.starts_with("devtools:") || lower.starts_with("file:") { return None; }
    let host = lower.split("//").nth(1).unwrap_or("").split('/').next().unwrap_or("").split(':').next().unwrap_or("");
    if host == "localhost" || host == "127.0.0.1" || host == "[::1]" || host.ends_with(".adom.cloud") { return Some("app"); }
    Some("web")
}

pub fn category_icon(cat: Option<&str>, wiki_authed: bool) -> &'static str {
    match cat { Some("wiki") => if wiki_authed { "pup-cat-wiki-in9.ico" } else { "pup-cat-wiki9.ico" }, Some("app") => "pup-cat-app9.ico", Some("web") => "pup-cat-web9.ico", Some("mixed") => "pup-cat-mixed9.ico", _ => "pup-cat-default9.ico" }
}

// ── art, rendered by the browser (Node: renderThreadTileIco / dualBadgeB64 / applyAppOverlay) ──

/// The thread-tile canvas script (verbatim port of the Node build's, v2.0.172 sizing rules):
/// resolves to a PNG data URL of a 256x256 teal tile with the name bottom-left.
/// The thread-tile canvas script (verbatim port of the Node build's, v2.0.172 sizing rules):
/// resolves to a PNG data URL of a 256x256 teal tile with the name bottom-left.
pub const TILE_SIZES: [u32; 8] = [16, 20, 24, 32, 40, 48, 64, 256];
/// The tile is rendered ONCE at this size (8x the largest DIB entry, per the brand pipeline) and every
/// ICO entry is produced from it by `brand_downscale`.
/// The thread-tile canvas script: Node v2.0.172 sizing rules on the Adom rounded plate (corner radius =
/// size / 5.5, the pup icon family's own spec), TWO lines at most (three read too small, John
/// 2026-09-06), names with separators wrapped on whole words when that fits at the floor, else Node's
/// character split; never an ellipsis (every character on a tile is identity). Resolves to base64 of the

/// A multi-entry ICO: small sizes as 32-bit DIBs (BGRA bottom-up + an all-opaque AND mask, Node
/// v2.0.160), the 256 entry as PNG. `raw` = (size, RGBA bytes), `png256` = the PNG bytes.
pub fn ico_from_tiles(raw: &[(u32, Vec<u8>)], png256: &[u8]) -> Vec<u8> {
    let mut entries: Vec<(u32, Vec<u8>)> = Vec::new();
    for (s, rgba) in raw {
        let s = *s; let n = (s * s) as usize;
        if rgba.len() < n * 4 { continue; }
        let mut d = Vec::with_capacity(40 + n * 4 + (((s + 31) / 32) * 4 * s) as usize);
        d.extend_from_slice(&40u32.to_le_bytes()); d.extend_from_slice(&(s as i32).to_le_bytes()); d.extend_from_slice(&((s * 2) as i32).to_le_bytes());
        d.extend_from_slice(&1u16.to_le_bytes()); d.extend_from_slice(&32u16.to_le_bytes()); d.extend_from_slice(&[0u8; 24]);
        for y in 0..s { let sy = s - 1 - y; for x in 0..s { let i = ((sy * s + x) * 4) as usize; d.extend_from_slice(&[rgba[i + 2], rgba[i + 1], rgba[i], rgba[i + 3]]); } }
        let and_row = ((s + 31) / 32) * 4; d.extend(std::iter::repeat(0u8).take((and_row * s) as usize));
        entries.push((s, d));
    }
    entries.push((256, png256.to_vec()));
    let n = entries.len() as u16;
    let mut out = Vec::new();
    out.extend_from_slice(&[0, 0, 1, 0]); out.extend_from_slice(&n.to_le_bytes());
    let mut offset = 6 + 16 * entries.len() as u32;
    for (s, d) in &entries {
        out.push(if *s >= 256 { 0 } else { *s as u8 }); out.push(if *s >= 256 { 0 } else { *s as u8 });
        out.extend_from_slice(&[0, 0, 1, 0, 32, 0]);
        out.extend_from_slice(&(d.len() as u32).to_le_bytes()); out.extend_from_slice(&offset.to_le_bytes());
        offset += d.len() as u32;
    }
    for (_, d) in &entries { out.extend_from_slice(d); }
    out
}



// ── the brand raster pipeline, dependency-free (gallia/brand/logos/SKILL.md, the May 2026 shootout) ──
// resvg at 8x -> PIL Lanczos -> ImageMagick unsharp 0.5x0.5+1.5+0.02 -> alpha < 25 clamped to 0.
// Here the 8x render is the canvas; the three raster steps are reproduced below so the bridge needs no
// image library (John: "embed all that into your rust code so you have no dependencies").

fn lanczos3(x: f64) -> f64 { if x == 0.0 { return 1.0; } let a = 3.0; if x.abs() >= a { return 0.0; } let px = std::f64::consts::PI * x; (a * px.sin() * (px / a).sin()) / (px * px) }

/// PIL's LANCZOS: separable, support 3, the kernel widened by the scale factor when shrinking.
pub fn lanczos_resize(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
    fn weights(src_len: u32, dst_len: u32) -> Vec<(usize, Vec<f64>)> {
        let scale = src_len as f64 / dst_len as f64; let support = 3.0 * scale.max(1.0); let ss = 1.0 / scale.max(1.0);
        (0..dst_len).map(|d| {
            let center = (d as f64 + 0.5) * scale; let lo = ((center - support).floor().max(0.0)) as usize; let hi = ((center + support).ceil().min(src_len as f64)) as usize;
            let mut w: Vec<f64> = (lo..hi).map(|s| lanczos3((s as f64 + 0.5 - center) * ss)).collect(); let sum: f64 = w.iter().sum(); if sum != 0.0 { for v in w.iter_mut() { *v /= sum; } }
            (lo, w)
        }).collect()
    }
    let wx = weights(sw, dw); let wy = weights(sh, dh);
    // horizontal pass -> f64 rows of width dw
    let mut tmp = vec![0f64; (dw * sh * 4) as usize];
    for y in 0..sh as usize { for (dx, (lo, w)) in wx.iter().enumerate() { for ch in 0..4 { let mut acc = 0.0; for (k, wv) in w.iter().enumerate() { acc += *wv * src[(y * sw as usize + lo + k) * 4 + ch] as f64; } tmp[(y * dw as usize + dx) * 4 + ch] = acc; } } }
    let mut out = vec![0u8; (dw * dh * 4) as usize];
    for (dy, (lo, w)) in wy.iter().enumerate() { for x in 0..dw as usize { for ch in 0..4 { let mut acc = 0.0; for (k, wv) in w.iter().enumerate() { acc += *wv * tmp[((lo + k) * dw as usize + x) * 4 + ch]; } out[(dy * dw as usize + x) * 4 + ch] = acc.round().clamp(0.0, 255.0) as u8; } } }
    out
}

/// ImageMagick `-unsharp 0.5x0.5+1.5+0.02` on the colour channels: gaussian blur (sigma 0.5, radius 0.5,
/// which IM widens to a 3x3 kernel), then pixel + 1.5 * (pixel - blur) wherever |pixel - blur| exceeds
/// 2% of full scale.
pub fn unsharp_im(px: &mut [u8], w: u32, h: u32) {
    let (w, h) = (w as usize, h as usize);
    let sigma = 0.5f64; let g = |d: f64| (-(d * d) / (2.0 * sigma * sigma)).exp();
    let k = [g(1.0), g(0.0), g(1.0)]; let ksum: f64 = k.iter().sum();
    let src = px.to_vec();
    let at = |x: isize, y: isize, ch: usize| -> f64 { let xx = x.clamp(0, w as isize - 1) as usize; let yy = y.clamp(0, h as isize - 1) as usize; src[(yy * w + xx) * 4 + ch] as f64 };
    let threshold = 0.02 * 255.0; let gain = 1.5;
    for y in 0..h { for x in 0..w { for ch in 0..3 {
        let mut blur = 0.0; for (j, ky) in k.iter().enumerate() { for (i, kx) in k.iter().enumerate() { blur += ky * kx * at(x as isize + i as isize - 1, y as isize + j as isize - 1, ch); } }
        blur /= ksum * ksum; let orig = src[(y * w + x) * 4 + ch] as f64; let diff = orig - blur;
        if diff.abs() > threshold { px[(y * w + x) * 4 + ch] = (orig + gain * diff).round().clamp(0.0, 255.0) as u8; }
    } } }
}

/// The alpha clamp: alpha below 25 becomes fully transparent (kills the dark fringe on rounded corners).
pub fn alpha_clamp(px: &mut [u8]) { for p in px.chunks_mut(4) { if p[3] < 25 { p[0] = 0; p[1] = 0; p[2] = 0; p[3] = 0; } } }

/// One ICO entry's pixels from the 8x render: Lanczos, unsharp, alpha clamp.
pub fn brand_downscale(src: &[u8], sw: u32, size: u32) -> Vec<u8> { let mut px = lanczos_resize(src, sw, sw, size, size); unsharp_im(&mut px, size, size); alpha_clamp(&mut px); px }

/// Minimal PNG encoder (RGBA, stored deflate blocks) for the 256 entry: no image library needed.
pub fn png_encode_rgba(px: &[u8], w: u32, h: u32) -> Vec<u8> {
    fn crc32(data: &[u8]) -> u32 { let mut c = 0xFFFF_FFFFu32; for &b in data { c ^= b as u32; for _ in 0..8 { c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; } } !c }
    fn chunk(out: &mut Vec<u8>, kind: &[u8], data: &[u8]) { out.extend_from_slice(&(data.len() as u32).to_be_bytes()); let mut cd = kind.to_vec(); cd.extend_from_slice(data); out.extend_from_slice(&cd); out.extend_from_slice(&crc32(&cd).to_be_bytes()); }
    let mut raw = Vec::with_capacity((w * h * 4 + h) as usize); for y in 0..h as usize { raw.push(0); raw.extend_from_slice(&px[y * w as usize * 4..(y + 1) * w as usize * 4]); }
    // zlib stream of stored (uncompressed) deflate blocks
    let mut z = vec![0x78, 0x01]; let mut i = 0; while i < raw.len() { let n = (raw.len() - i).min(65535); let last = i + n >= raw.len(); z.push(if last { 1 } else { 0 }); z.extend_from_slice(&(n as u16).to_le_bytes()); z.extend_from_slice(&(!(n as u16)).to_le_bytes()); z.extend_from_slice(&raw[i..i + n]); i += n; }
    let (mut a, mut b) = (1u32, 0u32); for &byte in &raw { a = (a + byte as u32) % 65521; b = (b + a) % 65521; } z.extend_from_slice(&((b << 16) | a).to_be_bytes());
    let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
    let mut ihdr = Vec::new(); ihdr.extend_from_slice(&w.to_be_bytes()); ihdr.extend_from_slice(&h.to_be_bytes()); ihdr.extend_from_slice(&[8, 6, 0, 0, 0]);
    chunk(&mut out, b"IHDR", &ihdr); chunk(&mut out, b"IDAT", &z); chunk(&mut out, b"IEND", &[]); out
}

/// The whole tile ICO from the 8x render: DIB entries for every small size, PNG for 256.
pub fn ico_from_render(rgba: &[u8], render: u32) -> Vec<u8> {
    let mut raw = Vec::new();
    for sz in TILE_SIZES { if sz >= 256 { continue; } raw.push((sz, brand_downscale(rgba, render, sz))); }
    let big = brand_downscale(rgba, render, 256);
    ico_from_tiles(&raw, &png_encode_rgba(&big, 256, 256))
}

/// A single-image ICO wrapping PNG bytes (Vista+ format; Windows AUMID registry icons accept it).
pub fn png_to_ico(png: &[u8], width: u32, height: u32) -> Vec<u8> {
    let mut out = Vec::with_capacity(png.len() + 22);
    out.extend_from_slice(&[0, 0, 1, 0, 1, 0]);
    out.push(if width >= 256 { 0 } else { width as u8 }); out.push(if height >= 256 { 0 } else { height as u8 });
    out.extend_from_slice(&[0, 0, 1, 0, 32, 0]);
    out.extend_from_slice(&(png.len() as u32).to_le_bytes());
    out.extend_from_slice(&22u32.to_le_bytes());
    out.extend_from_slice(png);
    out
}

pub fn png_size(png: &[u8]) -> Option<(u32, u32)> {
    if png.len() < 24 || &png[..8] != b"\x89PNG\r\n\x1a\n" { return None; }
    Some((u32::from_be_bytes([png[16], png[17], png[18], png[19]]), u32::from_be_bytes([png[20], png[21], png[22], png[23]])))
}

// ── the jump-list launcher (Node: ensurePupJumplistLauncher / pupTask / pupRelaunchCommand) ──

pub const LAUNCHER_VERSION: &str = "rs1";

pub fn launcher_script() -> String {
    format!(r#"# Adom Pup jump-list callback (v{LAUNCHER_VERSION}) — pup talks to itself; the CLI wakes it if asleep.
param([Parameter(Mandatory=$true)][string]$Verb, [string]$B64='')
$ErrorActionPreference='Stop'
$json = if ($B64) {{ [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($B64)) }} else {{ '{{}}' }}
try {{ $a = $json | ConvertFrom-Json }} catch {{ $a = @{{}} }}
try {{ $a | Add-Member -NotePropertyName caller -NotePropertyValue (@{{aiThread='user-taskbar-menu'}}) -Force }} catch {{}}
$body = (@{{ command=$Verb; args=$a }} | ConvertTo-Json -Depth 12 -Compress)
$portFile = Join-Path $env:LOCALAPPDATA 'Adom Bridge\pup-bridge-port.json'
$ok=$false
try {{
  $port = (Get-Content $portFile -Raw -ErrorAction Stop | ConvertFrom-Json).port
  Invoke-RestMethod -Uri ("http://127.0.0.1:{{0}}/command" -f $port) -Method Post -ContentType 'application/json' -Headers @{{'x-adom-caller-thread'='user-taskbar-menu';'x-adom-caller-reason'='jump-list click'}} -Body $body -TimeoutSec 60 | Out-Null
  $ok=$true
}} catch {{ $ok=$false }}
if (-not $ok) {{
  $cli = Join-Path $env:LOCALAPPDATA 'Adom Bridge\adom-bridge.exe'
  if (-not (Test-Path $cli)) {{ $cli = Join-Path $env:LOCALAPPDATA 'Adom Bridge\adom-bridge-cli.exe' }}
  if (Test-Path $cli) {{ & $cli $Verb $json | Out-Null }}
}}
"#)
}

pub fn launcher_path(local_app_data: &Path) -> PathBuf { local_app_data.join("Adom Bridge").join("pup-jumplist-call.ps1") }
pub fn port_file_path(local_app_data: &Path) -> PathBuf { local_app_data.join("Adom Bridge").join("pup-bridge-port.json") }

/// `launch(tail, quote_for_relaunch)` -> (target, args): the host's hidden script runner (HostWindows::script_launch).
pub fn task(launch: &dyn Fn(&str, bool) -> (String, String), verb: &str, args: serde_json::Value, title: &str, description: &str, icon: Option<&Path>) -> serde_json::Value {
    let b64 = base64_encode(args.to_string().as_bytes());
    let (target, argv) = launch(&format!("{verb} {b64}"), false);
    json!({ "title": title, "description": description, "iconPath": icon.map(|p| p.to_string_lossy().to_string()), "target": target, "args": argv })
}

pub fn relaunch_command(launch: &dyn Fn(&str, bool) -> (String, String), session_id: &str) -> String {
    let b64 = base64_encode(json!({ "sessionId": session_id, "foregroundReason": "the user clicked this window's taskbar entry" }).to_string().as_bytes());
    let (target, argv) = launch(&format!("pup_focus_window {b64}"), true);
    format!("{target} {argv}")
}

// ── tiny hashing + base64 (no dependencies) ────────────────────────────────────────────

pub fn base64_encode(bytes: &[u8]) -> String {
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);
    for chunk in bytes.chunks(3) {
        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
        out.push(T[(n >> 18) as usize & 63] as char); out.push(T[(n >> 12) as usize & 63] as char);
        out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });
        out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });
    }
    out
}

pub fn sha1_hex(data: &[u8]) -> String {
    let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
    let mut msg = data.to_vec(); let bit_len = (data.len() as u64) * 8;
    msg.push(0x80); while msg.len() % 64 != 56 { msg.push(0); } msg.extend_from_slice(&bit_len.to_be_bytes());
    for block in msg.chunks(64) {
        let mut w = [0u32; 80];
        for i in 0..16 { w[i] = u32::from_be_bytes([block[i * 4], block[i * 4 + 1], block[i * 4 + 2], block[i * 4 + 3]]); }
        for i in 16..80 { w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); }
        let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]);
        for (i, wi) in w.iter().enumerate() {
            let (f, k) = match i { 0..=19 => ((b & c) | (!b & d), 0x5A827999), 20..=39 => (b ^ c ^ d, 0x6ED9EBA1), 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC), _ => (b ^ c ^ d, 0xCA62C1D6) };
            let t = a.rotate_left(5).wrapping_add(f).wrapping_add(e).wrapping_add(k).wrapping_add(*wi);
            e = d; d = c; c = b.rotate_left(30); b = a; a = t;
        }
        h[0] = h[0].wrapping_add(a); h[1] = h[1].wrapping_add(b); h[2] = h[2].wrapping_add(c); h[3] = h[3].wrapping_add(d); h[4] = h[4].wrapping_add(e);
    }
    h.iter().map(|x| format!("{x:08x}")).collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test] fn sha1_known() { assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d"); assert_eq!(sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709"); }
    #[test] fn b64() { assert_eq!(base64_encode(b"hello"), "aGVsbG8="); assert_eq!(base64_encode(b"hi"), "aGk="); assert_eq!(base64_encode(b""), ""); }
    #[test] fn slugs_match_node() {
        // Node: threadSlug('pup-bridge') = 'pup-bridge-' + sha1('pup-bridge')[..6]
        assert_eq!(thread_slug("pup-bridge"), format!("pup-bridge-{}", &sha1_hex(b"pup-bridge")[..6]));
        assert!(thread_slug("My Thread / x").starts_with("my-thread-x-"));
        assert_eq!(thread_aumid("t", "iabc123"), format!("Adom.Pup.thread.t-{}.{}iabc123", &sha1_hex(b"t")[..6], TILE_GEN));
    }
    #[test] fn classify() { assert_eq!(classify_url("https://wiki.adom.inc/adom/x"), Some("wiki")); assert_eq!(classify_url("http://localhost:3000/"), Some("app")); assert_eq!(classify_url("https://x.adom.cloud/proxy/1/"), Some("app")); assert_eq!(classify_url("https://example.com"), Some("web")); assert_eq!(classify_url("about:blank"), None); }
    #[test] fn ico_wraps_png() { let png = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n', 0,0,0,13, b'I',b'H',b'D',b'R', 0,0,1,0, 0,0,1,0, 8,6,0,0,0]; assert_eq!(png_size(&png), Some((256, 256))); let ico = png_to_ico(&png, 256, 256); assert_eq!(&ico[..4], &[0,0,1,0]); assert_eq!(ico.len(), png.len() + 22); }
}