15.3 KiB · text History e745ef6
//! Rasterises an [`IconSpec`] with tiny-skia.
//!
//! Deliberately independent of egui: the preview grid and the exported PNG go
//! through this same function, so what you see is what lands on disk. It also
//! means the renderer is testable headlessly, which is how the reconstruction
//! is checked against the icons the original studio shipped.

use ab_glyph::{Font, FontRef, Glyph, OutlinedGlyph, Point as GlyphPoint, ScaleFont};
use tiny_skia::{
    Color, FillRule, GradientStop, LinearGradient, Paint, Path, PathBuilder, Pattern, Pixmap,
    PixmapPaint, Point, RadialGradient, Rect, Shader, SpreadMode, Transform,
};

use crate::spec::{Background, Foreground, IconSpec};

const OUTLINED: &[u8] = include_bytes!("../assets/MaterialSymbolsRounded-Regular.ttf");
const FILLED: &[u8] = include_bytes!("../assets/MaterialSymbolsRounded_Filled-Regular.ttf");

// B612 is the face the cbz apps ship in res/font, so text marks and banners
// match the apps they sit beside. The Material Symbols faces carry only icon
// glyphs at private-use codepoints — rendering "1234" through them lands on
// .notdef, which is where the earlier garbled digits came from.
const TEXT_REGULAR: &[u8] = include_bytes!("../assets/b612_regular.ttf");
const TEXT_BOLD: &[u8] = include_bytes!("../assets/b612_bold.ttf");

/// Which face a run should be rasterised with.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Face {
    IconOutlined,
    IconFilled,
    TextRegular,
    TextBold,
}

impl Face {
    fn icon(filled: bool) -> Self {
        if filled { Face::IconFilled } else { Face::IconOutlined }
    }

    fn text(bold: bool) -> Self {
        if bold { Face::TextBold } else { Face::TextRegular }
    }

    fn bytes(self) -> &'static [u8] {
        match self {
            Face::IconOutlined => OUTLINED,
            Face::IconFilled => FILLED,
            Face::TextRegular => TEXT_REGULAR,
            Face::TextBold => TEXT_BOLD,
        }
    }
}

fn font(face: Face) -> FontRef<'static> {
    FontRef::try_from_slice(face.bytes()).expect("bundled font is valid")
}

fn color_of(rgb: [u8; 3]) -> Color {
    Color::from_rgba8(rgb[0], rgb[1], rgb[2], 255)
}

/// Renders the background layer at `size` square.
pub fn render_background(spec: &IconSpec, size: u32) -> Pixmap {
    let mut pm = Pixmap::new(size, size).expect("non-zero size");
    let s = size as f32;
    let a = color_of(spec.bg_a.rgb);
    let b = color_of(spec.bg_b.rgb);

    pm.fill(a);
    if !spec.background.uses_second_color() {
        return pm;
    }

    let rad = spec.angle.to_radians();
    let (dx, dy) = (rad.cos(), rad.sin());
    let mut paint = Paint::default();
    paint.anti_alias = true;

    match spec.background {
        Background::Solid => {}

        // The attested one: a hard edge corner to corner. Drawn as a polygon
        // rather than a two-stop gradient so the boundary stays crisp — the
        // shipped icons show exactly one blended pixel per row.
        Background::DiagonalSplit => {
            paint.shader = Shader::SolidColor(b);
            let half = s * 1.5;
            let (cx, cy) = (s / 2.0, s / 2.0);
            // Half-plane on one side of the line through the centre. The
            // normal points up-and-right (y grows downward in image space), so
            // at 45 degrees the second colour lands in the upper-right
            // triangle — the orientation the shipped cbz icons use.
            let (px, py) = (dy, -dx);
            let mut pb = PathBuilder::new();
            pb.move_to(cx + dx * half + px * half, cy + dy * half + py * half);
            pb.line_to(cx - dx * half + px * half, cy - dy * half + py * half);
            pb.line_to(cx - dx * half, cy - dy * half);
            pb.line_to(cx + dx * half, cy + dy * half);
            pb.close();
            if let Some(p) = pb.finish() {
                pm.fill_path(&p, &paint, FillRule::Winding, Transform::identity(), None);
            }
        }

        Background::LinearGradient => {
            let (cx, cy) = (s / 2.0, s / 2.0);
            let r = s * 0.75;
            if let Some(sh) = LinearGradient::new(
                Point::from_xy(cx - dx * r, cy - dy * r),
                Point::from_xy(cx + dx * r, cy + dy * r),
                vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)],
                SpreadMode::Pad,
                Transform::identity(),
            ) {
                paint.shader = sh;
                pm.fill_rect(Rect::from_xywh(0.0, 0.0, s, s).unwrap(), &paint,
                             Transform::identity(), None);
            }
        }

        Background::RadialGradient => {
            if let Some(sh) = RadialGradient::new(
                Point::from_xy(s / 2.0, s / 2.0),
                Point::from_xy(s / 2.0, s / 2.0),
                s * 0.7,
                vec![GradientStop::new(0.0, b), GradientStop::new(1.0, a)],
                SpreadMode::Pad,
                Transform::identity(),
            ) {
                paint.shader = sh;
                pm.fill_rect(Rect::from_xywh(0.0, 0.0, s, s).unwrap(), &paint,
                             Transform::identity(), None);
            }
        }

        // tiny-skia has no conic shader, so this is drawn as fine wedges.
        // 512 steps is past the point where banding is visible at icon sizes.
        Background::ConicGradient => {
            let (cx, cy) = (s / 2.0, s / 2.0);
            let r = s * 1.5;
            const STEPS: usize = 512;
            for i in 0..STEPS {
                let t0 = i as f32 / STEPS as f32;
                let t1 = (i + 1) as f32 / STEPS as f32;
                // Triangle wave so the sweep closes seamlessly instead of
                // snapping from b back to a at 360 degrees.
                let m = if t0 < 0.5 { t0 * 2.0 } else { (1.0 - t0) * 2.0 };
                paint.shader = Shader::SolidColor(mix(a, b, m));
                let (a0, a1) = (
                    rad + t0 * std::f32::consts::TAU,
                    rad + t1 * std::f32::consts::TAU + 0.002,
                );
                let mut pb = PathBuilder::new();
                pb.move_to(cx, cy);
                pb.line_to(cx + a0.cos() * r, cy + a0.sin() * r);
                pb.line_to(cx + a1.cos() * r, cy + a1.sin() * r);
                pb.close();
                if let Some(p) = pb.finish() {
                    pm.fill_path(&p, &paint, FillRule::Winding, Transform::identity(), None);
                }
            }
        }

        Background::Stripes => {
            paint.shader = Shader::SolidColor(b);
            let band = s / 12.0;
            let mut t = -s;
            while t < s * 2.0 {
                if let Some(p) = band_path(s, dx, dy, t, band) {
                    pm.fill_path(&p, &paint, FillRule::Winding, Transform::identity(), None);
                }
                t += band * 2.0;
            }
        }

        Background::Checks => {
            paint.shader = Shader::SolidColor(b);
            let n = 8;
            let cell = s / n as f32;
            for row in 0..n {
                for col in 0..n {
                    if (row + col) % 2 == 0 {
                        continue;
                    }
                    if let Some(r) = Rect::from_xywh(col as f32 * cell, row as f32 * cell, cell, cell) {
                        pm.fill_rect(r, &paint, Transform::identity(), None);
                    }
                }
            }
        }

        Background::Dots => {
            paint.shader = Shader::SolidColor(b);
            let n = 7;
            let cell = s / n as f32;
            for row in 0..n {
                for col in 0..n {
                    let (cx, cy) = (col as f32 * cell + cell / 2.0, row as f32 * cell + cell / 2.0);
                    if let Some(p) = PathBuilder::from_circle(cx, cy, cell * 0.3) {
                        pm.fill_path(&p, &paint, FillRule::Winding, Transform::identity(), None);
                    }
                }
            }
        }

        Background::Rays => {
            paint.shader = Shader::SolidColor(b);
            let (cx, cy) = (s / 2.0, s / 2.0);
            let r = s * 1.5;
            const SPOKES: usize = 12;
            for i in 0..SPOKES {
                if i % 2 == 1 {
                    continue;
                }
                let a0 = rad + (i as f32 / SPOKES as f32) * std::f32::consts::TAU;
                let a1 = rad + ((i + 1) as f32 / SPOKES as f32) * std::f32::consts::TAU;
                let mut pb = PathBuilder::new();
                pb.move_to(cx, cy);
                pb.line_to(cx + a0.cos() * r, cy + a0.sin() * r);
                pb.line_to(cx + a1.cos() * r, cy + a1.sin() * r);
                pb.close();
                if let Some(p) = pb.finish() {
                    pm.fill_path(&p, &paint, FillRule::Winding, Transform::identity(), None);
                }
            }
        }

        Background::CornerWedge => {
            paint.shader = Shader::SolidColor(b);
            let mut pb = PathBuilder::new();
            pb.move_to(0.0, s);
            pb.line_to(s, s);
            pb.line_to(s, s * 0.45);
            pb.close();
            if let Some(p) = pb.finish() {
                pm.fill_path(&p, &paint, FillRule::Winding, Transform::identity(), None);
            }
        }
    }

    pm
}

fn mix(a: Color, b: Color, t: f32) -> Color {
    let t = t.clamp(0.0, 1.0);
    Color::from_rgba(
        a.red() + (b.red() - a.red()) * t,
        a.green() + (b.green() - a.green()) * t,
        a.blue() + (b.blue() - a.blue()) * t,
        1.0,
    )
    .unwrap_or(b)
}

/// One stripe of width `band`, offset `t` along the stripe normal.
fn band_path(s: f32, dx: f32, dy: f32, t: f32, band: f32) -> Option<Path> {
    let (px, py) = (-dy, dx);
    let long = s * 2.0;
    let (ox, oy) = (px * t, py * t);
    let mut pb = PathBuilder::new();
    pb.move_to(ox - dx * long, oy - dy * long);
    pb.line_to(ox + dx * long, oy + dy * long);
    pb.line_to(ox + dx * long + px * band, oy + dy * long + py * band);
    pb.line_to(ox - dx * long + px * band, oy - dy * long + py * band);
    pb.close();
    pb.finish()
}

/// Rasterises one glyph or a text run, centred on `(cx, cy)`.
fn draw_text(pm: &mut Pixmap, text: &str, face: Face, px: f32, cx: f32, cy: f32, rgb: [u8; 3]) {
    let f = font(face);
    let scaled = f.as_scaled(px);

    // Lay the run out first so it can be centred on its own ink rather than on
    // the font's nominal advance, which for Material glyphs is much wider.
    let mut pen = 0.0f32;
    let mut outlines: Vec<OutlinedGlyph> = Vec::new();
    let mut prev: Option<ab_glyph::GlyphId> = None;
    for ch in text.chars() {
        let id = f.glyph_id(ch);
        if let Some(p) = prev {
            pen += scaled.kern(p, id);
        }
        let glyph: Glyph = id.with_scale_and_position(px, GlyphPoint { x: pen, y: 0.0 });
        pen += scaled.h_advance(id);
        prev = Some(id);
        if let Some(o) = f.outline_glyph(glyph) {
            outlines.push(o);
        }
    }
    if outlines.is_empty() {
        return;
    }

    let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
    for o in &outlines {
        let b = o.px_bounds();
        x0 = x0.min(b.min.x);
        y0 = y0.min(b.min.y);
        x1 = x1.max(b.max.x);
        y1 = y1.max(b.max.y);
    }
    let (ox, oy) = (cx - (x0 + x1) / 2.0, cy - (y0 + y1) / 2.0);

    // Draw into a coverage mask, then composite once, so overlapping glyph
    // outlines do not double-darken at their seams.
    let w = pm.width();
    let h = pm.height();
    let mut cov = vec![0f32; (w * h) as usize];
    for o in &outlines {
        let b = o.px_bounds();
        o.draw(|gx, gy, c| {
            let x = (b.min.x + ox) as i32 + gx as i32;
            let y = (b.min.y + oy) as i32 + gy as i32;
            if x >= 0 && y >= 0 && (x as u32) < w && (y as u32) < h {
                let i = (y as u32 * w + x as u32) as usize;
                cov[i] = cov[i].max(c);
            }
        });
    }

    // Coverage becomes alpha, and the colour is stored premultiplied. Forcing
    // alpha to 255 here instead would turn every faintly-covered pixel into
    // opaque black, which is exactly what it looks like: a dark box around
    // the mark.
    let data = pm.pixels_mut();
    for (i, &c) in cov.iter().enumerate() {
        let a = c.clamp(0.0, 1.0);
        if a <= 0.0 {
            continue;
        }
        let prem = |v: u8| (v as f32 * a).round().clamp(0.0, 255.0) as u8;
        let alpha = (a * 255.0).round().clamp(0.0, 255.0) as u8;
        if let Some(p) = tiny_skia::PremultipliedColorU8::from_rgba(
            prem(rgb[0]),
            prem(rgb[1]),
            prem(rgb[2]),
            alpha,
        ) {
            // Layers start transparent and each pixel is written once, so the
            // brighter coverage simply wins where glyphs in a run overlap.
            if p.alpha() >= data[i].alpha() {
                data[i] = p;
            }
        }
    }
}

/// Renders the foreground layer: glyph or text, plus the optional sub-banner.
pub fn render_foreground(spec: &IconSpec, size: u32) -> Pixmap {
    let mut pm = Pixmap::new(size, size).expect("non-zero size");
    let s = size as f32;
    let has_banner = !spec.sub_banner.trim().is_empty();

    // With a banner the mark lifts so the two share the safe zone.
    let cy = if has_banner { s * 0.44 } else { s * 0.5 };
    let px = s * spec.scale * if has_banner { 0.82 } else { 1.0 };

    match &spec.foreground {
        Foreground::Icon(ch) => draw_text(
            &mut pm,
            &ch.to_string(),
            Face::icon(spec.filled),
            px,
            s / 2.0,
            cy,
            spec.fg_color.rgb,
        ),
        Foreground::Text(t) if !t.trim().is_empty() => {
            // Text runs wide, so scale down as it lengthens to stay inside the
            // safe zone rather than letting it clip at the mask.
            let n = t.chars().count().max(1) as f32;
            let fit = (2.0 / n).min(1.0).max(0.34);
            draw_text(
                &mut pm,
                t,
                Face::text(spec.filled),
                px * fit,
                s / 2.0,
                cy,
                spec.fg_color.rgb,
            )
        }
        _ => {}
    }

    if has_banner {
        draw_text(
            &mut pm,
            spec.sub_banner.trim(),
            Face::text(true),
            s * 0.13,
            s / 2.0,
            s * 0.72,
            spec.fg_color.rgb,
        );
    }

    pm
}

/// Composites foreground over background — the flattened icon.
pub fn render_composed(spec: &IconSpec, size: u32) -> Pixmap {
    let mut bg = render_background(spec, size);
    let fg = render_foreground(spec, size);
    bg.draw_pixmap(0, 0, fg.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
    bg
}

/// Applies Android's adaptive-icon circular mask, for previewing how a
/// launcher will actually crop it.
pub fn masked(pm: &Pixmap) -> Pixmap {
    let size = pm.width();
    let s = size as f32;
    let mut out = Pixmap::new(size, size).expect("non-zero size");
    let mut paint = Paint::default();
    paint.anti_alias = true;
    paint.shader = Pattern::new(
        pm.as_ref(),
        SpreadMode::Pad,
        tiny_skia::FilterQuality::Nearest,
        1.0,
        Transform::identity(),
    );
    if let Some(circle) = PathBuilder::from_circle(s / 2.0, s / 2.0, s * 0.5) {
        out.fill_path(&circle, &paint, FillRule::Winding, Transform::identity(), None);
    }
    out
}