19.9 KiB · text History b9e9582
//! 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, OutlineCurve, 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");
/// The binary hands this to egui as well, for the icon picker.
pub 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 {
        anti_alias: true,
        ..Default::default()
    };

    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()
}

/// Where and how one run lands: centre, pixel size, rotation and colour.
struct Placed {
    cx: f32,
    cy: f32,
    px: f32,
    /// Degrees, clockwise on screen.
    rotation: f32,
    rgb: [u8; 3],
}

/// The outline of a glyph or text run as one path, in pixels, y down, with the
/// pen starting at the origin.
fn run_path(text: &str, face: Face, px: f32) -> Option<Path> {
    let f = font(face);
    let scaled = f.as_scaled(px);
    let (hs, vs) = (scaled.h_scale_factor(), scaled.v_scale_factor());

    let mut pb = PathBuilder::new();
    let mut pen = 0.0f32;
    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);
        }
        prev = Some(id);
        let origin = pen;
        pen += scaled.h_advance(id);
        let Some(outline) = f.outline(id) else {
            continue;
        };

        // Font units are y up; flip into image space.
        let at = |p: GlyphPoint| (origin + p.x * hs, -p.y * vs);
        let mut last: Option<GlyphPoint> = None;
        for curve in &outline.curves {
            let (start, end) = match *curve {
                OutlineCurve::Line(a, b) => (a, b),
                OutlineCurve::Quad(a, _, b) => (a, b),
                OutlineCurve::Cubic(a, _, _, b) => (a, b),
            };
            // Curves arrive as loose segments; a break in continuity is the
            // start of the next contour.
            if last != Some(start) {
                let (x, y) = at(start);
                pb.move_to(x, y);
            }
            match *curve {
                OutlineCurve::Line(_, b) => {
                    let (x, y) = at(b);
                    pb.line_to(x, y);
                }
                OutlineCurve::Quad(_, c, b) => {
                    let ((cx, cy), (x, y)) = (at(c), at(b));
                    pb.quad_to(cx, cy, x, y);
                }
                OutlineCurve::Cubic(_, c1, c2, b) => {
                    let ((ax, ay), (bx, by), (x, y)) = (at(c1), at(c2), at(b));
                    pb.cubic_to(ax, ay, bx, by, x, y);
                }
            }
            last = Some(end);
        }
    }
    pb.finish()
}

/// Draws one glyph or a text run, centred on its own ink.
///
/// Drawn as vector outlines rather than rasterised coverage so rotation is a
/// transform, not a resample: edges stay as crisp at 30 degrees as at 0. The
/// whole run is one path filled non-zero, so glyphs that overlap within a run
/// merge instead of darkening at their seams.
fn draw_text(pm: &mut Pixmap, text: &str, face: Face, at: &Placed) {
    let Some(path) = run_path(text, face, at.px) else {
        return;
    };
    // Centre on the ink rather than the nominal advance, which for Material
    // glyphs is much wider than the mark.
    let b = path.bounds();
    let (bx, by) = ((b.left() + b.right()) / 2.0, (b.top() + b.bottom()) / 2.0);
    let transform = Transform::from_translate(-bx, -by)
        .post_rotate(at.rotation)
        .post_translate(at.cx, at.cy);

    let paint = Paint {
        shader: Shader::SolidColor(color_of(at.rgb)),
        anti_alias: true,
        ..Default::default()
    };
    pm.fill_path(&path, &paint, FillRule::Winding, transform, None);
}

/// Renders the foreground layer: glyph or text, any stacked layers, 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 lift = if has_banner { 0.82 } else { 1.0 };
    // Sizes and offsets are in units of the overall scale, so resizing the
    // icon keeps its composition.
    let unit = s * spec.scale * lift;
    let place = |size: f32, rotation: f32, offset: [f32; 2], rgb: [u8; 3]| Placed {
        cx: s / 2.0 + offset[0] * unit,
        cy: cy + offset[1] * unit,
        px: unit * size,
        rotation,
        rgb,
    };

    match &spec.foreground {
        Foreground::Icon(ch) => draw_text(
            &mut pm,
            &ch.to_string(),
            Face::icon(spec.filled),
            &place(
                spec.mark_size,
                spec.mark_rotation,
                spec.mark_offset,
                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).clamp(0.34, 1.0);
            draw_text(
                &mut pm,
                t,
                Face::text(spec.filled),
                &place(
                    spec.mark_size * fit,
                    spec.mark_rotation,
                    spec.mark_offset,
                    spec.fg_color.rgb,
                ),
            )
        }
        _ => {}
    }

    for layer in &spec.layers {
        draw_text(
            &mut pm,
            &layer.glyph.to_string(),
            Face::icon(layer.filled),
            &place(
                layer.size,
                layer.rotation,
                layer.offset,
                spec.layer_color(layer).rgb,
            ),
        );
    }

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

    pm
}

/// Android's adaptive safe zone: of a 108dp canvas, launchers show the middle
/// 72dp. Everything outside it can be cropped away by the mask.
pub const SAFE_ZONE: f32 = 72.0 / 108.0;

/// Corner radius of the flat launcher icon, as a fraction of its side.
///
/// Measured from the shipped cbz icons rather than picked: a least-squares fit
/// of the alpha edge against a circular corner gives 52.3px on the 192px
/// xxxhdpi icon, and the lower densities agree once antialiasing is accounted
/// for.
pub const LEGACY_CORNER: f32 = 0.272;

/// A rounded rectangle covering the whole pixmap, corners of radius `r`.
fn rounded_square(size: f32, r: f32) -> Option<Path> {
    // Circle-approximating cubic control offset.
    const K: f32 = 0.5522847;
    let c = r * K;
    let (lo, hi) = (0.0, size);
    let mut pb = PathBuilder::new();
    pb.move_to(lo + r, lo);
    pb.line_to(hi - r, lo);
    pb.cubic_to(hi - r + c, lo, hi, lo + r - c, hi, lo + r);
    pb.line_to(hi, hi - r);
    pb.cubic_to(hi, hi - r + c, hi - r + c, hi, hi - r, hi);
    pb.line_to(lo + r, hi);
    pb.cubic_to(lo + r - c, hi, lo, hi - r + c, lo, hi - r);
    pb.line_to(lo, lo + r);
    pb.cubic_to(lo, lo + r - c, lo + r - c, lo, lo + r, lo);
    pb.close();
    pb.finish()
}

/// The flat `ic_icon.png` a launcher or a repository index shows when it is not
/// composing the adaptive layers itself.
///
/// Two things distinguish it from [`render_composed`], and getting either
/// wrong is invisible until you look at a store listing: it is cropped to the
/// safe zone, so it frames the mark the way a launcher would, and its corners
/// are masked away. A full-bleed square here is what makes an icon show up as
/// a hard-edged tile among rounded ones.
pub fn render_legacy(spec: &IconSpec, size: u32) -> Pixmap {
    let s = size as f32;
    // Render the whole composition large enough that cropping to the safe zone
    // lands on exactly `size` pixels.
    let full = (s / SAFE_ZONE).round().max(s) as u32;
    let composed = render_composed(spec, full);
    let offset = (full as f32 - s) / 2.0;

    let mut out = Pixmap::new(size, size).expect("non-zero size");
    let mut paint = Paint {
        anti_alias: true,
        ..Default::default()
    };
    paint.shader = Pattern::new(
        composed.as_ref(),
        SpreadMode::Pad,
        tiny_skia::FilterQuality::Bicubic,
        1.0,
        Transform::from_translate(-offset, -offset),
    );
    if let Some(path) = rounded_square(s, s * LEGACY_CORNER) {
        out.fill_path(
            &path,
            &paint,
            FillRule::Winding,
            Transform::identity(),
            None,
        );
    }
    out
}

/// 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 {
        anti_alias: true,
        ..Default::default()
    };
    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
}