//! 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 {
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()
}
/// 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).clamp(0.34, 1.0);
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
}
/// 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
}
neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
18.4 KiB · text
History
6ed9d8a