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.
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'