Module: Everywhere::Icon
- Defined in:
- lib/everywhere/icon.rb
Overview
Turns one source PNG into platform-native app icons — no Tauri, no sips, no iconutil, no ImageMagick. Pure Ruby, so it runs the same locally and on the cloud build runners.
The important part is macОS shaping. macOS renders an .icns literally: it does not round the corners or add margins the way iOS does. A full-bleed square PNG therefore shows up as a hard square, out of place next to every native app. So we bake the platform's look into the artwork ourselves — inset the icon to the Big Sur grid (~824px inside a 1024px canvas) and clip it to a superellipse ("squircle") with transparent corners.
Windows (.ico) and Linux (hicolor PNGs) don't get the squircle — those desktops present square icons — so they're generated straight from the full-bleed source.
Constant Summary collapse
- CANVAS =
Big Sur icon-grid geometry, all as fractions of the 1024px canvas so it scales to any master size.
1024- INSET =
icon body is 824/1024 of the canvas
0.8047- CORNER_RATIO =
corner radius as a fraction of the body's side
0.2237- EXPONENT =
superellipse power — the iOS/macOS "squircle" corner
5.0- ICNS_TYPES =
.icns type codes -> pixel size. Mirrors what
iconutilemits from a standard .iconset (retina variants included), so the result is indistinguishable from an Apple-toolchain icns. [ ["icp4", 16], ["icp5", 32], ["ic07", 128], ["ic08", 256], ["ic09", 512], ["ic11", 32], ["ic12", 64], ["ic13", 256], ["ic14", 512], ["ic10", 1024] ].freeze
- ICO_SIZES =
[16, 32, 48, 64, 128, 256].freeze
- LINUX_SIZES =
[16, 32, 48, 64, 128, 256, 512].freeze
- ANDROID_ADAPTIVE_SIZES =
The adaptive icon's foreground layer, one entry per density bucket (mdpi is 1x, the rest step 1.5/2/3/4x) of the 108dp layer. No legacy mipmap-
/ic_launcher.png companions: the shell's minSdk is 28, so every device that can install the app resolves @mipmap/ic_launcher to the anydpi-v26 adaptive icon and could never load them. { "mdpi" => 108, "hdpi" => 162, "xhdpi" => 216, "xxhdpi" => 324, "xxxhdpi" => 432 }.freeze
- ADAPTIVE_ART =
Adaptive-icon safe-zone geometry, as a fraction of the 108dp layer.
The launcher masks both layers to a shape it chooses and can parallax them, so only the centre 72dp is ever guaranteed visible — the outer 18dp on each side is headroom. The most aggressive stock mask is the circle inscribed in that 72dp viewport, and Google's keyline pulls the safe zone one step further in, to a 66dp circle. So a logo goes inside that circle, bounding box and all: side = 66/sqrt(2) = 46.7dp, i.e. 0.432 of the layer. Scaling a logo to the 72dp viewport instead is the usual mistake — it looks correct in a square preview and loses every corner on the round-mask launchers most devices ship with.
(66.0 / Math.sqrt(2)) / 108.0
- CONTENT_ALPHA =
Alpha at or below this is background, not artwork — it survives PNG export slop and antialiased edges without counting them as content.
8- FULL_BLEED_COVERAGE =
Opaque fraction of the source above which it is treated as full-bleed art rather than a logo.
config.iconis one shared icon.png feeding macOS, iOS and Android, and the iOS path wants it opaque corner to corner, so in practice it usually is. Insetting that into the safe zone would render the source's own baked background as a shrunken tile floating inside the launcher's background colour — two nested backgrounds and a tiny logo.0.95 sits in an empty gap. Shaped-but-full-bleed sources stay above it: a Big Sur squircle keeps ~99% of its bounding square, a 22%-rounded rect ~97%. Anything with a deliberate margin falls far below — even a mere 5% border leaves 81%. Nothing real lands near the line.
0.95- ANDROID_ADAPTIVE_ICON_XML =
Both launcher aliases resolve to the same layers; ic_launcher_round exists only because the manifest's android:roundIcon asks for it by name. The layers are @mipmap, not the template placeholder's @drawable, because what gets stamped is a raster per density rather than one vector.
<<~XML <?xml version="1.0" encoding="utf-8"?> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@color/ic_launcher_background" /> <foreground android:drawable="@mipmap/ic_launcher_foreground" /> </adaptive-icon> XML
- ANDROID_THEMED_ADAPTIVE_ICON_XML =
Android 13+ themed ("Material You") icons tint the monochrome layer's alpha and throw its colour away, so the layer is only worth emitting when the alpha is already a silhouette — i.e. the inset branch, where the source arrived as a logo on transparency. Full-bleed art would tint as a solid filled square, which is worse than the launcher's own fallback, so that branch ships the icon without a monochrome layer and lets the fallback happen.
<<~XML <?xml version="1.0" encoding="utf-8"?> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@color/ic_launcher_background" /> <foreground android:drawable="@mipmap/ic_launcher_foreground" /> <monochrome android:drawable="@mipmap/ic_launcher_foreground" /> </adaptive-icon> XML
- ANDROID_BACKGROUND_XML =
The background layer is a colour, not a bitmap: it has to survive being scaled and shifted by the launcher's parallax, which a PNG can't do without banding or seams at the edges.
<<~XML <?xml version="1.0" encoding="utf-8"?> <resources> <color name="ic_launcher_background">%s</color> </resources> XML
- IOS_APPICON_CONTENTS =
Modern (Xcode 14+) single-size asset catalog entry: one 1024px universal image, iOS derives every size itself.
<<~JSON { "images" : [ { "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } JSON
Class Method Summary collapse
-
.android_art(raster) ⇒ Object
How this source wants to be scaled onto a launcher layer.
-
.centred(raster, canvas, box) ⇒ Object
rasterscaled to fit abox-sized square (aspect preserved, never stretched) and centred on a transparentcanvasxcanvasraster. -
.content_bounds(raster) ⇒ Object
[[x0, y0, x1, y1], coverage] for the pixels above CONTENT_ALPHA — the half-open bounding box of the artwork and the fraction of the canvas it actually paints.
-
.crop(raster, x0, y0, x1, y1) ⇒ Object
The [x0, y0, x1, y1) region of
rasteras a new raster. -
.filling(raster, canvas) ⇒ Object
rasterscaled to cover acanvasxcanvassquare and centre-cropped to it — no transparent border anywhere. -
.hex_rgb(hex) ⇒ Object
"#RRGGBB" (or "RRGGBBAA" — alpha ignored) -> [r, g, b] bytes, nil if unparseable.
-
.macos_master(source, dest, canvas: CANVAS) ⇒ Object
Write a macOS-shaped 1024px RGBA master PNG (padding + squircle) from a source PNG.
-
.sized_png(master, size) ⇒ Object
PNG bytes of
masteratsizexsize(no-op resample when already sized). -
.squircle_coverage(x, y, centre, half, straight, radius, exponent) ⇒ Object
Coverage (0.0..1.0) of pixel (x, y) by the squircle: a rounded rectangle with continuous (superelliptical) corners.
-
.squircle_inside?(sx, sy, centre, half, straight, radius, exponent) ⇒ Boolean
Is the continuous point (sx, sy) inside the squircle? Straight edges up to
straightfrom centre, superelliptical corners ofradiusbeyond that. -
.write_android_mipmaps(source, res_dir, background: nil) ⇒ Object
Write the Android launcher icon under
res_dir: an adaptive icon's foreground layer per density, the solid-colour background resource, and the anydpi-v26pair that binds them (ic_launcher and the ic_launcher_round alias the manifest names). -
.write_icns(master_png, dest) ⇒ Object
Build a macOS .icns from an already-shaped master PNG (ideally 1024px).
-
.write_ico(source, dest, sizes: ICO_SIZES) ⇒ Object
Build a Windows .ico (PNG-compressed entries, Vista+) from a full-bleed source PNG.
-
.write_ios_appiconset(source, dest_dir, background: nil) ⇒ Object
Write an iOS AppIcon.appiconset (Contents.json + one 1024px PNG) from a full-bleed source PNG.
-
.write_png_set(source, dest_dir, sizes: LINUX_SIZES) ⇒ Object
Write a set of square PNGs (Linux/desktop use) named "
x .png" into dest_dir from a full-bleed source.
Class Method Details
.android_art(raster) ⇒ Object
How this source wants to be scaled onto a launcher layer. Returns [art, full_bleed] — either the raster untouched, to be scaled edge to edge because it is art designed to be cropped by the mask, or the source cropped to its opaque bounding box, to be inset into the safe zone.
Cropping matters for the inset branch: scaling the whole canvas would let the source's own transparent padding eat into the 66dp circle and land the logo smaller than the safe zone actually allows. A source with no content at all is nothing to measure, so it takes the full-bleed path.
298 299 300 301 302 303 |
# File 'lib/everywhere/icon.rb', line 298 def android_art(raster) bounds, coverage = content_bounds(raster) return [raster, true] if bounds.nil? || coverage >= FULL_BLEED_COVERAGE [crop(raster, *bounds), false] end |
.centred(raster, canvas, box) ⇒ Object
raster scaled to fit a box-sized square (aspect preserved, never
stretched) and centred on a transparent canvasxcanvas raster.
356 357 358 359 360 361 362 363 364 |
# File 'lib/everywhere/icon.rb', line 356 def centred(raster, canvas, box) fit = [box.to_f / raster.width, box.to_f / raster.height].min sw = (raster.width * fit).round.clamp(1, box) sh = (raster.height * fit).round.clamp(1, box) out = Raster.transparent(canvas, canvas) out.paste!(raster.resample(sw, sh), (canvas - sw) / 2, (canvas - sh) / 2) out end |
.content_bounds(raster) ⇒ Object
[[x0, y0, x1, y1], coverage] for the pixels above CONTENT_ALPHA — the half-open bounding box of the artwork and the fraction of the canvas it actually paints. [nil, 0.0] when the raster is entirely transparent.
308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 |
# File 'lib/everywhere/icon.rb', line 308 def content_bounds(raster) x0 = raster.width y0 = raster.height x1 = 0 y1 = 0 opaque = 0 raster.height.times do |y| base = y * raster.width raster.width.times do |x| next if raster.pixels.getbyte(((base + x) * 4) + 3) <= CONTENT_ALPHA opaque += 1 x0 = x if x < x0 x1 = x + 1 if x >= x1 y0 = y if y < y0 y1 = y + 1 if y >= y1 end end return [nil, 0.0] if opaque.zero? [[x0, y0, x1, y1], opaque.to_f / (raster.width * raster.height)] end |
.crop(raster, x0, y0, x1, y1) ⇒ Object
The [x0, y0, x1, y1) region of raster as a new raster.
334 335 336 337 338 339 340 |
# File 'lib/everywhere/icon.rb', line 334 def crop(raster, x0, y0, x1, y1) w = x1 - x0 h = y1 - y0 out = String.new(capacity: w * h * 4) h.times { |y| out << raster.pixels.byteslice(((((y0 + y) * raster.width) + x0) * 4), w * 4) } Raster.new(w, h, out) end |
.filling(raster, canvas) ⇒ Object
raster scaled to cover a canvasxcanvas square and centre-cropped to
it — no transparent border anywhere. Full-bleed art is meant to be
cropped, so a non-square source loses its overhang rather than exposing
bands of background the launcher's mask would read as a mistake.
346 347 348 349 350 351 352 |
# File 'lib/everywhere/icon.rb', line 346 def filling(raster, canvas) fill = [canvas.to_f / raster.width, canvas.to_f / raster.height].max sw = [(raster.width * fill).ceil, canvas].max sh = [(raster.height * fill).ceil, canvas].max crop(raster.resample(sw, sh), (sw - canvas) / 2, (sh - canvas) / 2, ((sw - canvas) / 2) + canvas, ((sh - canvas) / 2) + canvas) end |
.hex_rgb(hex) ⇒ Object
"#RRGGBB" (or "RRGGBBAA" — alpha ignored) -> [r, g, b] bytes, nil if unparseable. For resolving appearance colors into flatten backdrops.
284 285 286 287 |
# File 'lib/everywhere/icon.rb', line 284 def hex_rgb(hex) m = hex.to_s.strip.match(/\A#?(\h{2})(\h{2})(\h{2})/) or return nil m.captures.map { |c| c.to_i(16) } end |
.macos_master(source, dest, canvas: CANVAS) ⇒ Object
Write a macOS-shaped 1024px RGBA master PNG (padding + squircle) from a source PNG. Returns true, or false if the source PNG can't be read.
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 |
# File 'lib/everywhere/icon.rb', line 140 def macos_master(source, dest, canvas: CANVAS) raster = Raster.decode_png(source) or return false body = (canvas * INSET).round offset = (canvas - body) / 2 # Fit the artwork inside the body square preserving aspect ratio (square # sources fill it exactly; non-square ones are centred, not stretched). fit = [body.to_f / raster.width, body.to_f / raster.height].min sw = (raster.width * fit).round.clamp(1, body) sh = (raster.height * fit).round.clamp(1, body) scaled = raster.resample(sw, sh) out = Raster.transparent(canvas, canvas) out.paste!(scaled, offset + (body - sw) / 2, offset + (body - sh) / 2) centre = canvas / 2.0 half = body / 2.0 radius = [body * CORNER_RATIO, half].min straight = half - radius # half-extent of the straight edges out.mask_region!(offset, offset, offset + body, offset + body) do |x, y| squircle_coverage(x, y, centre, half, straight, radius, EXPONENT) end out.write_png(dest) true end |
.sized_png(master, size) ⇒ Object
PNG bytes of master at sizexsize (no-op resample when already sized).
367 368 369 |
# File 'lib/everywhere/icon.rb', line 367 def sized_png(master, size) (size == master.width && size == master.height ? master : master.resample(size, size)).encode_png end |
.squircle_coverage(x, y, centre, half, straight, radius, exponent) ⇒ Object
Coverage (0.0..1.0) of pixel (x, y) by the squircle: a rounded rectangle with continuous (superelliptical) corners. Interior pixels return 1.0, exterior 0.0, and boundary pixels are antialiased by 4x4 supersampling.
374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 |
# File 'lib/everywhere/icon.rb', line 374 def squircle_coverage(x, y, centre, half, straight, radius, exponent) c0 = squircle_inside?(x, y, centre, half, straight, radius, exponent) c1 = squircle_inside?(x + 1, y, centre, half, straight, radius, exponent) c2 = squircle_inside?(x, y + 1, centre, half, straight, radius, exponent) c3 = squircle_inside?(x + 1, y + 1, centre, half, straight, radius, exponent) return 1.0 if c0 && c1 && c2 && c3 return 0.0 unless c0 || c1 || c2 || c3 hit = 0 4.times do |i| 4.times do |j| hit += 1 if squircle_inside?(x + (i + 0.5) / 4.0, y + (j + 0.5) / 4.0, centre, half, straight, radius, exponent) end end hit / 16.0 end |
.squircle_inside?(sx, sy, centre, half, straight, radius, exponent) ⇒ Boolean
Is the continuous point (sx, sy) inside the squircle? Straight edges up to
straight from centre, superelliptical corners of radius beyond that.
394 395 396 397 398 399 400 401 402 403 |
# File 'lib/everywhere/icon.rb', line 394 def squircle_inside?(sx, sy, centre, half, straight, radius, exponent) u = (sx - centre).abs v = (sy - centre).abs return false if u > half || v > half return true if u <= straight || v <= straight du = (u - straight) / radius dv = (v - straight) / radius (du**exponent) + (dv**exponent) <= 1.0 end |
.write_android_mipmaps(source, res_dir, background: nil) ⇒ Object
Write the Android launcher icon under res_dir: an adaptive icon's
foreground layer per density, the solid-colour background resource, and
the anydpi-v26 background is an [r, g, b]
triple as Icon.hex_rgb returns, default white. Returns true, or false if
the source PNG can't be read — same contract as write_ios_appiconset, so
the builder can warn and ship the placeholder.
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 |
# File 'lib/everywhere/icon.rb', line 201 def write_android_mipmaps(source, res_dir, background: nil) raster = Raster.decode_png(source) or return false rgb = Array(background || [255, 255, 255])[0, 3] art, full_bleed = android_art(raster) ANDROID_ADAPTIVE_SIZES.each do |density, layer| dir = File.join(res_dir, "mipmap-#{density}") FileUtils.mkdir_p(dir) # The foreground keeps its alpha — the launcher composites it over the # background layer, and its mask needs the surround empty to cut into. foreground = if full_bleed filling(art, layer) else centred(art, layer, (layer * ADAPTIVE_ART).round) end foreground.write_png(File.join(dir, "ic_launcher_foreground.png")) end xml = full_bleed ? ANDROID_ADAPTIVE_ICON_XML : ANDROID_THEMED_ADAPTIVE_ICON_XML anydpi = File.join(res_dir, "mipmap-anydpi-v26") FileUtils.mkdir_p(anydpi) File.write(File.join(anydpi, "ic_launcher.xml"), xml) File.write(File.join(anydpi, "ic_launcher_round.xml"), xml) values = File.join(res_dir, "values") FileUtils.mkdir_p(values) File.write(File.join(values, "ic_launcher_background.xml"), format(ANDROID_BACKGROUND_XML, format("#%02X%02X%02X", *rgb))) true end |
.write_icns(master_png, dest) ⇒ Object
Build a macOS .icns from an already-shaped master PNG (ideally 1024px). Returns true on success.
236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/everywhere/icon.rb', line 236 def write_icns(master_png, dest) master = Raster.decode_png(master_png) or return false body = ICNS_TYPES.map do |type, size| png = sized_png(master, size) type.b + [png.bytesize + 8].pack("N") + png end.join File.binwrite(dest, "icns".b + [body.bytesize + 8].pack("N") + body) true end |
.write_ico(source, dest, sizes: ICO_SIZES) ⇒ Object
Build a Windows .ico (PNG-compressed entries, Vista+) from a full-bleed source PNG. Returns true on success.
250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'lib/everywhere/icon.rb', line 250 def write_ico(source, dest, sizes: ICO_SIZES) master = Raster.decode_png(source) or return false images = sizes.map { |s| [s, sized_png(master, s)] } offset = 6 + images.size * 16 entries = +"".b blob = +"".b images.each do |size, png| dim = size >= 256 ? 0 : size # 0 means 256 in the ICONDIRENTRY entries << [dim, dim, 0, 0, 1, 32, png.bytesize, offset].pack("CCCCvvVV") blob << png offset += png.bytesize end header = [0, 1, images.size].pack("vvv") # reserved, type=icon, count File.binwrite(dest, header + entries + blob) true end |
.write_ios_appiconset(source, dest_dir, background: nil) ⇒ Object
Write an iOS AppIcon.appiconset (Contents.json + one 1024px PNG) from a
full-bleed source PNG. Unlike macOS, iOS masks the icon itself, so the
artwork ships as a plain square — no inset, no squircle. The App Store
rejects alpha channels, so the source is flattened onto background
([r, g, b] bytes, default white). Returns true, false if unreadable.
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/everywhere/icon.rb', line 173 def write_ios_appiconset(source, dest_dir, background: nil) raster = Raster.decode_png(source) or return false # Fit the artwork inside the canvas preserving aspect ratio (square # sources fill it exactly), then flatten onto the opaque backdrop. fit = [CANVAS.to_f / raster.width, CANVAS.to_f / raster.height].min sw = (raster.width * fit).round.clamp(1, CANVAS) sh = (raster.height * fit).round.clamp(1, CANVAS) canvas = Raster.transparent(CANVAS, CANVAS) canvas.paste!(raster.resample(sw, sh), (CANVAS - sw) / 2, (CANVAS - sh) / 2) out = canvas.flatten_onto(Array(background || [255, 255, 255])[0, 3]) FileUtils.mkdir_p(dest_dir) out.write_png(File.join(dest_dir, "AppIcon.png")) File.write(File.join(dest_dir, "Contents.json"), IOS_APPICON_CONTENTS) true end |
.write_png_set(source, dest_dir, sizes: LINUX_SIZES) ⇒ Object
Write a set of square PNGs (Linux/desktop use) named "
271 272 273 274 275 276 277 278 279 280 |
# File 'lib/everywhere/icon.rb', line 271 def write_png_set(source, dest_dir, sizes: LINUX_SIZES) master = Raster.decode_png(source) or return [] FileUtils.mkdir_p(dest_dir) sizes.map do |size| path = File.join(dest_dir, "#{size}x#{size}.png") File.binwrite(path, sized_png(master, size)) path end end |