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 +4 -4
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/commands/build.rb +38 -17
- data/lib/everywhere/commands/icon.rb +62 -0
- data/lib/everywhere/icon.rb +159 -0
- data/lib/everywhere/png.rb +54 -0
- data/lib/everywhere/raster.rb +178 -0
- data/lib/everywhere/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6445e84081e08a1d5c545054c0a7dc6ef2e4b05338aff54ea68dc4c48d6b6c60
|
|
4
|
+
data.tar.gz: 2a057925b0ee3266c294046b0be24cb1c0d3fa4b8a82e179b88425cd6afa7e91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1e8528cf2ac3b62191795709e23ee708d1c706672b8fc5d5f2beec6c3455d1a092b71498a086d67506e9c8175efcad1ad2f5c22df0b5d7dbe0f130a3aba8600e
|
|
7
|
+
data.tar.gz: ff1a73eeaabf7d7b839b092b3482c4a06511a6793133b6133f9671ad17d883092877b608fa8db04c9709a419d38c73f905e44dca32d952c992121a18c9e05aa1
|
data/lib/everywhere/cli.rb
CHANGED
|
@@ -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
|
|
128
|
-
# restore the placeholder after; touch build.rs so
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
data/lib/everywhere/png.rb
CHANGED
|
@@ -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
|
data/lib/everywhere/version.rb
CHANGED
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.
|
|
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
|