distorted-floor 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +661 -0
  3. data/README.md +32 -0
  4. data/bin/distorted-floor +16 -0
  5. data/bin/repl +14 -0
  6. data/bin/setup +8 -0
  7. data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/img/Less_Perfect_DOS_VGA.png +0 -0
  8. data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/img/More_Perfect_DOS_VGA.png +0 -0
  9. data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/img/Perfect_DOS_VGA.png +0 -0
  10. data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/less_more_perfect_dos_vga_437.html +52 -0
  11. data/font/1252/LICENSE/PerfectDOSVGA437/font-comment.php@file=perfect_dos_vga_437.html +5 -0
  12. data/font/1252/LessPerfectDOSVGA.ttf +0 -0
  13. data/font/1252/MorePerfectDOSVGA.ttf +0 -0
  14. data/font/1252/Perfect DOS VGA 437 Win.ttf +0 -0
  15. data/font/437/Perfect DOS VGA 437.ttf +0 -0
  16. data/font/437/dos437.txt +72 -0
  17. data/font/65001/Anonymous Pro B.ttf +0 -0
  18. data/font/65001/Anonymous Pro BI.ttf +0 -0
  19. data/font/65001/Anonymous Pro I.ttf +0 -0
  20. data/font/65001/Anonymous Pro.ttf +0 -0
  21. data/font/65001/LICENSE/AnonymousPro/FONTLOG.txt +45 -0
  22. data/font/65001/LICENSE/AnonymousPro/OFL-FAQ.txt +235 -0
  23. data/font/65001/LICENSE/AnonymousPro/OFL.txt +94 -0
  24. data/font/65001/LICENSE/AnonymousPro/README.txt +55 -0
  25. data/font/850/ProFont-Bold-01/LICENSE +22 -0
  26. data/font/850/ProFont-Bold-01/readme.txt +28 -0
  27. data/font/850/ProFontWindows-Bold.ttf +0 -0
  28. data/font/850/ProFontWindows.ttf +0 -0
  29. data/font/850/Profont/LICENSE +22 -0
  30. data/font/850/Profont/readme.txt +31 -0
  31. data/font/932/LICENSE/README-ttf.txt +213 -0
  32. data/font/932/mona.ttf +0 -0
  33. data/lib/distorted-floor/checking_you_out.rb +78 -0
  34. data/lib/distorted-floor/click_again.rb +406 -0
  35. data/lib/distorted-floor/element_of_media/change.rb +114 -0
  36. data/lib/distorted-floor/element_of_media/compound.rb +120 -0
  37. data/lib/distorted-floor/element_of_media.rb +2 -0
  38. data/lib/distorted-floor/error_code.rb +55 -0
  39. data/lib/distorted-floor/floor.rb +17 -0
  40. data/lib/distorted-floor/invoker.rb +100 -0
  41. data/lib/distorted-floor/media_molecule/font.rb +200 -0
  42. data/lib/distorted-floor/media_molecule/image.rb +33 -0
  43. data/lib/distorted-floor/media_molecule/pdf.rb +45 -0
  44. data/lib/distorted-floor/media_molecule/svg.rb +46 -0
  45. data/lib/distorted-floor/media_molecule/text.rb +247 -0
  46. data/lib/distorted-floor/media_molecule/video.rb +21 -0
  47. data/lib/distorted-floor/media_molecule.rb +58 -0
  48. data/lib/distorted-floor/modular_technology/gstreamer.rb +175 -0
  49. data/lib/distorted-floor/modular_technology/pango.rb +90 -0
  50. data/lib/distorted-floor/modular_technology/ttfunk.rb +48 -0
  51. data/lib/distorted-floor/modular_technology/vips/ffi.rb +66 -0
  52. data/lib/distorted-floor/modular_technology/vips/load.rb +174 -0
  53. data/lib/distorted-floor/modular_technology/vips/operatio$.rb +268 -0
  54. data/lib/distorted-floor/modular_technology/vips/save.rb +135 -0
  55. data/lib/distorted-floor/modular_technology/vips.rb +17 -0
  56. data/lib/distorted-floor/monkey_business/encoding.rb +374 -0
  57. data/lib/distorted-floor/monkey_business/hash.rb +18 -0
  58. data/lib/distorted-floor/monkey_business/set.rb +15 -0
  59. data/lib/distorted-floor/monkey_business/string.rb +6 -0
  60. data/lib/distorted-floor.rb +2 -0
  61. 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
@@ -0,0 +1,2 @@
1
+ require 'distorted-floor/element_of_media/change'
2
+ require 'distorted-floor/element_of_media/compound'