master
John Lauer rust/: the tree 2.0.594 is built from, pushed whole (pup-bridge#117); ship.sh pushes it on every release from now on ccda43b 1mo ago
//! The taskbar overlay badge (the active tab's favicon on an adaptive card), composed IN THE BINARY.
//! Until 2.0.568 this was JavaScript evaluated inside the page (badge_js), which a strict
//! Content-Security-Policy refused (Google Docs, claude.ai, the wiki), so those windows never got an
//! overlay ("no badge could be composed"; John, 2026-09-14, the all-engineering agenda). Same card as
//! before: 48 px, corner radius 6, #202124 (or #f1f3f4 when the icon is dark-dominant AND transparent),
//! the icon cover-fitted into a 44 px inset box, then scaled to overlaySize percent and pinned north-east
//! so the thread tile's bottom line of text stays readable (2.0.262).
use image::{imageops, Rgba, RgbaImage};

pub const BSZ: u32 = 48;
const FIN: u32 = 2;
/// Chrome's grey globe and the PDF sheet, the SVGs from generic_icon_svg rasterised once at 48 px.
pub static GENERIC_WEB: &[u8] = include_bytes!("../assets/badge-generic-web.png");
pub static GENERIC_PDF: &[u8] = include_bytes!("../assets/badge-generic-pdf.png");

pub struct Analysis { pub luminance: Option<u32>, pub transparent: bool, pub dark_dominant: bool }

/// PNG, ICO, JPEG, GIF, BMP, WebP. SVG favicons are not decodable here (no rasteriser in the binary) and
/// fall through to the next candidate or the generic glyph.
/// SVG or not: the first bytes name the format. Anything else starting with `<` is an HTML error page
/// served where an image was expected, and stays rejected.
pub fn is_svg(bytes: &[u8]) -> bool {
    let head = String::from_utf8_lossy(&bytes[..bytes.len().min(1024)]).to_ascii_lowercase();
    let t = head.trim_start();
    t.starts_with("<svg") || (t.starts_with("<?xml") && head.contains("<svg")) || (t.starts_with("<!--") && head.contains("<svg"))
}
/// Rasterize an SVG favicon to a square RGBA image (fit inside `px`, centered). Straight alpha out.
fn rasterize_svg(bytes: &[u8], px: u32) -> Option<RgbaImage> {
    let tree = resvg::usvg::Tree::from_data(bytes, &resvg::usvg::Options::default()).ok()?;
    let size = tree.size();
    let (w, h) = (size.width(), size.height());
    if w <= 0.0 || h <= 0.0 { return None; }
    let scale = (px as f32 / w).min(px as f32 / h);
    let (ow, oh) = (((w * scale).round() as u32).max(1), ((h * scale).round() as u32).max(1));
    let mut pm = resvg::tiny_skia::Pixmap::new(ow, oh)?;
    resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pm.as_mut());
    let mut out = RgbaImage::new(ow, oh);
    for (i, p) in pm.pixels().iter().enumerate() {
        let c = p.demultiply();
        let (x, y) = ((i as u32) % ow, (i as u32) / ow);
        out.put_pixel(x, y, image::Rgba([c.red(), c.green(), c.blue(), c.alpha()]));
    }
    Some(out)
}
/// The favicon-sourcing guard: an image is anything that is not an HTML page. SVG passes.
pub fn looks_like_image(bytes: &[u8]) -> bool { !bytes.trim_ascii_start().starts_with(b"<") || is_svg(bytes) }
pub fn decode(bytes: &[u8]) -> Option<RgbaImage> {
    if bytes.is_empty() { return None; }
    if bytes.trim_ascii_start().starts_with(b"<") { return if is_svg(bytes) { rasterize_svg(bytes, 64) } else { None }; }
    image::load_from_memory(bytes).ok().map(|i| i.to_rgba8())
}
pub fn generic(is_pdf: bool) -> RgbaImage {
    decode(if is_pdf { GENERIC_PDF } else { GENERIC_WEB }).unwrap_or_else(|| RgbaImage::new(BSZ, BSZ))
}

/// Node analyseIcon: mean luminance of the opaque pixels of a 16 px downscale, and whether any pixel is see-through.
pub fn analyse(img: &RgbaImage) -> Analysis {
    let small = imageops::resize(img, 16, 16, imageops::FilterType::Triangle);
    let (mut sum, mut n, mut clear) = (0f64, 0u32, 0u32);
    for p in small.pixels() {
        if p[3] < 250 { clear += 1; }
        if p[3] > 40 { sum += 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64; n += 1; }
    }
    let luminance = if n > 0 { Some((sum / n as f64).round() as u32) } else { None };
    Analysis { luminance, transparent: clear > 0, dark_dominant: luminance.map(|l| l < 80).unwrap_or(false) }
}

fn rounded_cov(x: u32, y: u32, s: f32, r: f32) -> f32 {
    let (cx, cy) = (x as f32 + 0.5 - s / 2.0, y as f32 + 0.5 - s / 2.0);
    let (dx, dy) = ((cx.abs() - (s / 2.0 - r)).max(0.0), (cy.abs() - (s / 2.0 - r)).max(0.0));
    (0.5 - ((dx * dx + dy * dy).sqrt() - r)).clamp(0.0, 1.0)
}

/// The finished 48 x 48 PNG.
pub fn compose(icon: &RgbaImage, pct: u32, light_card: bool) -> Vec<u8> {
    let fill = if light_card { [0xf1, 0xf3, 0xf4] } else { [0x20, 0x21, 0x24] };
    let mut card = RgbaImage::new(BSZ, BSZ);
    for y in 0..BSZ { for x in 0..BSZ { let a = (rounded_cov(x, y, BSZ as f32, 6.0) * 255.0).round() as u8; card.put_pixel(x, y, Rgba([fill[0], fill[1], fill[2], a])); } }
    // cover-fit into the inset box, centred (drawImage semantics: no clip, the canvas edge clips)
    let bx = (BSZ - FIN * 2) as f32;
    let (iw, ih) = (icon.width().max(1) as f32, icon.height().max(1) as f32);
    let sc = (bx / iw).max(bx / ih);
    let (dw, dh) = (((iw * sc).round() as u32).max(1), ((ih * sc).round() as u32).max(1));
    let scaled = imageops::resize(icon, dw, dh, imageops::FilterType::Lanczos3);
    let (ox, oy) = ((FIN as f32 + (bx - dw as f32) / 2.0).round() as i64, (FIN as f32 + (bx - dh as f32) / 2.0).round() as i64);
    imageops::overlay(&mut card, &scaled, ox, oy);
    let out = if pct < 100 {
        let h = (((BSZ as f32) * pct as f32 / 100.0).round() as u32).max(8);
        let small = imageops::resize(&card, h, h, imageops::FilterType::Lanczos3);
        let mut c2 = RgbaImage::new(BSZ, BSZ);
        imageops::overlay(&mut c2, &small, (BSZ - h) as i64, 0);   // north-east, the tile text stays clear
        c2
    } else { card };
    crate::png_encode_rgba(&out.into_raw(), BSZ, BSZ)
}

/// Crop a PNG (device pixels) to a rect; used by the extension lane's annotate capture, where the
/// whole viewport is captured and the clip is applied here.
pub fn crop_png(png: &[u8], x: u32, y: u32, w: u32, h: u32) -> Option<Vec<u8>> {
    let img = image::load_from_memory(png).ok()?.to_rgba8();
    if img.width() == 0 || img.height() == 0 { return None; }
    let x = x.min(img.width() - 1); let y = y.min(img.height() - 1);
    let w = w.max(1).min(img.width() - x); let h = h.max(1).min(img.height() - y);
    let sub = imageops::crop_imm(&img, x, y, w, h).to_image();
    Some(crate::png_encode_rgba(&sub.into_raw(), w, h))
}

/// The annotate capture with its caption strip (26 px, #0d1117, #00d4cb text) and a 320 px-wide preview,
/// composed in the binary (2.0.569; it was an in-page canvas, which a page CSP refused).
/// Returns (png, preview_rgba, preview_w, preview_h). The preview is RAW RGBA, not PNG: the overlay
/// paints it with putImageData (its v2.0.135 rule, so a page CSP can never veto it as an image load).
/// 2.0.573: the first port handed it a PNG, ImageData threw on the byte length, and John got a toast
/// with no thumbnail ("why the fuck do you keep regressing").
pub fn annotate_compose(png: &[u8], caption: Option<&str>) -> Option<(Vec<u8>, Vec<u8>, u32, u32)> {
    let img = image::load_from_memory(png).ok()?.to_rgba8();
    let (w, h) = (img.width().max(1), img.height().max(1));
    let bar = if caption.is_some() { 26 } else { 0 };
    let mut out = RgbaImage::new(w, h + bar);
    imageops::overlay(&mut out, &img, 0, 0);
    if let Some(text) = caption {
        for y in h..h + bar { for x in 0..w { out.put_pixel(x, y, Rgba([0x0d, 0x11, 0x17, 255])); } }
        if let Some(f) = crate::tile::font_handle() {
            let mut buf = out.into_raw();
            crate::tile::draw_text(&f, &mut buf, w, h + bar, text, 12.0, 8.0, h as f32 + bar as f32 / 2.0 + 4.0, [0x00, 0xd4, 0xcb]);
            out = RgbaImage::from_raw(w, h + bar, buf)?;
        }
    }
    let pw = w.min(320); let ph = ((h as f32) * pw as f32 / w as f32).round().max(1.0) as u32;
    let preview = imageops::resize(&img, pw, ph, imageops::FilterType::Triangle);
    Some((crate::png_encode_rgba(&out.into_raw(), w, h + bar), preview.into_raw(), pw, ph))
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn composes_generic_and_a_real_icon() {
        for is_pdf in [false, true] {
            let g = generic(is_pdf); assert_eq!((g.width(), g.height()), (48, 48));
            let a = analyse(&g); assert!(!a.dark_dominant || !a.transparent);
            let png = compose(&g, 75, false); assert!(png.len() > 200 && png.starts_with(b"\x89PNG"));
            let png100 = compose(&g, 100, true); assert!(png100.len() > 200);
        }
        // a dark, transparent icon (the GitHub-cat case) must pick the light card
        let mut cat = RgbaImage::new(32, 32);
        for y in 8..24 { for x in 8..24 { cat.put_pixel(x, y, Rgba([20, 20, 20, 255])); } }
        let a = analyse(&cat); assert!(a.dark_dominant && a.transparent, "{:?} {} {}", a.luminance, a.transparent, a.dark_dominant);
        let dir = std::env::temp_dir().join("pup-badge-samples"); let _ = std::fs::create_dir_all(&dir);
        std::fs::write(dir.join("cat-75.png"), compose(&cat, 75, a.dark_dominant && a.transparent)).unwrap();
        std::fs::write(dir.join("web-75.png"), compose(&generic(false), 75, false)).unwrap();
        std::fs::write(dir.join("web-100.png"), compose(&generic(false), 100, false)).unwrap();
    }
}