distorted-floor 0.7.0
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 +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
|