-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRakefile
More file actions
297 lines (254 loc) · 11.7 KB
/
Copy pathRakefile
File metadata and controls
297 lines (254 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
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
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# Every raster in this repo is generated from the two SVG masters, so they all
# stay in step. After changing artwork, plain `rake` rebuilds the lot from
# scratch and offers to copy it into the website and assets repos; `rake icons`,
# `rake web`, and `rake assets` are the individual steps.
#
# Naming rule: no suffix means the square logo on the Ruby 2D red, which is the
# common case. `_transparent` means the gem on its own.
#
# Needs `magick` (brew install imagemagick). Rendering goes through headless
# Chrome rather than ImageMagick: IM has no librsvg delegate installed and its
# own SVG renderer silently drops the gem's white stroke. QuickLook renders the
# stroke correctly but flattens transparency onto white, so it can't produce
# the transparent PNG either.
require "tmpdir"
require "fileutils"
SQUARE_SVG = "ruby2d.svg" # master: square, red background
PLAIN_SVG = "ruby2d_transparent.svg" # master: gem only, transparent
MASTER_PX = 2800 # 4x the 700 viewBox — supersampled so downsamples stay clean
SMALL_CROP = 0.82 # tighter framing for the 16px favicon entry only
# Everything `rake icons` produces, and everything `rake clean` removes. Keeping
# one list means the two can't drift apart.
GENERATED = %w[
favicon.ico
apple-touch-icon.png
app-icon.png
ruby2d_1000px.png
ruby2d_1000px_transparent.png
ruby2d.icns
].freeze
# What `rake web` pushes into the website repo: a file here on the left, where
# it lands in the site checkout on the right.
WEB_ASSETS = {
"favicon.ico" => "favicon.ico",
"apple-touch-icon.png" => "apple-touch-icon.png",
SQUARE_SVG => "icon.svg",
PLAIN_SVG => "assets/img/logo.svg", # nav and hero mark
"ruby2d_1000px.png" => "assets/img/logo.png", # og:image
}.freeze
# What `rake assets` pushes into the ruby2d/assets repo, which the gem vendors
# as a submodule. `ruby2d build --native` stamps icon.icns onto the macOS app
# bundle; icon.png sits beside it as the platform-neutral equivalent.
GEM_ASSETS = {
"ruby2d.icns" => "resources/icons/icon.icns",
"app-icon.png" => "resources/icons/icon.png",
}.freeze
# The two repos that consume what we generate. `path` is a lambda so the SITE
# and ASSETS overrides are read when the task runs, not when this file loads.
DESTINATIONS = {
site: {
path: -> { ENV.fetch("SITE", "../ruby2d.com") },
files: WEB_ASSETS,
reminder: "remember to rebuild the site so the CSS stays in sync",
},
assets: {
path: -> { ENV.fetch("ASSETS", "../assets") },
files: GEM_ASSETS,
reminder: "the gem vendors assets as a submodule — commit there, then bump the pointer",
},
}.freeze
CHROME = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
].find { |p| File.executable?(p) }
# Render an SVG to PNG at an exact pixel width, preserving transparency.
#
# Deliberately no --user-data-dir: passing one makes Chrome 150 write the
# screenshot and then never exit. Without it Chrome exits cleanly. The watchdog
# below copes either way, since the file lands before the hang.
def render(svg, out, width, height)
Dir.mktmpdir do |tmp|
html = File.join(tmp, "page.html")
File.write(html, <<~HTML)
<style>html,body{margin:0;padding:0;background:transparent}
img{display:block;width:#{width}px;height:auto}</style>
<img src="file://#{File.expand_path(svg)}">
HTML
File.delete(out) if File.exist?(out)
pid = spawn(CHROME, "--headless", "--disable-gpu", "--hide-scrollbars",
"--no-first-run", "--no-default-browser-check",
"--force-device-scale-factor=1", "--default-background-color=00000000",
"--window-size=#{width},#{height}", "--screenshot=#{out}",
"file://#{html}", out: File::NULL, err: File::NULL)
done = 120.times.any? { Process.waitpid(pid, Process::WNOHANG) || (sleep(0.5) && false) }
unless done
Process.kill("KILL", pid) rescue nil
Process.waitpid(pid) rescue nil
end
if !File.exist?(out) || File.size(out).zero?
abort "chrome produced nothing for #{svg} — if Chrome is already running, quit it and retry"
end
end
end
# macOS app icons aren't full-bleed squares like the web/iOS ones. Apple's grid
# is an 824x824 rounded body centered on a 1024x1024 canvas, leaving 100px around
# it for the system shadow, with a 185.4px corner radius. That shaping is a
# platform convention rather than artwork, so it happens here instead of in a
# third SVG master that would have to be kept in step by hand.
#
# (Apple's real corner is a continuous curve; this is the circular-arc
# approximation, which is what most non-Apple icons ship.)
ICNS_CANVAS = 1024
ICNS_BODY = 824
ICNS_RADIUS = 185
# Every size macOS asks for. @1x and @2x of the same logical size must share
# framing, or the icon visibly changes between Retina and non-Retina displays.
ICONSET = {
"icon_16x16.png" => 16, "icon_16x16@2x.png" => 32,
"icon_32x32.png" => 32, "icon_32x32@2x.png" => 64,
"icon_128x128.png" => 128, "icon_128x128@2x.png" => 256,
"icon_256x256.png" => 256, "icon_256x256@2x.png" => 512,
"icon_512x512.png" => 512, "icon_512x512@2x.png" => 1024,
}.freeze
def build_icns(square_master, tmp)
unless system("command -v iconutil > /dev/null")
warn " skipping ruby2d.icns — iconutil is macOS only"
return
end
body = File.join(tmp, "body.png")
mask = File.join(tmp, "mask.png")
round = File.join(tmp, "rounded.png")
full = File.join(tmp, "macos.png")
# Red square scaled to the body size, then masked to the rounded shape and
# padded out to the full canvas with transparency.
#
# The mask and the -extent have to be separate calls. Chained into one, the
# mask's grayscale colorspace carries through and the result comes out black.
sh "magick #{square_master} -resize #{ICNS_BODY}x#{ICNS_BODY} #{body}"
sh "magick -size #{ICNS_BODY}x#{ICNS_BODY} xc:black -fill white " \
"-draw \"roundrectangle 0,0 #{ICNS_BODY - 1},#{ICNS_BODY - 1} #{ICNS_RADIUS},#{ICNS_RADIUS}\" #{mask}"
sh "magick #{body} #{mask} -alpha off -compose CopyOpacity -composite #{round}"
sh "magick #{round} -background none -gravity center -extent #{ICNS_CANVAS}x#{ICNS_CANVAS} #{full}"
iconset = File.join(tmp, "ruby2d.iconset")
FileUtils.mkdir_p iconset
ICONSET.each do |name, px|
sh "magick #{full} -resize #{px}x#{px} -strip #{File.join(iconset, name)}"
end
FileUtils.rm_f "ruby2d.icns"
sh "iconutil -c icns #{iconset} -o ruby2d.icns"
end
# Checked up front rather than inside `icons`, so `rake` (which cleans first)
# can't delete the whole set and only then discover it has no way to rebuild it.
task :preflight do
abort "need imagemagick — brew install imagemagick" unless system("command -v magick > /dev/null")
abort "no headless Chrome found — install Chrome or Chromium" unless CHROME
[SQUARE_SVG, PLAIN_SVG].each { |f| abort "missing master #{f}" unless File.exist?(f) }
end
desc "Regenerate every raster from the SVG masters"
task icons: :preflight do
flat = "-alpha remove -alpha off -strip" # icons must not carry alpha
keep = "-strip" # ...but the plain logo must
Dir.mktmpdir do |tmp|
square = File.join(tmp, "square.png")
plain = File.join(tmp, "plain.png")
render SQUARE_SVG, square, MASTER_PX, MASTER_PX
render PLAIN_SVG, plain, MASTER_PX, (MASTER_PX * 0.75).round + 40
# Square, red background
sh "magick #{square} -resize 1000x1000 #{flat} ruby2d_1000px.png"
sh "magick #{square} -resize 180x180 #{flat} apple-touch-icon.png"
# favicon.ico — 16px is cropped tighter on purpose: at that size the full
# framing turns the gem to mush, and nothing shows 16px and 180px together.
crop = (MASTER_PX * SMALL_CROP).round
i16, i32 = File.join(tmp, "16.png"), File.join(tmp, "32.png")
sh "magick #{square} -gravity center -crop #{crop}x#{crop}+0+0 +repage -resize 16x16 #{flat} #{i16}"
sh "magick #{square} -resize 32x32 #{flat} #{i32}"
sh "magick #{i16} #{i32} favicon.ico"
# Gem only, transparent, trimmed to the artwork
sh "magick #{plain} -trim +repage -resize 1000x #{keep} ruby2d_1000px_transparent.png"
# The gem ships this beside ruby2d.icns for platforms that want a plain
# square PNG. Same artwork as the transparent logo, padded rather than
# cropped, so nothing is lost off the sides.
sh "magick #{plain} -trim +repage -resize 512x " \
"-background none -gravity center -extent 512x512 #{keep} app-icon.png"
build_icns square, tmp
end
puts "\ngenerated:"
GENERATED.select { |f| File.exist?(f) }.each do |f|
size = f.end_with?(".icns") ? "" : `magick identify -format "%wx%h" #{f}`
puts format(" %-30s %8d bytes %s", f, File.size(f), size)
end
end
desc "Remove every generated asset, leaving just the SVG masters"
task :clean do
gone = GENERATED.select { |f| File.exist?(f) }
gone.each { |f| FileUtils.rm_f f }
puts gone.empty? ? "nothing to remove" : "removed:\n#{gone.map { |f| " #{f}" }.join("\n")}"
end
# The push tasks are the only ones that write outside this repo, into checkouts
# they don't own, so they always ask first. YES=1 answers for you when something
# else is driving rake; a non-interactive shell without it aborts rather than
# hanging on a prompt nobody will see.
def confirm(question)
return true if %w[1 true yes y].include?(ENV["YES"].to_s.downcase)
abort "not a terminal — re-run with YES=1 to copy without being asked" unless $stdin.tty?
print "#{question} [y/N] "
$stdin.gets.to_s.strip.downcase.start_with?("y")
end
# Work out what copying into one destination would do, without doing any of it.
def plan_for(name)
dest = DESTINATIONS.fetch(name)
root = dest[:path].call
abort "no #{name} checkout at #{root}" unless Dir.exist?(root)
dest[:files].map do |src, path|
target = File.join(root, path)
state = if !File.exist?(target) then "new"
elsif FileUtils.identical?(src, target) then "unchanged"
else "overwrite"
end
{ name: name, src: src, path: path, target: target, state: state, root: root }
end
end
# Copy into one or more repos, showing everything that would change and asking
# once for the lot. Files that already match are left alone, so an unchanged
# plan copies nothing and doesn't bother asking.
def push(*names)
plans = names.to_h { |name| [name, plan_for(name)] }
plans.each_value do |plan|
puts "\ninto #{File.expand_path(plan.first[:root])}"
plan.each { |f| puts format(" %-24s -> %-26s %s", f[:src], f[:path], f[:state]) }
end
changing = plans.values.flatten.reject { |f| f[:state] == "unchanged" }
if changing.empty?
puts "\nalready up to date, nothing to copy"
return
end
# Answering no is also how you get a dry run: the plan above is the whole
# story, and nothing has been touched yet.
unless confirm("\nCopy #{changing.length} file#{"s" unless changing.length == 1}?")
puts "cancelled, nothing copied"
return
end
many = plans.length > 1
changing.each do |f|
FileUtils.mkdir_p File.dirname(f[:target])
FileUtils.cp f[:src], f[:target]
puts " copied #{many ? "#{f[:name]}: " : ""}#{f[:path]}"
end
# Only nag about the repos that actually received something.
plans.each_key do |name|
puts "\n#{DESTINATIONS[name][:reminder]}" if changing.any? { |f| f[:name] == name }
end
end
desc "Regenerate, then copy the web assets into the website repo (SITE=path)"
task web: :icons do
push :site
end
desc "Regenerate, then copy the app icons into the assets repo (ASSETS=path)"
task assets: :icons do
push :assets
end
desc "Clean, regenerate everything, and copy it into both repos"
task all: [:preflight, :clean, :icons] do
push :site, :assets
end
task default: :all