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
- 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
-
.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_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
.hex_rgb(hex) ⇒ Object
"#RRGGBB" (or "RRGGBBAA" — alpha ignored) -> [r, g, b] bytes, nil if unparseable. For resolving appearance colors into flatten backdrops.
165 166 167 168 |
# File 'lib/everywhere/icon.rb', line 165 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.
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/everywhere/icon.rb', line 63 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).
171 172 173 |
# File 'lib/everywhere/icon.rb', line 171 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.
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/everywhere/icon.rb', line 178 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.
198 199 200 201 202 203 204 205 206 207 |
# File 'lib/everywhere/icon.rb', line 198 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_icns(master_png, dest) ⇒ Object
Build a macOS .icns from an already-shaped master PNG (ideally 1024px). Returns true on success.
117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/everywhere/icon.rb', line 117 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.
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/everywhere/icon.rb', line 131 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.
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/everywhere/icon.rb', line 96 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 "
152 153 154 155 156 157 158 159 160 161 |
# File 'lib/everywhere/icon.rb', line 152 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 |