distorted-floor 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +661 -0
- data/README.md +32 -0
- data/bin/distorted-floor +16 -0
- data/bin/repl +14 -0
- data/bin/setup +8 -0
- data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/img/Less_Perfect_DOS_VGA.png +0 -0
- data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/img/More_Perfect_DOS_VGA.png +0 -0
- data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/img/Perfect_DOS_VGA.png +0 -0
- data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/less_more_perfect_dos_vga_437.html +52 -0
- data/font/1252/LICENSE/PerfectDOSVGA437/font-comment.php@file=perfect_dos_vga_437.html +5 -0
- data/font/1252/LessPerfectDOSVGA.ttf +0 -0
- data/font/1252/MorePerfectDOSVGA.ttf +0 -0
- data/font/1252/Perfect DOS VGA 437 Win.ttf +0 -0
- data/font/437/Perfect DOS VGA 437.ttf +0 -0
- data/font/437/dos437.txt +72 -0
- data/font/65001/Anonymous Pro B.ttf +0 -0
- data/font/65001/Anonymous Pro BI.ttf +0 -0
- data/font/65001/Anonymous Pro I.ttf +0 -0
- data/font/65001/Anonymous Pro.ttf +0 -0
- data/font/65001/LICENSE/AnonymousPro/FONTLOG.txt +45 -0
- data/font/65001/LICENSE/AnonymousPro/OFL-FAQ.txt +235 -0
- data/font/65001/LICENSE/AnonymousPro/OFL.txt +94 -0
- data/font/65001/LICENSE/AnonymousPro/README.txt +55 -0
- data/font/850/ProFont-Bold-01/LICENSE +22 -0
- data/font/850/ProFont-Bold-01/readme.txt +28 -0
- data/font/850/ProFontWindows-Bold.ttf +0 -0
- data/font/850/ProFontWindows.ttf +0 -0
- data/font/850/Profont/LICENSE +22 -0
- data/font/850/Profont/readme.txt +31 -0
- data/font/932/LICENSE/README-ttf.txt +213 -0
- data/font/932/mona.ttf +0 -0
- data/lib/distorted-floor/checking_you_out.rb +78 -0
- data/lib/distorted-floor/click_again.rb +406 -0
- data/lib/distorted-floor/element_of_media/change.rb +114 -0
- data/lib/distorted-floor/element_of_media/compound.rb +120 -0
- data/lib/distorted-floor/element_of_media.rb +2 -0
- data/lib/distorted-floor/error_code.rb +55 -0
- data/lib/distorted-floor/floor.rb +17 -0
- data/lib/distorted-floor/invoker.rb +100 -0
- data/lib/distorted-floor/media_molecule/font.rb +200 -0
- data/lib/distorted-floor/media_molecule/image.rb +33 -0
- data/lib/distorted-floor/media_molecule/pdf.rb +45 -0
- data/lib/distorted-floor/media_molecule/svg.rb +46 -0
- data/lib/distorted-floor/media_molecule/text.rb +247 -0
- data/lib/distorted-floor/media_molecule/video.rb +21 -0
- data/lib/distorted-floor/media_molecule.rb +58 -0
- data/lib/distorted-floor/modular_technology/gstreamer.rb +175 -0
- data/lib/distorted-floor/modular_technology/pango.rb +90 -0
- data/lib/distorted-floor/modular_technology/ttfunk.rb +48 -0
- data/lib/distorted-floor/modular_technology/vips/ffi.rb +66 -0
- data/lib/distorted-floor/modular_technology/vips/load.rb +174 -0
- data/lib/distorted-floor/modular_technology/vips/operatio$.rb +268 -0
- data/lib/distorted-floor/modular_technology/vips/save.rb +135 -0
- data/lib/distorted-floor/modular_technology/vips.rb +17 -0
- data/lib/distorted-floor/monkey_business/encoding.rb +374 -0
- data/lib/distorted-floor/monkey_business/hash.rb +18 -0
- data/lib/distorted-floor/monkey_business/set.rb +15 -0
- data/lib/distorted-floor/monkey_business/string.rb +6 -0
- data/lib/distorted-floor.rb +2 -0
- metadata +215 -0
@@ -0,0 +1,406 @@
|
|
1
|
+
require 'set'
|
2
|
+
require 'distorted-floor/monkey_business/set'
|
3
|
+
|
4
|
+
require 'optparse'
|
5
|
+
require 'shellwords' # Necessary for inclusion in OptionParser coercions list.
|
6
|
+
|
7
|
+
require 'distorted-floor/invoker'
|
8
|
+
require 'distorted-floor/checking_you_out'
|
9
|
+
using ::DistorteD::CHECKING::YOU::OUT
|
10
|
+
require 'distorted-floor/element_of_media/compound'
|
11
|
+
|
12
|
+
|
13
|
+
module Cooltrainer; end
|
14
|
+
module Cooltrainer::DistorteD; end
|
15
|
+
|
16
|
+
class Cooltrainer::DistorteD::ClickAgain
|
17
|
+
|
18
|
+
include Cooltrainer::DistorteD::Invoker # MediaMolecule plugger
|
19
|
+
|
20
|
+
attr_reader :global_options, :lower_options, :outer_options
|
21
|
+
|
22
|
+
|
23
|
+
# Set up and parse a given Array of command-line switches based on
|
24
|
+
# our global OptionParser and its Type/Molecule-specific sub-commands.
|
25
|
+
#
|
26
|
+
# :argv will be operated on destructively!
|
27
|
+
# Consider passing a duplicate of ARGV instead of passing it directly.
|
28
|
+
def initialize(argv, exe_name)
|
29
|
+
|
30
|
+
# Partition argv into (switches and their arguments) and (filenames or wanted type Strings)
|
31
|
+
switches, @get_out = partition_argv(argv)
|
32
|
+
|
33
|
+
# Initialize Hashes to store our three types of Options using a small
|
34
|
+
# custom subclass that will store items as a Set but won't store :nil alone.
|
35
|
+
@global_options = Hash.new { |h,k| h[k] = h.class.new(&h.default_proc) }
|
36
|
+
@lower_options = Hash.new { |h,k| h[k] = h.class.new(&h.default_proc) }
|
37
|
+
@outer_options = Hash.new { |h,k| h[k] = h.class.new(&h.default_proc) }
|
38
|
+
# Temporary Array for unmatched Switches when parsing subcommands.
|
39
|
+
sorry_try_again = Array.new
|
40
|
+
|
41
|
+
# Pass our executable name in for the global OptionParser's banner String,
|
42
|
+
# then parse the complete/raw user-given-arguments-list first with this Parser.
|
43
|
+
#
|
44
|
+
# I am intentionally using OptionParser's non-POSIXy :permute! method
|
45
|
+
# instead of the POSIX-compatible :order! method,
|
46
|
+
# because I want to :)
|
47
|
+
# Otherwise users would have to define all switch arguments
|
48
|
+
# ahead of all positional arguments in the command,
|
49
|
+
# and I think that would be frustrating and silly.
|
50
|
+
#
|
51
|
+
# In strictly-POSIX mode, one would have to call e.g.
|
52
|
+
# `distorted -o image/png inputfile.webp outfilewithnofileextension`
|
53
|
+
# instead of
|
54
|
+
# `distorted inputfile.webp -o image/png outfilewithnofileextension`,
|
55
|
+
# which I find to be much more intuitive.
|
56
|
+
#
|
57
|
+
# Note that `:parse!` would call one of the other of :order!/:permute! based on
|
58
|
+
# an invironment variable `POSIXLY_CORRECT`. Talk about a footgun!
|
59
|
+
# Be explicit!!
|
60
|
+
global = global_options(exe_name)
|
61
|
+
begin
|
62
|
+
switches = global.permute!(switches, into: @global_options)
|
63
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::ParseError => nope
|
64
|
+
nope.recover(sorry_try_again) # Will :unshift the :nope value to the recovery Array.
|
65
|
+
#if switches&.first&.chr == '-'.freeze
|
66
|
+
# sorry_try_again.unshift(switches.shift)
|
67
|
+
#end
|
68
|
+
retry
|
69
|
+
end
|
70
|
+
switches.unshift(*sorry_try_again.reverse)
|
71
|
+
|
72
|
+
# The global OptionParser#permute! call will strip our `:argv` Array of
|
73
|
+
# any `--help` or Molecule-picking switches.
|
74
|
+
# Molecule-specific switches (both 'lower' and 'outer') and positional
|
75
|
+
# file-name arguments remain.
|
76
|
+
#
|
77
|
+
# The first remaining `argv` will be our input filename if one was given!
|
78
|
+
#
|
79
|
+
# NOTE: Never assume this filename will be a complete, absolute, usable path.
|
80
|
+
# POSIX shells do not do tilde expansion, for example, on quoted switch arguments,
|
81
|
+
# so a quoted filename argument '~/cover.png' will come through to Ruby-land
|
82
|
+
# as the literal String '~/cover.png' while the same filename argument sans-quotes
|
83
|
+
# will be expanded to e.g. '/home/okeeblow/cover.png' (based on `$HOME` env var).
|
84
|
+
# Additional Ruby-side path validation will almost certainly be needed!
|
85
|
+
# https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_01
|
86
|
+
@name = @get_out&.shift
|
87
|
+
|
88
|
+
# Print some sort of help message or list of supported input/output Types
|
89
|
+
# if no source filename was given.
|
90
|
+
unless @name
|
91
|
+
puts case
|
92
|
+
when @global_options.has_key?(:help) then global
|
93
|
+
when @global_options.has_key?(:"lower-world")
|
94
|
+
"Supported input media types:\n#{lower_world.keys.join("\n")}"
|
95
|
+
when @global_options.has_key?(:"outer-limits")
|
96
|
+
"Supported output media types:\n#{outer_limits(all: true).values.map{|m| m.keys}.join("\n")}"
|
97
|
+
else global
|
98
|
+
end
|
99
|
+
exit
|
100
|
+
end
|
101
|
+
|
102
|
+
# Here's that additional filename validation I was talking about.
|
103
|
+
# I don't do this as a one-shot with the argv.shift because
|
104
|
+
# File::expand_path raises an error on :nil argument,
|
105
|
+
# and we already checked for that when we checked for 'help' switches.
|
106
|
+
@name = File.expand_path(@name)
|
107
|
+
|
108
|
+
# Check for 'help' switches *again* now that we have a source file path,
|
109
|
+
# because the output can be file-specific instead of generic.
|
110
|
+
# This is where we display subcommands' help!
|
111
|
+
specific_help = case
|
112
|
+
when @get_out.empty?
|
113
|
+
# Only input filename given; no outputs; nothing left to do!
|
114
|
+
lower_subcommands.merge(outer_subcommands).values.unshift(Hash[:DistorteD => [global]]).map { |l|
|
115
|
+
l.values.join("\n")
|
116
|
+
}.join("\n")
|
117
|
+
when @global_options.has_key?(:help), @global_options.has_key?(:"lower-world")
|
118
|
+
lower_subcommands.values.map { |l|
|
119
|
+
l.values.join("\n")
|
120
|
+
}.join("\n")
|
121
|
+
when @global_options.has_key?(:"outer-limits")
|
122
|
+
# Trigger this help message on `-o` iff that switch is used bare.
|
123
|
+
# If `-o` is given an argument it will inform `::CHECKING::YOU::OUT` of the same-index output file,
|
124
|
+
# e.g. `-o image/png -o image/webp pngnoextension webpnoextension`
|
125
|
+
# will work exactly as that example implies.
|
126
|
+
@global_options.dig(:"outer-limits")&.empty? ?
|
127
|
+
outer_subcommands.values.map { |o|
|
128
|
+
o.values.join("\n")
|
129
|
+
}.join("\n") : nil
|
130
|
+
else nil
|
131
|
+
end
|
132
|
+
if specific_help
|
133
|
+
puts specific_help
|
134
|
+
exit
|
135
|
+
end
|
136
|
+
|
137
|
+
# Our "subcommands" are additional instances of OptionParser,
|
138
|
+
# one for every MediaMolecule that can load the source file,
|
139
|
+
# and one for every intended output variation.
|
140
|
+
lower_subcommands.each_pair { |type, molecule_commands|
|
141
|
+
molecule_commands.each_pair { |molecule, subcommand|
|
142
|
+
begin
|
143
|
+
switches = subcommand.permute!(switches, into: @lower_options[type][molecule])
|
144
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::ParseError => nope
|
145
|
+
nope.recover(sorry_try_again) # Will :unshift the :nope value to the recovery Array.
|
146
|
+
retry
|
147
|
+
end
|
148
|
+
switches.unshift(*sorry_try_again.reverse)
|
149
|
+
@lower_options[type][molecule].store(:molecule, molecule)
|
150
|
+
}
|
151
|
+
}
|
152
|
+
outer_subcommands.each_pair { |molecule, type_commands|
|
153
|
+
type_commands.each_pair { |type, subcommand|
|
154
|
+
begin
|
155
|
+
switches = subcommand.permute!(switches, into: @outer_options[molecule][type])
|
156
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::ParseError => nope
|
157
|
+
nope.recover(sorry_try_again) # Will :unshift the :nope value to the recovery Array.
|
158
|
+
retry
|
159
|
+
end
|
160
|
+
switches.unshift(*sorry_try_again.reverse)
|
161
|
+
@outer_options[molecule][type].store(:molecule, molecule)
|
162
|
+
}
|
163
|
+
}
|
164
|
+
end
|
165
|
+
|
166
|
+
# Writes all intended output files to a given directory.
|
167
|
+
# `dest_root` is a Jekyll-ism not used here in the CLI, but define it anyway for consistency.
|
168
|
+
def write(dest_root = nil)
|
169
|
+
changes.each { |change|
|
170
|
+
if self.respond_to?(change.type.distorted_file_method)
|
171
|
+
# WISHLIST: Remove the empty final positional Hash argument once we require a Ruby version
|
172
|
+
# that will not perform the implicit Change-to-Hash conversion due to Change's
|
173
|
+
# implementation of :to_hash. Ruby 2.7 will complain but still do the conversion,
|
174
|
+
# breaking downstream callers that want a Struct they can call arbitrary key methods on.
|
175
|
+
# https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/
|
176
|
+
self.send(change.type.distorted_file_method, dest_root, change, **{})
|
177
|
+
else
|
178
|
+
raise MediaTypeOutputNotImplementedError.new(change.name, change.type, self.class.name)
|
179
|
+
end
|
180
|
+
}
|
181
|
+
end
|
182
|
+
|
183
|
+
private
|
184
|
+
|
185
|
+
# Partitions the raw `argv` into two buckets — outer_limits (String relative filenames or "media/type" Strings) and switches/arguments.
|
186
|
+
#
|
187
|
+
# References:
|
188
|
+
# - glibc Program Argument Syntax Conventions https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html
|
189
|
+
# - POSIX Utility Argument Syntax https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_01
|
190
|
+
# - Windows Command-line syntax key https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/command-line-syntax-key
|
191
|
+
#
|
192
|
+
# The filenames will be used as the source file (first member) and destination file(s) (any others).
|
193
|
+
# The switches/arguments will be passed to our global OptionParser's `:parse_in_order`
|
194
|
+
# which will return the unused remainder.
|
195
|
+
#
|
196
|
+
# I think it should be possible to achieve this same effect with our global OptionParser alone
|
197
|
+
# by specifying two required NoArgument Switches (source and first destination filename),
|
198
|
+
# specifying multiple optional NoArgument Switches (additional destinations),
|
199
|
+
# and parsing the unmodified `:argv` in permutation mode.
|
200
|
+
# I can't figure out how to wrangle OptionParser into doing that rn tho so welp here we are.
|
201
|
+
#
|
202
|
+
# There is a built-in Enumeraable#partition, but:
|
203
|
+
# - It doesn't take an accumulator variable natively.
|
204
|
+
# - I'm bad at chaining Enumerators and idk how to chain in a `:with_object` without returning only that object.
|
205
|
+
# - `:with_object` treats scalar types as immutable which precludes cleanly passing a boolean flag variable between iterations.
|
206
|
+
def partition_argv(argv)
|
207
|
+
switches, @get_out = argv.each_with_object(
|
208
|
+
# Accumulate to a three-key Hash containing the two wanted buckets and the flag that will be discarded.
|
209
|
+
Hash[:switches => Array.new, :get_out => Array.new, :want_value => false]
|
210
|
+
) { |arg, partition|
|
211
|
+
# Switches and their values will be:
|
212
|
+
# - Any argument beginning with a single dash, e.g. long switches like '--crop' or short switches like '-Q90'.
|
213
|
+
# - Any non-dash argument if :want_value is flagged, e.g. the 'attention' value for the '--crop' switch.
|
214
|
+
# Filenames will be:
|
215
|
+
# - Anything else :)
|
216
|
+
#
|
217
|
+
# Combine long switches like '--crop' with their value as a single String with equals, e.g. '--crop=none'.
|
218
|
+
# Combine short switches like '-Q' with their value as a single String without equals, e.g. '-Q90'.
|
219
|
+
if partition.fetch(:want_value) and arg[0] != '-'.freeze # Does this look like a value when we want a value?
|
220
|
+
# `ARGV` Strings are frozen, so we have to replace instead of directly concat
|
221
|
+
partition[:switches].push(partition[:switches].pop.yield_self { |last|
|
222
|
+
# Prefix the value with equals for long switches if we don't already have one.
|
223
|
+
"#{last}#{'='.freeze if last[1] == '-'.freeze and arg[0] != '='.freeze}#{arg}"
|
224
|
+
})
|
225
|
+
else
|
226
|
+
partition[(arg[0] == '-'.freeze or partition.fetch(:want_value)) ? :switches : :get_out].push(arg)
|
227
|
+
end
|
228
|
+
# The *next* argument should be a value for this iteration's argument iff:
|
229
|
+
# - This iteration is a long switch with no included value, e.g. '--crop' but not '--crop=attention'.
|
230
|
+
# - This iteration is a short switch with no included value, e.g. '-Q' but not '-Q90' or '-Q=90'.
|
231
|
+
partition.store(:want_value, [
|
232
|
+
arg[0] == '-'.freeze, # e.g. '--crop' or '-Q'
|
233
|
+
!arg.include?('='.freeze), # e.g. not '--crop=attention' or '-Q=90'
|
234
|
+
[
|
235
|
+
arg[1] == '-'.freeze, # e.g. '--crop'
|
236
|
+
[arg[1] == '-'.freeze, arg.length > 2].none? # e.g. not '-Q90'
|
237
|
+
].any?
|
238
|
+
].all?)
|
239
|
+
}.values.select(&Array.method(:===)) # Return only the Array members of the Hash
|
240
|
+
end
|
241
|
+
|
242
|
+
# Generic top-level OptionParser
|
243
|
+
def global_options(exe_name)
|
244
|
+
OptionParser.new do |opts|
|
245
|
+
opts.banner = "Usage: #{exe_name} [OPTION]… SOURCE DEST [DEST]…"
|
246
|
+
opts.on_tail('-h', '--help', 'Show this message')
|
247
|
+
opts.on('-v', '--[no-]verbose', 'Run verbosely')
|
248
|
+
opts.on('-l', '--lower-world', 'Show supported input media types')
|
249
|
+
opts.on('-o', '--outer-limits', 'Show supported output media types')
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
# Stubs for CYO port. TODO: make obsolete.
|
254
|
+
def the_setting_sun(...); nil; end
|
255
|
+
def context_arguments(...); nil; end
|
256
|
+
|
257
|
+
# Returns an Array[Change] for every intended output variation.
|
258
|
+
def changes
|
259
|
+
@changes ||= begin
|
260
|
+
# TODO: Handle loewr_options and outer_options separately
|
261
|
+
# once I have some idea of what kind of Change chaining I want to do.
|
262
|
+
# :@lower_options will be a Hash[CHECKING::YOU::OUT] => Hash[MediaMolecule] => given options, e.g.
|
263
|
+
# {#<CYO: text/x-nfo>=>{Cooltrainer::DistorteD::Molecule::Text=>{:encoding=>"IBM437", :molecule=>Cooltrainer::DistorteD::Molecule::Text}}}
|
264
|
+
#
|
265
|
+
# Combine all Molecule options into one for now since we don't have multi-Molecule or Change-chaining.
|
266
|
+
# TODO: Don't combine them, because it's currently possible for same-key options to interfere.
|
267
|
+
combined_lower_options = @lower_options.slice(*type_mars)&.values&.reduce(&:merge)&.values&.reduce(&:merge)
|
268
|
+
|
269
|
+
# :@outer_options will be a Hash[MediaMolecule] => Hash[CHECKING::YOU::OUT] => given options,
|
270
|
+
# with the given options duplicated among each supported Type, e.g.
|
271
|
+
# {Cooltrainer::DistorteD::Molecule::Text=>{
|
272
|
+
# #<CYO: image/png>=>{:dpi=>"144", :molecule=>Cooltrainer::DistorteD::Molecule::Text},
|
273
|
+
# #<CYO: image/jpeg>=>{:dpi=>"144", :molecule=>Cooltrainer::DistorteD::Molecule::Text},
|
274
|
+
# #<CYO: image/webp>=>{:dpi=>"144", :molecule=>Cooltrainer::DistorteD::Molecule::Text},
|
275
|
+
# …
|
276
|
+
# }
|
277
|
+
#
|
278
|
+
# Combine all Molecule options into one for now since we don't have multi-Molecule.
|
279
|
+
# TODO: Don't combine them, because it's currently possible for same-key options to interfere.
|
280
|
+
combined_outer_options = @outer_options&.values&.reduce(&:merge)
|
281
|
+
|
282
|
+
# TODO: Support intermediate operations separate from OUTER LIMITS.
|
283
|
+
# For example, for image output we currently add the :crop option to each OL,
|
284
|
+
# but it will be more appropriate to support that VipsThumbnail operation as its own standalone thing.
|
285
|
+
|
286
|
+
# Construct a Change for each desired output,
|
287
|
+
# which is what we consider any positional argument to the CLI
|
288
|
+
# aside from the first one (for the source file path).
|
289
|
+
@get_out.each_with_object(Array[]) { |out, wanted|
|
290
|
+
# An output's positional argument may be:
|
291
|
+
# - A destination path, in which case we get the Type from the extension.
|
292
|
+
# - A `::CHECKING::YOU::OUT` identifier String, e.g. 'image/png'.
|
293
|
+
#
|
294
|
+
# TODO: Nice way to check format for Type string here and fail
|
295
|
+
# if we aren't given enough info to identify the wanted output Type.
|
296
|
+
# TODO: Get rid of `#prepend` here once I fix that method.
|
297
|
+
unless ::CHECKING::YOU::OUT::from_postfix(File.extname(out).prepend(-?*)).nil?
|
298
|
+
name = out
|
299
|
+
type = ::CHECKING::YOU::OUT::from_postfix(File.extname(out).prepend(-?*))
|
300
|
+
else
|
301
|
+
name = @name
|
302
|
+
type = ::CHECKING::YOU::OUT::from_ietf_media_type(out)
|
303
|
+
end
|
304
|
+
|
305
|
+
supported_options = [
|
306
|
+
Cooltrainer::DistorteD::IMPLANTATION(:LOWER_WORLD, combined_lower_options[:molecule])&.slice(*type_mars)&.values&.reduce(&:merge),
|
307
|
+
Cooltrainer::DistorteD::IMPLANTATION(:OUTER_LIMITS, combined_outer_options[:molecule])&.fetch(type, nil)
|
308
|
+
].compact.reduce(&:merge)
|
309
|
+
|
310
|
+
atoms = supported_options.each_pair.with_object(Hash.new) { |(aka, compound), atoms|
|
311
|
+
next if aka.nil? or compound.nil? # Allow Molecules to define Types with no options.
|
312
|
+
next if aka != compound.element # Skip alias Compounds since they will all be handled at once.
|
313
|
+
# Look for a user-given argument matching any supported alias of a Compound,
|
314
|
+
# and check those values against the Compound for validity.
|
315
|
+
atoms.store(compound.element, Cooltrainer::Atom.new(compound.isotopes.reduce(nil) { |value, isotope|
|
316
|
+
# TODO: Compound#valid?, and cast non-Strings to the correct :valid class.
|
317
|
+
#value || type_options&.delete(isotope) || combined_lower_options&.fetch(isotope, nil)
|
318
|
+
value || combined_lower_options&.fetch(isotope, nil)
|
319
|
+
}, compound.default))
|
320
|
+
}
|
321
|
+
wanted.push(Cooltrainer::Change.new(type, src: @name, dir: Cooltrainer::Atom.new(File.dirname(name)), **atoms))
|
322
|
+
}
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
# Returns an absolute String path to the source file.
|
327
|
+
def path
|
328
|
+
File.expand_path(@name)
|
329
|
+
end
|
330
|
+
|
331
|
+
# This is a CLI, so we always want to write new files when called.
|
332
|
+
def modified?
|
333
|
+
true
|
334
|
+
end
|
335
|
+
|
336
|
+
# And again.
|
337
|
+
def write?
|
338
|
+
true
|
339
|
+
end
|
340
|
+
|
341
|
+
# Generate an OptionParser for a flat Enumerable of Compounds
|
342
|
+
COMPOUND_OPTIONPARSER = Proc.new { |compounds, from, to|
|
343
|
+
OptionParser.new(banner = "#{from.to_s} ⟹ #{to.to_s}:") { |subopt|
|
344
|
+
compounds.map { |compound|
|
345
|
+
next if compound.nil?
|
346
|
+
parts = Array[
|
347
|
+
*compound.to_options,
|
348
|
+
]
|
349
|
+
if compound.default == true
|
350
|
+
parts.append(TrueClass)
|
351
|
+
elsif compound.default == false
|
352
|
+
parts.append(FalseClass)
|
353
|
+
elsif compound.valid.is_a?(Range)
|
354
|
+
# TODO: decide how to handle Ranges that might be hundreds/thousands of items in length.
|
355
|
+
elsif compound.valid.is_a?(Enumerable)
|
356
|
+
parts.append(compound.valid.to_a)
|
357
|
+
elsif Cooltrainer::OPTIONPARSER_COERSIONS.include?(compound.valid.class)
|
358
|
+
parts.append(compound.valid)
|
359
|
+
end
|
360
|
+
parts.append(compound.default.nil? ? compound.blurb : "#{compound.blurb} (default: #{compound.default})")
|
361
|
+
|
362
|
+
# Avoid using a `subopt.accept(Range)` Proc to handle Ranges,
|
363
|
+
# because that would only allow us to define a single handler for all Ranges
|
364
|
+
# regardless of the type or value they represent.
|
365
|
+
# The value yielded from this block will be the value recieved by parse_in_order's `into`.
|
366
|
+
if compound.valid.is_a?(Range)
|
367
|
+
subopt.on(*parts) do |value|
|
368
|
+
if compound.valid.to_a.all?(Integer)
|
369
|
+
value = Integer(value)
|
370
|
+
elsif compound.valid.to_a.all(Float)
|
371
|
+
value = Float(value)
|
372
|
+
end
|
373
|
+
next compound.valid.include?(value) ? value : compound.default
|
374
|
+
end
|
375
|
+
else
|
376
|
+
subopt.on(*parts)
|
377
|
+
end
|
378
|
+
}
|
379
|
+
}
|
380
|
+
}
|
381
|
+
|
382
|
+
# Generate a Hash[CHECKING::YOU::OUT] => Hash[MediaMolecule] => OptionParser
|
383
|
+
# for file-specific input options.
|
384
|
+
def lower_subcommands
|
385
|
+
type_mars.each_with_object(Hash[]) { |type, commands|
|
386
|
+
lower_world[type].each_pair { |molecule, aka|
|
387
|
+
commands.update(type => {
|
388
|
+
molecule => COMPOUND_OPTIONPARSER.call(aka&.values.to_set, type, molecule)
|
389
|
+
}) { |k,o,n| o.merge(n) } unless aka.nil?
|
390
|
+
}
|
391
|
+
}
|
392
|
+
end
|
393
|
+
|
394
|
+
# Generate a Hash[MediaMolecule] => Hash[CHECKING::YOU::OUT] => OptionParser
|
395
|
+
# for file-specific output options.
|
396
|
+
def outer_subcommands(all: false)
|
397
|
+
outer_limits(all: all).each_with_object(Hash[]) { |(molecule, types), commands|
|
398
|
+
types.each_pair { |type, aka|
|
399
|
+
commands.update(molecule => {
|
400
|
+
type => COMPOUND_OPTIONPARSER.call(aka&.values.to_set, molecule, type)
|
401
|
+
}) { |k,o,n| o.merge(n) } unless aka.nil?
|
402
|
+
}
|
403
|
+
}
|
404
|
+
end
|
405
|
+
|
406
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module Cooltrainer
|
2
|
+
|
3
|
+
# Fun Ruby Fact™: `false` is always object_id 0
|
4
|
+
# https://skorks.com/2009/09/true-false-and-nil-objects-in-ruby/
|
5
|
+
# irb(main):650:0> true.object_id
|
6
|
+
# => 20
|
7
|
+
# irb(main):651:0> false.object_id
|
8
|
+
# => 0
|
9
|
+
BOOLEAN_VALUES = Set[false, true]
|
10
|
+
|
11
|
+
|
12
|
+
# Struct to encapsulate all the data needed to perform one (1) `::CHECKING::YOU::OUT` transformation
|
13
|
+
# of a source media file into any supported `::CHECKING::YOU::OUT`, possibly even the same type as input.
|
14
|
+
Change = Struct.new(:type, :src, :basename, :molecule, :tag, :breaks, :atoms, keyword_init: true) do
|
15
|
+
|
16
|
+
# Customize the destination filename and other values before doing the normal Struct setup.
|
17
|
+
def initialize(type, src: nil, molecule: nil, tag: nil, breaks: Array.new, **atoms)
|
18
|
+
# `name` might have a leading slash if referenced as an absolute path as the Tag.
|
19
|
+
basename = File.basename(src, '.*'.freeze).reverse.chomp('/'.freeze).reverse
|
20
|
+
|
21
|
+
# Set the &default_proc on the kwarg-glob Hash instead of making a new Hash,
|
22
|
+
atoms.default_proc = lambda { |h,k| h[k] = Cooltrainer::Atom.new }
|
23
|
+
atoms.transform_values {
|
24
|
+
# We might get Atoms already instantiated, but do it for any that aren't.
|
25
|
+
# We won't have a default value for them in that case.
|
26
|
+
|v| v.is_a?(Cooltrainer::Atom) ? atom : Cooltrainer::Atom.new(v, nil)
|
27
|
+
}.each_key { |k|
|
28
|
+
# Define accessors for context-specific :atoms keys/values that aren't normal Struct members.
|
29
|
+
self.singleton_class.define_method(k) { self[:atoms]&.fetch(k, nil)&.get }
|
30
|
+
self.singleton_class.define_method("#{k}=".to_sym) { |v| self[:atoms][k] = v }
|
31
|
+
}
|
32
|
+
|
33
|
+
# And now back to your regularly-scheduled Struct
|
34
|
+
super(type: type, src: src, basename: basename, molecule: molecule, tag: tag, breaks: breaks, atoms: atoms)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Returns the Change Type's file extension as a String with leading dot (.)
|
38
|
+
def extname; type.extname; end
|
39
|
+
|
40
|
+
# Returns an Array[String] of filenames this Change should generate,
|
41
|
+
# one 'full'/'original' plus any limit-breaks,
|
42
|
+
# e.g. ["DistorteD.png", "DistorteD-333.png", "DistorteD-555.png", "DistorteD-888.png", "DistorteD-1111.png"]
|
43
|
+
def names
|
44
|
+
Array[''.freeze].concat(self[:breaks]).map { |b|
|
45
|
+
filetag = (b.nil? || b&.to_s.empty?) ? ''.freeze : '-'.concat(b.to_s)
|
46
|
+
"#{self[:basename]}#{"-#{self.tag}" unless self.tag.nil?}#{filetag}#{extname}"
|
47
|
+
}
|
48
|
+
end
|
49
|
+
|
50
|
+
# Returns a String describing the :names but rolled into one,
|
51
|
+
# e.g. "IIDX-turntable-(400|800|1500).png"
|
52
|
+
def name
|
53
|
+
break_tags = self[:breaks].length > 1 ? "-(#{self[:breaks].join('|'.freeze)})" : ''.freeze
|
54
|
+
"#{self.basename}#{"-#{self.tag}" unless self.tag.nil?}#{break_tags}#{self.extname}"
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns an Array[String] of all absolute destination paths this Change should generate,
|
58
|
+
# given a root destination directory.
|
59
|
+
def paths(dest_root = nil)
|
60
|
+
output_dir = self[:atoms]&.fetch(:dir, nil)
|
61
|
+
return self.names.map { |n| File.expand_path(File.join(*([dest_root, output_dir, n].compact))) }
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns a String absolute destination path for only one limit-break.
|
65
|
+
def path(dest_root, break_value)
|
66
|
+
output_dir = self[:atoms]&.fetch(:dir, ''.freeze)
|
67
|
+
return File.join(File.expand_path(dest_root), output_dir, "#{self.basename}#{"-#{self.tag}" unless self.tag.nil?}-#{break_value}#{self.extname}")
|
68
|
+
end
|
69
|
+
|
70
|
+
# A generic version of Struct#to_hash was rejected with good reason,
|
71
|
+
# but I'm going to use it here because I want the implicit Struct-to-Hash
|
72
|
+
# conversion to let me destructure these Structs with a double-splat:
|
73
|
+
# https://bugs.ruby-lang.org/issues/4862
|
74
|
+
#
|
75
|
+
# Defining this method causes Ruby 2.7 to emit a "Using the last argument as keyword parameters is deprecated" warning
|
76
|
+
# if this Struct is passed to a method as the final positional argument! Ruby 2.7 will actually do the
|
77
|
+
# conversion when calling the method in that scenario, causing incorrect behavior to methods expecting Struct.
|
78
|
+
# This is why DD-Floor's `:write` and DD-Jekyll's `:render_to_output_buffer` pass an empty kwargs Hash.
|
79
|
+
# https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/
|
80
|
+
def to_hash # Implicit
|
81
|
+
Hash[self.members.reject{|m| m == :atoms}.zip(self.values.reject{|v| v.is_a?(Hash)})].merge(self[:atoms].transform_values(&:get))
|
82
|
+
end
|
83
|
+
# Struct#to_h does exist in stdlib, but redefine its behavior to match our `:to_hash`.
|
84
|
+
def to_h # Explicit
|
85
|
+
Hash[self.members.reject{|m| m == :atoms}.zip(self.values.reject{|v| v.is_a?(Hash)})].merge(self[:atoms].transform_values(&:get))
|
86
|
+
end
|
87
|
+
def dig(*keys); self.to_hash.dig(*keys); end
|
88
|
+
|
89
|
+
# Support setting Atoms that were not defined at instantiation.
|
90
|
+
def method_missing(meth, *a, **k, &b)
|
91
|
+
# Are we a setter?
|
92
|
+
if meth.to_s[-1] == '='.freeze
|
93
|
+
# Set the :value of an existing Atom Struct
|
94
|
+
self[:atoms][meth.to_s.chomp('='.freeze).to_sym].value = a.first
|
95
|
+
else
|
96
|
+
self[:atoms]&.fetch(meth, nil)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
end # Struct Change
|
101
|
+
|
102
|
+
|
103
|
+
# Struct to wrap just the user and default values for a Compound or just for freeform usage.
|
104
|
+
Atom = Struct.new(:value, :default) do
|
105
|
+
# Return a value if set, otherwise a default. Both can be `nil`.
|
106
|
+
def get; self.value || self.default; end
|
107
|
+
# Override these default Struct methods with ones that reference our :get
|
108
|
+
def to_s; self.get.to_s; end # Explicit
|
109
|
+
def to_str; self.get.to_s; end # Implicit
|
110
|
+
# Send any unknown message through to a value/default.
|
111
|
+
def method_missing(meth, *a, **k, &b); self.get.send(meth, *a, **k, &b); end
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'date'
|
3
|
+
|
4
|
+
module Cooltrainer
|
5
|
+
|
6
|
+
# This is defined in writing in a comment in optparse.rb's RDoc,
|
7
|
+
# but I can't seem to find anywhere it's available directly in code,
|
8
|
+
# so I am going to build my own.
|
9
|
+
# Maybe I am missing something obvious, in which case I should use that
|
10
|
+
# and get rid of this :)
|
11
|
+
# Based on https://ruby-doc.org/stdlib/libdoc/optparse/rdoc/OptionParser.html#class-OptionParser-label-Type+Coercion
|
12
|
+
OPTIONPARSER_COERSIONS = [
|
13
|
+
Date,
|
14
|
+
DateTime,
|
15
|
+
Time,
|
16
|
+
URI,
|
17
|
+
#Shellwords, # Stock in optparse, but under autoload. I don't want it.
|
18
|
+
String,
|
19
|
+
Integer,
|
20
|
+
Float,
|
21
|
+
Numeric,
|
22
|
+
TrueClass,
|
23
|
+
FalseClass,
|
24
|
+
Array,
|
25
|
+
Regexp,
|
26
|
+
].concat(OptionParser::Acceptables::constants)
|
27
|
+
|
28
|
+
|
29
|
+
# Struct to wrap a MediaMolecule option/attribute datum.
|
30
|
+
Compound = Struct.new(:isotopes, :molecule, :valid, :default, :blurb, keyword_init: true) do
|
31
|
+
|
32
|
+
# Massage the data then call `super` to set the members/values and create the accessors.
|
33
|
+
def initialize(isotopes, molecule: nil, valid: nil, default: nil, blurb: nil)
|
34
|
+
super(
|
35
|
+
# The first argument defines the aliases for a single option and may be just a Symbol
|
36
|
+
# or may be an Enumerable[Symbol] in which case all items after the first are aliases for the first.
|
37
|
+
isotopes: isotopes.is_a?(Enumerable) ? isotopes.to_a : Array[isotopes],
|
38
|
+
# Hint the MediaMolecule that should execute this Change.
|
39
|
+
molecule: molecule,
|
40
|
+
# Valid values for this option may be expressed as a Class a value must be an instance of,
|
41
|
+
# a Regexp a String value must match, an Enumerable that a valid value must be in,
|
42
|
+
# a Range a Float/Integer must be within, a special Boolean Set, etc etc.
|
43
|
+
valid: case valid
|
44
|
+
when Set then valid.to_a
|
45
|
+
else valid
|
46
|
+
end,
|
47
|
+
# Optional default value to use when unset.
|
48
|
+
default: default,
|
49
|
+
# String description of this option's effect.
|
50
|
+
blurb: blurb,
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
54
|
+
# The first isotope is the """real""" option name. Any others are aliases for it.
|
55
|
+
def element; self.isotopes&.first; end
|
56
|
+
def to_s; self.element.to_s; end
|
57
|
+
|
58
|
+
# Returns a longform String representation of one option.
|
59
|
+
def inspect
|
60
|
+
# Intentionally not including the blurb here since they are pretty long and messy.
|
61
|
+
"#{self.isotopes.length > 1 ? self.isotopes : self.element}: #{"#{self.valid} " if self.valid}#{"(#{self.default})" unless self.default.nil?}"
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns an Array of properly-formatted OptionParser::Switch strings for this Compound.
|
65
|
+
def to_options
|
66
|
+
# @isotopes is a Hash[Symbol] => Compound, allowing for Compound aliasing
|
67
|
+
# to multiple Hash keys, e.g. libvips' `:Q` and `:quality` are two Hash keys
|
68
|
+
# referencing the same Compound object.
|
69
|
+
self.isotopes.each_with_object(Array[]) { |aka, commands|
|
70
|
+
# Every Switch has at least one leading dash, and longer ones have two,
|
71
|
+
# e.g. `-Q` vs `--quality`.
|
72
|
+
command = "-"
|
73
|
+
if aka.length > 1
|
74
|
+
command << '-'.freeze
|
75
|
+
end
|
76
|
+
|
77
|
+
# Compounds that take a boolean should format their Switch string
|
78
|
+
# as `--[no]-whatever` (including the brackets!) instead of taking
|
79
|
+
# any kind of boolean-ish argument like true/false/yes/no.
|
80
|
+
#
|
81
|
+
# TODO: There seems to be a bug with Ruby optparse and multiple of these
|
82
|
+
# "--[no]-whatever"-style Switches where only the final Switch will display,
|
83
|
+
# so disable this for now in favor of separate --whatever/--no-whatever.
|
84
|
+
# I have a very basic standalone repro case that fails, so it's not just DD.
|
85
|
+
#
|
86
|
+
#if @valid == BOOLEAN_VALUES or @valid == BOOLEAN_VALUES.to_a
|
87
|
+
# command << '[no]-'.freeze
|
88
|
+
#end
|
89
|
+
|
90
|
+
# Add the alias to form the command.
|
91
|
+
command << aka.to_s
|
92
|
+
|
93
|
+
# Format the valid values and/or default value and stick it on the end.
|
94
|
+
# https://ruby-doc.org/stdlib/libdoc/optparse/rdoc/OptionParser.html#class-OptionParser-label-Type+Coercion
|
95
|
+
if self.valid.is_a?(Range)
|
96
|
+
command << " [#{self.valid.to_s}]"
|
97
|
+
elsif self.valid == BOOLEAN_VALUES or self.valid == BOOLEAN_VALUES.to_a
|
98
|
+
# Intentional no-op
|
99
|
+
elsif self.valid.is_a?(Enumerable)
|
100
|
+
command << " [#{self.valid.join(', '.freeze)}]" unless self.valid.empty?
|
101
|
+
elsif self.valid.is_a?(Class)
|
102
|
+
command << " [#{self.valid.name}]"
|
103
|
+
else
|
104
|
+
command << " [#{self.element.upcase}]"
|
105
|
+
end
|
106
|
+
|
107
|
+
commands << command
|
108
|
+
|
109
|
+
# HACK around issue with multiple "--[no]-whatever"-style long arguments.
|
110
|
+
# See above note and the commented-out implementation I'd like to use
|
111
|
+
# instead of this. Remove this iff I can figure out what's wrong there.
|
112
|
+
if self.valid == BOOLEAN_VALUES or self.valid == BOOLEAN_VALUES.to_a
|
113
|
+
commands << "--no-#{aka}"
|
114
|
+
end
|
115
|
+
}
|
116
|
+
end # to_options
|
117
|
+
|
118
|
+
end # Compound
|
119
|
+
|
120
|
+
end
|