ruby_everywhere 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9bbeb5f84c4ec19197b0943357aab3392040876afe678f504f717acb22201d18
4
- data.tar.gz: c48f81139c0aac8763b0c4fd9d04ace837e9131676a151d8d05fe964219ef79e
3
+ metadata.gz: 6445e84081e08a1d5c545054c0a7dc6ef2e4b05338aff54ea68dc4c48d6b6c60
4
+ data.tar.gz: 2a057925b0ee3266c294046b0be24cb1c0d3fa4b8a82e179b88425cd6afa7e91
5
5
  SHA512:
6
- metadata.gz: 4d144af27b9eaaa9ea2c11c847b41956021ea92d94929d12764f80d1b06dbfa2b43d9fbc43b18e0697c782866a1f46b9a5c5229ed8287ce086aab4c86832d83a
7
- data.tar.gz: ff6f45ab57847acd8897cf0548e3d6b076289f67524863067f1f581a48af5131b34c6da0a4ac82bcdde269c60aa9d809e93f15710395eeae49b61bc46d6fea67
6
+ metadata.gz: 1e8528cf2ac3b62191795709e23ee708d1c706672b8fc5d5f2beec6c3455d1a092b71498a086d67506e9c8175efcad1ad2f5c22df0b5d7dbe0f130a3aba8600e
7
+ data.tar.gz: ff1a73eeaabf7d7b839b092b3482c4a06511a6793133b6133f9671ad17d883092877b608fa8db04c9709a419d38c73f905e44dca32d952c992121a18c9e05aa1
@@ -5,6 +5,7 @@ require_relative "../everywhere"
5
5
  require_relative "framework"
6
6
  require_relative "commands/build"
7
7
  require_relative "commands/dev"
8
+ require_relative "commands/icon"
8
9
  require_relative "commands/doctor"
9
10
  require_relative "commands/install"
10
11
  require_relative "commands/release"
@@ -33,6 +34,7 @@ module Everywhere
33
34
  register "install", Commands::Install
34
35
  register "dev", Commands::Dev
35
36
  register "build", Commands::Build
37
+ register "icon", Commands::Icon
36
38
  register "release", Commands::Release
37
39
 
38
40
  register "platform login", Commands::PlatformLogin
@@ -6,6 +6,7 @@ require "tmpdir"
6
6
  require "shellwords"
7
7
  require_relative "../shellout"
8
8
  require_relative "../png"
9
+ require_relative "../icon"
9
10
  require_relative "../paths"
10
11
 
11
12
  module Everywhere
@@ -122,15 +123,40 @@ module Everywhere
122
123
  end
123
124
  UI.step("app icon: #{icon_source ? icon_source.sub("#{config.root}/", "") : "placeholder"}")
124
125
 
126
+ # macOS renders icons literally — no OS-applied rounding or margins — so
127
+ # a full-bleed square looks wrong next to native apps. Bake the Big Sur
128
+ # look (inset + squircle) into a shaped 1024px master and use it for
129
+ # both the compiled Dock icon and the bundle .icns. work/ is torn down
130
+ # when the .app is assembled.
131
+ work = Dir.mktmpdir("everywhere-icon")
132
+ macos_icon = icon_source
133
+ if icon_source
134
+ shaped = File.join(work, "AppIcon-macos.png")
135
+ if Everywhere::Icon.macos_master(icon_source, shaped)
136
+ macos_icon = shaped
137
+ else
138
+ UI.warn "couldn't read #{File.basename(icon_source)} (unusual PNG flavor) — using it unshaped"
139
+ end
140
+ end
141
+
125
142
  # Tauri bakes icons/icon.png into the shell binary and sets it as the
126
143
  # Dock icon at runtime (stomping the bundle's icns after launch), so the
127
- # shell must be COMPILED with the app's icon. Swap it in for the build,
128
- # restore the placeholder after; touch build.rs so tauri-build re-embeds.
144
+ # shell must be COMPILED with the app's icon. Swap in the shaped master
145
+ # for the build, restore the placeholder after; touch build.rs so
146
+ # tauri-build re-embeds.
129
147
  shell_icon = File.join(shell_dir, "icons", "icon.png")
130
148
  placeholder_backup = "#{shell_icon}.placeholder"
131
149
  if icon_source
132
150
  FileUtils.cp(shell_icon, placeholder_backup) unless File.exist?(placeholder_backup)
133
- unless Everywhere::PNG.ensure_rgba(icon_source, shell_icon)
151
+ # The shaped master is already RGBA; fall back to raw RGBA conversion
152
+ # if shaping failed, and to the placeholder if even that fails.
153
+ dock_ok = if macos_icon == icon_source
154
+ Everywhere::PNG.ensure_rgba(icon_source, shell_icon)
155
+ else
156
+ FileUtils.cp(macos_icon, shell_icon)
157
+ true
158
+ end
159
+ unless dock_ok
134
160
  UI.warn "couldn't convert #{File.basename(icon_source)} to RGBA (unusual PNG flavor) — Dock icon will use the placeholder after launch"
135
161
  FileUtils.cp(placeholder_backup, shell_icon)
136
162
  end
@@ -171,7 +197,7 @@ module Everywhere
171
197
  UI.warn "splash #{splash} not found — using the default splash"
172
198
  end
173
199
  end
174
- icns = make_icns(icon_source || File.join(shell_dir, "icons", "icon.png"), resources)
200
+ icns = make_icns(macos_icon || File.join(shell_dir, "icons", "icon.png"), resources)
175
201
  File.write(File.join(app_dir, "Contents", "Info.plist"),
176
202
  info_plist(app_name, exe_name, icns, config.bundle_id, config.version))
177
203
 
@@ -185,24 +211,19 @@ module Everywhere
185
211
  Shellout.run?("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f #{app_dir.shellescape} 2>/dev/null")
186
212
 
187
213
  UI.success("app bundle: #{app_dir.sub("#{Dir.pwd}/", "")} #{UI.dim("(open it!)")}")
214
+ ensure
215
+ FileUtils.remove_entry(work) if work && File.directory?(work)
188
216
  end
189
217
 
190
- # macOS ships everything needed to make an .icns from a png.
218
+ # Write Resources/AppIcon.icns from a (shaped) PNG. Pure Ruby no sips or
219
+ # iconutil — so it produces the same icns on any build host.
191
220
  def make_icns(png, resources_dir)
192
221
  return nil unless File.exist?(png)
193
222
 
194
- require "shellwords"
195
- Dir.mktmpdir do |tmp|
196
- iconset = File.join(tmp, "App.iconset")
197
- FileUtils.mkdir_p(iconset)
198
- [16, 32, 128, 256, 512].each do |size|
199
- [[size, "icon_#{size}x#{size}.png"], [size * 2, "icon_#{size}x#{size}@2x.png"]].each do |px, name|
200
- Shellout.run?("sips -z #{px} #{px} #{png.shellescape} --out #{File.join(iconset, name).shellescape} >/dev/null 2>&1")
201
- end
202
- end
203
- out = File.join(resources_dir, "AppIcon.icns")
204
- return "AppIcon" if Shellout.run?("iconutil -c icns #{iconset.shellescape} -o #{out.shellescape} >/dev/null 2>&1")
205
- end
223
+ out = File.join(resources_dir, "AppIcon.icns")
224
+ Everywhere::Icon.write_icns(png, out) ? "AppIcon" : nil
225
+ rescue StandardError => e
226
+ UI.warn "icon generation failed (#{e.message}) — bundling without a custom icon"
206
227
  nil
207
228
  end
208
229
 
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require "fileutils"
5
+ require_relative "../icon"
6
+ require_relative "../config"
7
+ require_relative "../ui"
8
+
9
+ module Everywhere
10
+ module Commands
11
+ # Generate platform-native app icons from a single source PNG — the same
12
+ # pipeline `every build` uses, exposed on its own for previewing and CI.
13
+ class Icon < Dry::CLI::Command
14
+ desc "Generate macOS (.icns), Windows (.ico) and Linux icons from a source PNG"
15
+
16
+ argument :source, required: false, desc: "Source PNG (default: app.icon / icon.png from everywhere.yml)"
17
+ option :root, default: ".", desc: "App root directory (to resolve the default icon)"
18
+ option :output, default: nil, desc: "Output directory (default: dist/icons)"
19
+
20
+ def call(source: nil, root: ".", output: nil, **)
21
+ root_path = File.expand_path(root)
22
+ source ||= default_source(root_path)
23
+ UI.die!("no source PNG — pass one or set app.icon in everywhere.yml") unless source
24
+ UI.die!("source PNG not found: #{source}") unless File.exist?(source)
25
+
26
+ out = File.expand_path(output || File.join(root_path, "dist", "icons"))
27
+ FileUtils.mkdir_p(out)
28
+ UI.step("generating icons from #{File.basename(source)} → #{out.sub("#{Dir.pwd}/", "")}")
29
+
30
+ master = File.join(out, "AppIcon-macos.png")
31
+ unless Everywhere::Icon.macos_master(source, master)
32
+ UI.die!("couldn't read #{File.basename(source)} — expected an 8-bit RGB/RGBA PNG")
33
+ end
34
+ UI.ok("macOS master #{rel(master, out)} #{UI.dim("(inset + squircle, 1024px)")}")
35
+
36
+ Everywhere::Icon.write_icns(master, File.join(out, "AppIcon.icns"))
37
+ UI.ok("macOS icon AppIcon.icns")
38
+
39
+ Everywhere::Icon.write_ico(source, File.join(out, "icon.ico"))
40
+ UI.ok("Windows icon icon.ico")
41
+
42
+ pngs = Everywhere::Icon.write_png_set(source, File.join(out, "linux"))
43
+ UI.ok("Linux icons linux/ #{UI.dim("(#{pngs.size} sizes)")}")
44
+
45
+ UI.success("icons written to #{out.sub("#{Dir.pwd}/", "")}")
46
+ end
47
+
48
+ private
49
+
50
+ def rel(path, base)
51
+ path.sub("#{base}/", "")
52
+ end
53
+
54
+ def default_source(root_path)
55
+ Everywhere::Config.load(root_path).icon
56
+ rescue StandardError
57
+ default = File.join(root_path, "icon.png")
58
+ File.exist?(default) ? default : nil
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "raster"
5
+
6
+ module Everywhere
7
+ # Turns one source PNG into platform-native app icons — no Tauri, no sips, no
8
+ # iconutil, no ImageMagick. Pure Ruby, so it runs the same locally and on the
9
+ # cloud build runners.
10
+ #
11
+ # The important part is macОS shaping. macOS renders an .icns *literally*: it
12
+ # does not round the corners or add margins the way iOS does. A full-bleed
13
+ # square PNG therefore shows up as a hard square, out of place next to every
14
+ # native app. So we bake the platform's look into the artwork ourselves —
15
+ # inset the icon to the Big Sur grid (~824px inside a 1024px canvas) and clip
16
+ # it to a superellipse ("squircle") with transparent corners.
17
+ #
18
+ # Windows (.ico) and Linux (hicolor PNGs) don't get the squircle — those
19
+ # desktops present square icons — so they're generated straight from the
20
+ # full-bleed source.
21
+ module Icon
22
+ # Big Sur icon-grid geometry, all as fractions of the 1024px canvas so it
23
+ # scales to any master size.
24
+ CANVAS = 1024
25
+ INSET = 0.8047 # icon body is 824/1024 of the canvas
26
+ CORNER_RATIO = 0.2237 # corner radius as a fraction of the body's side
27
+ EXPONENT = 5.0 # superellipse power — the iOS/macOS "squircle" corner
28
+
29
+ # .icns type codes -> pixel size. Mirrors what `iconutil` emits from a
30
+ # standard .iconset (retina variants included), so the result is
31
+ # indistinguishable from an Apple-toolchain icns.
32
+ ICNS_TYPES = [
33
+ ["icp4", 16], ["icp5", 32], ["ic07", 128], ["ic08", 256], ["ic09", 512],
34
+ ["ic11", 32], ["ic12", 64], ["ic13", 256], ["ic14", 512], ["ic10", 1024]
35
+ ].freeze
36
+
37
+ ICO_SIZES = [16, 32, 48, 64, 128, 256].freeze
38
+ LINUX_SIZES = [16, 32, 48, 64, 128, 256, 512].freeze
39
+
40
+ module_function
41
+
42
+ # Write a macOS-shaped 1024px RGBA master PNG (padding + squircle) from a
43
+ # source PNG. Returns true, or false if the source PNG can't be read.
44
+ def macos_master(source, dest, canvas: CANVAS)
45
+ raster = Raster.decode_png(source) or return false
46
+
47
+ body = (canvas * INSET).round
48
+ offset = (canvas - body) / 2
49
+
50
+ # Fit the artwork inside the body square preserving aspect ratio (square
51
+ # sources fill it exactly; non-square ones are centred, not stretched).
52
+ fit = [body.to_f / raster.width, body.to_f / raster.height].min
53
+ sw = (raster.width * fit).round.clamp(1, body)
54
+ sh = (raster.height * fit).round.clamp(1, body)
55
+ scaled = raster.resample(sw, sh)
56
+
57
+ out = Raster.transparent(canvas, canvas)
58
+ out.paste!(scaled, offset + (body - sw) / 2, offset + (body - sh) / 2)
59
+
60
+ centre = canvas / 2.0
61
+ half = body / 2.0
62
+ radius = [body * CORNER_RATIO, half].min
63
+ straight = half - radius # half-extent of the straight edges
64
+ out.mask_region!(offset, offset, offset + body, offset + body) do |x, y|
65
+ squircle_coverage(x, y, centre, half, straight, radius, EXPONENT)
66
+ end
67
+
68
+ out.write_png(dest)
69
+ true
70
+ end
71
+
72
+ # Build a macOS .icns from an already-shaped master PNG (ideally 1024px).
73
+ # Returns true on success.
74
+ def write_icns(master_png, dest)
75
+ master = Raster.decode_png(master_png) or return false
76
+
77
+ body = ICNS_TYPES.map do |type, size|
78
+ png = sized_png(master, size)
79
+ type.b + [png.bytesize + 8].pack("N") + png
80
+ end.join
81
+
82
+ File.binwrite(dest, "icns".b + [body.bytesize + 8].pack("N") + body)
83
+ true
84
+ end
85
+
86
+ # Build a Windows .ico (PNG-compressed entries, Vista+) from a full-bleed
87
+ # source PNG. Returns true on success.
88
+ def write_ico(source, dest, sizes: ICO_SIZES)
89
+ master = Raster.decode_png(source) or return false
90
+
91
+ images = sizes.map { |s| [s, sized_png(master, s)] }
92
+ offset = 6 + images.size * 16
93
+ entries = +"".b
94
+ blob = +"".b
95
+ images.each do |size, png|
96
+ dim = size >= 256 ? 0 : size # 0 means 256 in the ICONDIRENTRY
97
+ entries << [dim, dim, 0, 0, 1, 32, png.bytesize, offset].pack("CCCCvvVV")
98
+ blob << png
99
+ offset += png.bytesize
100
+ end
101
+
102
+ header = [0, 1, images.size].pack("vvv") # reserved, type=icon, count
103
+ File.binwrite(dest, header + entries + blob)
104
+ true
105
+ end
106
+
107
+ # Write a set of square PNGs (Linux/desktop use) named "<size>x<size>.png"
108
+ # into dest_dir from a full-bleed source. Returns the written paths.
109
+ def write_png_set(source, dest_dir, sizes: LINUX_SIZES)
110
+ master = Raster.decode_png(source) or return []
111
+
112
+ FileUtils.mkdir_p(dest_dir)
113
+ sizes.map do |size|
114
+ path = File.join(dest_dir, "#{size}x#{size}.png")
115
+ File.binwrite(path, sized_png(master, size))
116
+ path
117
+ end
118
+ end
119
+
120
+ # PNG bytes of `master` at `size`x`size` (no-op resample when already sized).
121
+ def sized_png(master, size)
122
+ (size == master.width && size == master.height ? master : master.resample(size, size)).encode_png
123
+ end
124
+
125
+ # Coverage (0.0..1.0) of pixel (x, y) by the squircle: a rounded rectangle
126
+ # with continuous (superelliptical) corners. Interior pixels return 1.0,
127
+ # exterior 0.0, and boundary pixels are antialiased by 4x4 supersampling.
128
+ def squircle_coverage(x, y, centre, half, straight, radius, exponent)
129
+ c0 = squircle_inside?(x, y, centre, half, straight, radius, exponent)
130
+ c1 = squircle_inside?(x + 1, y, centre, half, straight, radius, exponent)
131
+ c2 = squircle_inside?(x, y + 1, centre, half, straight, radius, exponent)
132
+ c3 = squircle_inside?(x + 1, y + 1, centre, half, straight, radius, exponent)
133
+ return 1.0 if c0 && c1 && c2 && c3
134
+ return 0.0 unless c0 || c1 || c2 || c3
135
+
136
+ hit = 0
137
+ 4.times do |i|
138
+ 4.times do |j|
139
+ hit += 1 if squircle_inside?(x + (i + 0.5) / 4.0, y + (j + 0.5) / 4.0,
140
+ centre, half, straight, radius, exponent)
141
+ end
142
+ end
143
+ hit / 16.0
144
+ end
145
+
146
+ # Is the continuous point (sx, sy) inside the squircle? Straight edges up to
147
+ # `straight` from centre, superelliptical corners of `radius` beyond that.
148
+ def squircle_inside?(sx, sy, centre, half, straight, radius, exponent)
149
+ u = (sx - centre).abs
150
+ v = (sy - centre).abs
151
+ return false if u > half || v > half
152
+ return true if u <= straight || v <= straight
153
+
154
+ du = (u - straight) / radius
155
+ dv = (v - straight) / radius
156
+ (du**exponent) + (dv**exponent) <= 1.0
157
+ end
158
+ end
159
+ end
@@ -39,6 +39,60 @@ module Everywhere
39
39
  true
40
40
  end
41
41
 
42
+ # Decode an 8-bit RGB/RGBA PNG to a flat RGBA pixel buffer. Returns
43
+ # [width, height, bytes] (bytes = width*height*4, no per-scanline filter
44
+ # byte) or nil for flavors we don't handle (palette, 16-bit, interlaced,
45
+ # grayscale). This is the pixel gateway the Raster/Icon generators build on.
46
+ def decode_rgba(path)
47
+ data = File.binread(path)
48
+ return nil unless data.start_with?(SIGNATURE)
49
+
50
+ chunks = read_chunks(data)
51
+ ihdr = chunks.find { |type, _| type == "IHDR" }&.last or return nil
52
+ width, height, depth, color, _comp, _filter, interlace = ihdr.unpack("NNC5")
53
+ return nil unless depth == 8 && interlace.zero?
54
+ return nil unless color == 2 || color == 6 # RGB or RGBA
55
+
56
+ bpp = color == 6 ? 4 : 3
57
+ raw = Zlib::Inflate.inflate(chunks.select { |t, _| t == "IDAT" }.map(&:last).join)
58
+ stride = width * bpp
59
+ prev = "\0".b * stride
60
+ out = String.new(capacity: width * height * 4)
61
+
62
+ height.times do |y|
63
+ filter = raw.getbyte(y * (stride + 1))
64
+ line = raw.byteslice(y * (stride + 1) + 1, stride).dup
65
+ unfilter!(line, prev, filter, bpp)
66
+ if bpp == 4
67
+ out << line
68
+ else
69
+ x = 0
70
+ while x < width
71
+ out << line.byteslice(x * 3, 3) << "\xFF".b
72
+ x += 1
73
+ end
74
+ end
75
+ prev = line
76
+ end
77
+ [width, height, out]
78
+ end
79
+
80
+ # Encode a flat RGBA pixel buffer to PNG bytes (filter 0, single IDAT).
81
+ def encode_rgba(width, height, rgba)
82
+ stride = width * 4
83
+ raw = String.new(capacity: (stride + 1) * height)
84
+ height.times do |y|
85
+ raw << "\0".b # filter: None
86
+ raw << rgba.byteslice(y * stride, stride)
87
+ end
88
+
89
+ out = SIGNATURE.dup
90
+ out << chunk("IHDR", [width, height, 8, 6, 0, 0, 0].pack("NNC5"))
91
+ out << chunk("IDAT", Zlib::Deflate.deflate(raw, Zlib::BEST_COMPRESSION))
92
+ out << chunk("IEND", "")
93
+ out
94
+ end
95
+
42
96
  def read_chunks(data)
43
97
  chunks = []
44
98
  pos = 8
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "png"
4
+
5
+ module Everywhere
6
+ # A mutable RGBA raster and the handful of pixel operations the icon
7
+ # generator needs — decode/encode PNG, high-quality resampling, pasting, and
8
+ # a superellipse ("squircle") alpha mask. Pure Ruby (zlib only): no
9
+ # ImageMagick, no sips, no Tauri. Works the same on any OS the CLI runs on.
10
+ #
11
+ # Pixels are stored as a flat 8-bit RGBA byte string (row-major, 4 bytes per
12
+ # pixel). Resampling is a separable area/box filter done in *premultiplied*
13
+ # alpha so transparent regions never bleed their colour into opaque edges.
14
+ class Raster
15
+ attr_reader :width, :height, :pixels
16
+
17
+ def initialize(width, height, pixels = nil)
18
+ @width = width
19
+ @height = height
20
+ @pixels = pixels || ("\x00".b * (width * height * 4))
21
+ end
22
+
23
+ # A fully transparent canvas.
24
+ def self.transparent(width, height)
25
+ new(width, height)
26
+ end
27
+
28
+ # Decode a PNG file into a Raster, or nil if it's a flavor PNG.decode_rgba
29
+ # can't read (palette, 16-bit, interlaced, grayscale).
30
+ def self.decode_png(path)
31
+ width, height, bytes = PNG.decode_rgba(path)
32
+ return nil unless width
33
+
34
+ new(width, height, bytes)
35
+ end
36
+
37
+ def encode_png
38
+ PNG.encode_rgba(width, height, pixels)
39
+ end
40
+
41
+ def write_png(path)
42
+ File.binwrite(path, encode_png)
43
+ end
44
+
45
+ # Resample to a new size with a separable area filter. Great for the
46
+ # downscales an icon set is made of; degrades gracefully on upscale.
47
+ def resample(new_width, new_height)
48
+ return self if new_width == width && new_height == height
49
+
50
+ # Premultiplied float channels: pr = r * (a/255), etc.
51
+ pr = Array.new(width * height)
52
+ pg = Array.new(width * height)
53
+ pb = Array.new(width * height)
54
+ pa = Array.new(width * height)
55
+ i = 0
56
+ (width * height).times do |p|
57
+ a = pixels.getbyte(i + 3)
58
+ af = a / 255.0
59
+ pr[p] = pixels.getbyte(i) * af
60
+ pg[p] = pixels.getbyte(i + 1) * af
61
+ pb[p] = pixels.getbyte(i + 2) * af
62
+ pa[p] = a.to_f
63
+ i += 4
64
+ end
65
+
66
+ # Horizontal pass (width -> new_width), then vertical (height -> new_height).
67
+ wx = self.class.axis_weights(width, new_width)
68
+ hr = resample_axis(pr, width, height, new_width, wx, rows: true)
69
+ hg = resample_axis(pg, width, height, new_width, wx, rows: true)
70
+ hb = resample_axis(pb, width, height, new_width, wx, rows: true)
71
+ ha = resample_axis(pa, width, height, new_width, wx, rows: true)
72
+
73
+ wy = self.class.axis_weights(height, new_height)
74
+ vr = resample_axis(hr, new_width, height, new_height, wy, rows: false)
75
+ vg = resample_axis(hg, new_width, height, new_height, wy, rows: false)
76
+ vb = resample_axis(hb, new_width, height, new_height, wy, rows: false)
77
+ va = resample_axis(ha, new_width, height, new_height, wy, rows: false)
78
+
79
+ out = String.new(capacity: new_width * new_height * 4)
80
+ (new_width * new_height).times do |p|
81
+ a = va[p]
82
+ if a <= 0.001
83
+ out << "\x00\x00\x00\x00".b
84
+ else
85
+ out << [
86
+ (vr[p] * 255.0 / a).round.clamp(0, 255),
87
+ (vg[p] * 255.0 / a).round.clamp(0, 255),
88
+ (vb[p] * 255.0 / a).round.clamp(0, 255),
89
+ a.round.clamp(0, 255)
90
+ ].pack("C4")
91
+ end
92
+ end
93
+ self.class.new(new_width, new_height, out)
94
+ end
95
+
96
+ # Copy src into this raster with its top-left at (ox, oy), replacing pixels
97
+ # (the canvas is transparent underneath, so a plain copy is the composite).
98
+ def paste!(src, ox, oy)
99
+ row = src.width * 4
100
+ src.height.times do |y|
101
+ dst_index = ((oy + y) * width + ox) * 4
102
+ pixels[dst_index, row] = src.pixels.byteslice(y * row, row)
103
+ end
104
+ self
105
+ end
106
+
107
+ # Multiply the alpha channel of every pixel in [x0, x1) x [y0, y1) by the
108
+ # coverage the block returns for pixel (x, y) — a value in 0.0..1.0.
109
+ # Pixels outside the range keep their (already transparent) alpha.
110
+ def mask_region!(x0, y0, x1, y1)
111
+ y0.upto(y1 - 1) do |y|
112
+ base = y * width
113
+ x0.upto(x1 - 1) do |x|
114
+ cov = yield(x, y)
115
+ next if cov >= 1.0
116
+
117
+ idx = (base + x) * 4 + 3
118
+ a = pixels.getbyte(idx)
119
+ next if a.zero?
120
+
121
+ pixels.setbyte(idx, cov <= 0.0 ? 0 : (a * cov).round)
122
+ end
123
+ end
124
+ self
125
+ end
126
+
127
+ # For each output index along an axis of `n_out` pixels, the list of
128
+ # [input_index, weight] contributions, where weights sum to 1 (area filter:
129
+ # each output pixel is the average of the input pixels it overlaps).
130
+ def self.axis_weights(n_in, n_out)
131
+ scale = n_in.to_f / n_out
132
+ Array.new(n_out) do |i|
133
+ start = i * scale
134
+ finish = [start + scale, n_in.to_f].min # float slop can push this past n_in
135
+ weights = []
136
+ k = [start.floor, 0].max
137
+ while k < finish && k < n_in
138
+ lo = [k.to_f, start].max
139
+ hi = [k + 1.0, finish].min
140
+ overlap = hi - lo
141
+ weights << [k, overlap / scale] if overlap > 0
142
+ k += 1
143
+ end
144
+ weights
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ # Resample one float channel along a single axis. `rows: true` resizes each
151
+ # row from `n_in` to `n_out` columns; `rows: false` resizes each column.
152
+ def resample_axis(src, in_w, in_h, n_out, weights, rows:)
153
+ if rows
154
+ out = Array.new(n_out * in_h, 0.0)
155
+ in_h.times do |y|
156
+ src_base = y * in_w
157
+ out_base = y * n_out
158
+ n_out.times do |o|
159
+ acc = 0.0
160
+ weights[o].each { |k, wt| acc += src[src_base + k] * wt }
161
+ out[out_base + o] = acc
162
+ end
163
+ end
164
+ out
165
+ else
166
+ out = Array.new(in_w * n_out, 0.0)
167
+ in_w.times do |x|
168
+ n_out.times do |o|
169
+ acc = 0.0
170
+ weights[o].each { |k, wt| acc += src[k * in_w + x] * wt }
171
+ out[o * in_w + x] = acc
172
+ end
173
+ end
174
+ out
175
+ end
176
+ end
177
+ end
178
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Everywhere
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
 
6
6
  # Version of the vendored @rubyeverywhere/bridge JS this gem ships. Tracks
7
7
  # bridge/package.json — bump it whenever lib/everywhere/javascript/bridge.js is
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_everywhere
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrea Fomera
@@ -55,6 +55,7 @@ files:
55
55
  - lib/everywhere/commands/build.rb
56
56
  - lib/everywhere/commands/dev.rb
57
57
  - lib/everywhere/commands/doctor.rb
58
+ - lib/everywhere/commands/icon.rb
58
59
  - lib/everywhere/commands/install.rb
59
60
  - lib/everywhere/commands/platform/auth_status.rb
60
61
  - lib/everywhere/commands/platform/build.rb
@@ -65,6 +66,7 @@ files:
65
66
  - lib/everywhere/config.rb
66
67
  - lib/everywhere/database.rb
67
68
  - lib/everywhere/framework.rb
69
+ - lib/everywhere/icon.rb
68
70
  - lib/everywhere/ignore.rb
69
71
  - lib/everywhere/javascript/bridge.js
70
72
  - lib/everywhere/paths.rb
@@ -72,6 +74,7 @@ files:
72
74
  - lib/everywhere/platform/credentials.rb
73
75
  - lib/everywhere/platform/snapshot.rb
74
76
  - lib/everywhere/png.rb
77
+ - lib/everywhere/raster.rb
75
78
  - lib/everywhere/receipt.rb
76
79
  - lib/everywhere/shellout.rb
77
80
  - lib/everywhere/ui.rb