distorted 0.5.4 → 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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +661 -0
  3. data/README.md +5 -140
  4. data/bin/console +14 -0
  5. data/bin/distorted +6 -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.rb +2 -0
  34. data/lib/distorted/checking_you_out.rb +219 -0
  35. data/lib/distorted/checking_you_out/README +4 -0
  36. data/lib/distorted/checking_you_out/application.yaml +33 -0
  37. data/lib/distorted/checking_you_out/font.yaml +29 -0
  38. data/lib/distorted/checking_you_out/image.yaml +108 -0
  39. data/lib/distorted/click_again.rb +333 -0
  40. data/lib/distorted/element_of_media.rb +2 -0
  41. data/lib/distorted/element_of_media/change.rb +119 -0
  42. data/lib/distorted/element_of_media/compound.rb +120 -0
  43. data/lib/distorted/error_code.rb +51 -0
  44. data/lib/distorted/floor.rb +17 -0
  45. data/lib/distorted/invoker.rb +97 -0
  46. data/lib/distorted/media_molecule.rb +58 -0
  47. data/lib/distorted/media_molecule/font.rb +195 -0
  48. data/lib/distorted/media_molecule/image.rb +33 -0
  49. data/lib/distorted/media_molecule/pdf.rb +44 -0
  50. data/lib/distorted/media_molecule/svg.rb +45 -0
  51. data/lib/distorted/media_molecule/text.rb +203 -0
  52. data/lib/distorted/media_molecule/video.rb +18 -0
  53. data/lib/distorted/modular_technology/gstreamer.rb +174 -0
  54. data/lib/distorted/modular_technology/pango.rb +90 -0
  55. data/lib/distorted/modular_technology/ttfunk.rb +48 -0
  56. data/lib/distorted/modular_technology/vips.rb +17 -0
  57. data/lib/distorted/modular_technology/vips/foreign.rb +489 -0
  58. data/lib/distorted/modular_technology/vips/load.rb +133 -0
  59. data/lib/distorted/modular_technology/vips/save.rb +161 -0
  60. data/lib/distorted/monkey_business/encoding.rb +317 -0
  61. data/lib/distorted/monkey_business/hash.rb +18 -0
  62. data/lib/distorted/monkey_business/set.rb +15 -0
  63. data/lib/distorted/monkey_business/string.rb +6 -0
  64. data/lib/distorted/triple_counter.rb +52 -0
  65. data/lib/distorted/version.rb +22 -0
  66. data/test/distorted_test.rb +11 -0
  67. data/test/test_helper.rb +4 -0
  68. metadata +130 -20
Binary file
@@ -0,0 +1,2 @@
1
+ #TODO: Genericize Ruby version-checking and use it here to require 2.7+
2
+ require 'distorted/invoker'
@@ -0,0 +1,219 @@
1
+ require 'set'
2
+
3
+ # CYO encapsulate all concepts related to media file identification for DistorteD!
4
+
5
+ # General media type resources:
6
+ # https://www.iana.org/assignments/media-types/media-types.xhtml
7
+
8
+
9
+ # The Gem `ruby-mime-types` and its associated `mime-types-data` provide our core classes:
10
+ # https://github.com/mime-types/ruby-mime-types
11
+ require 'mime/types'
12
+ #
13
+ # - Its MIME Types database ensures I don't have to constantly update DD with
14
+ # new filetypes as they are invented, like how AVIF is still currently brand-new as of 2021.
15
+ # - Our main Type search method — :OUT() — wraps `MIME::Types.type_for`/`MIME::Types[]`
16
+ # to identify media types based on filename alone (e.g. even for hypothetical files).
17
+ # - The Object we give callers will be a `MIME::Type`, not anything wrapped/renamed.
18
+ # - I use its Loader class and YAML structure to ship additional Type definitions local to DD.
19
+ #
20
+ # NOTE: Type objects returned from the unwrapped MIME::Types interfaces will not have equality
21
+ # with instances of the same media type from CYO! This is because we load our own database.
22
+ # irb(main)> MIME::Types['image/jpeg'].object_id
23
+ # => 136400
24
+ # irb(main)> CHECKING::YOU::OUT['image/jpeg'].object_id
25
+ # => 90800
26
+
27
+
28
+ # The Gem `ruby-filemagic` provides the ability to inspect the magic bytes of actual on-disk files.
29
+ # https://github.com/blackwinter/ruby-filemagic
30
+ # http://blackwinter.github.io/ruby-filemagic/
31
+ #
32
+ # NOTE: Unmaintained!
33
+ # https://github.com/blackwinter/ruby-filemagic/commit/e1f2efd07da4130484f06f58fed016d9eddb4818
34
+ #
35
+ # Might consider replacing this with an FFI filemagic to eliminate the native-code compilation.
36
+ # https://rubygems.org/gems/glongman-ffiruby-filemagic/
37
+ # https://stuart.com/blog/ruby-bindings-extensions/
38
+ require 'ruby-filemagic'
39
+
40
+
41
+ # Monkey-patch some DistorteD-specific methods into MIME::Type objects.
42
+ module MIME
43
+ class Type
44
+
45
+ # Provide a few variations on the base :distorted_method for mixed workflows
46
+ # where it isn't feasible to overload a single method name and call :super.
47
+ # Jekyll, for example, renders its output markup upfront, collects all of
48
+ # the StaticFiles (or StaticStatic-includers, in our case), then calls their
49
+ # :write methods all at once after the rest of the site is built,
50
+ # and this precludes us from easily sharing method names between layers.
51
+ DISTORTED_METHOD_PREFIXES = Hash[
52
+ :buffer => 'to'.freeze,
53
+ :file => 'write'.freeze,
54
+ :template => 'render'.freeze,
55
+ ]
56
+ SUB_TYPE_SEPARATORS = /[-_+\.]/
57
+
58
+ # Returns a Symbol name of the method that should return a String buffer containing the file in this Type.
59
+ def distorted_buffer_method; "#{DISTORTED_METHOD_PREFIXES[:buffer]}_#{distorted_method_suffix}".to_sym; end
60
+
61
+ # Returns a Symbol name of the method that should write a file of this Type to a given path on a filesystem.
62
+ def distorted_file_method; "#{DISTORTED_METHOD_PREFIXES[:file]}_#{distorted_method_suffix}".to_sym; end
63
+
64
+ # Returns a Symbol name of the method that should returns a context-appropriate Object
65
+ # for displaying the file as this Type.
66
+ # Might be e.g. a String buffer containing Rendered Liquid in Jekylland,
67
+ # or a Type-appropriate frame in some GUI toolkit in DD-Booth.
68
+ def distorted_template_method; "#{DISTORTED_METHOD_PREFIXES[:template]}_#{distorted_method_suffix}".to_sym; end
69
+
70
+ # Returns an Array[Array[String]] of human-readable keys we can use for our YAML config,
71
+ # e.g. :media_type 'image' & :sub_type 'svg+xml' would be split to ['image', 'svg'].
72
+ # `nil` `:sub_type`s will just be compacted out.
73
+ # Every non-nil :media_type will also request a key path [media_type, '*']
74
+ # to allow for similar-type defaults, e.g. every image type outputting a fallback.
75
+ def settings_paths; [[self.media_type, '*'.freeze], [self.media_type, self.sub_type&.split('+'.freeze)&.first].compact]; end
76
+
77
+ private
78
+
79
+ # Provide a consistent base method name for context-specific DistorteD operations.
80
+ def distorted_method_suffix
81
+ # Standardize MIME::Types' media_type+sub_type to DistorteD method mapping
82
+ # by replacing all the combining characters with underscores (snake case)
83
+ # to match Ruby conventions:
84
+ # https://rubystyle.guide/#snake-case-symbols-methods-vars
85
+ #
86
+ # For the worst possible example, an intended outout Type of
87
+ # "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
88
+ # (a.k.a. a MSWord `docx` file) would map to a DistorteD saver method
89
+ # :to_application_vnd_openxmlformats_officedocument_wordprocessingml_document
90
+ # which would most likely be defined by the :included method of a library-specific
91
+ # module for handling OpenXML MS Office documents.
92
+ "#{self.media_type}_#{self.sub_type.gsub(SUB_TYPE_SEPARATORS, '_'.freeze)}"
93
+ end # distorted_method_suffix
94
+ end
95
+ end
96
+
97
+
98
+ module CHECKING
99
+ class YOU
100
+
101
+ # Returns a single Type with Array-style access.
102
+ class OUT
103
+ def self.[](type)
104
+ CHECKING::YOU::types[type].first
105
+ end
106
+ end
107
+
108
+ # Returns a Set of MIME::Type for a given file path, by default only
109
+ # based on the file extension. If the file extension is unavailable—
110
+ # or if `so_deep` is enabled—the `path` will be used as an actual
111
+ # path to look at the magic bytes with ruby-filemagic.
112
+ def self.OUT(path, so_deep: false, only_one_test: false)
113
+ return Set[] if path.nil?
114
+ if not (only_one_test || types.type_for(path).empty?)
115
+ # NOTE: `type_for`'s return order is supposed to be deterministic:
116
+ # https://github.com/mime-types/ruby-mime-types/issues/148
117
+ # My use case so far has never required order but has required
118
+ # many Set comparisons, so I am going to return a Set here
119
+ # and possibly throw the order away.
120
+ # In my experience the order is usually preserved anyway:
121
+ # irb(main)> MIME::Types.type_for(File.expand_path('lol.ttf'))
122
+ # => [#<MIME::Type: font/ttf>, #<MIME::Type: application/font-sfnt>, #<MIME::Type: application/x-font-truetype>, #<MIME::Type: application/x-font-ttf>]
123
+ # irb(main)> MIME::Types.type_for('lol.ttf')).to_set
124
+ # => #<Set: {#<MIME::Type: font/ttf>, #<MIME::Type: application/font-sfnt>, #<MIME::Type: application/x-font-truetype>, #<MIME::Type: application/x-font-ttf>}>
125
+ return types.type_for(path).to_set
126
+ elsif (so_deep && path[0] != '.'.freeze) # Support taking hypothetical file extensions (e.g. '.jpg') without stat()ing anything.
127
+ # Did we fail to guess any MIME::Types from the given filename?
128
+ # We're going to have to look at the actual file
129
+ # (or at least its first four bytes).
130
+ FileMagic.open(:mime) do |fm|
131
+ # The second argument makes fm.file return just the simple
132
+ # MIME::Type String, e.g.:
133
+ #
134
+ # irb(main)> fm.file('/home/okeeblow/IIDX-turntable.svg')
135
+ # => "image/svg+xml; charset=us-ascii"
136
+ # irb(main)> fm.file('/home/okeeblow/IIDX-turntable.svg', true)
137
+ # => "image/svg"
138
+ #
139
+ # However MIME::Types won't take short variants like 'image/svg',
140
+ # so explicitly have FM return long types and split it ourself
141
+ # on the semicolon:
142
+ #
143
+ # irb(main)> "image/svg+xml; charset=us-ascii".split(';').first
144
+ # => "image/svg+xml"
145
+ mime = types[fm.file(path, false).split(';'.freeze).first].to_set
146
+ end # FileMagic.open
147
+ else
148
+ # TODO: Warn here that we may need a custom type!
149
+ #p "NO MATCH FOR #{path}"
150
+ Set[]
151
+ end # if
152
+ end # self.OUT()
153
+
154
+ # Returns a Set of MIME::Type objects matching a String search key of the
155
+ # format MEDIA_TYPE/SUB_TYPE.
156
+ # This can return multiple Types, e.g. 'font/collection' TTC/OTC variations:
157
+ # [#<MIME::Type: font/collection>, #<MIME::Type: font/collection>]
158
+ def self.IN(wanted_type_or_types)
159
+ if wanted_type_or_types.is_a?(Enumerable)
160
+ # Support taking a list of String types for Molecules whose Type support
161
+ # isn't easily expressable as a single Regexp.
162
+ types.select{ |type| wanted_type_or_types.include?(type.to_s) }
163
+ else
164
+ # Might be a single String or Regexp
165
+ types[wanted_type_or_types, :complete => wanted_type_or_types.is_a?(Regexp)].to_set
166
+ end
167
+ end
168
+
169
+ protected
170
+
171
+ # Returns the MIME::Types container or loads one
172
+ def self.types
173
+ @@types ||= types_loader
174
+ end
175
+
176
+ # Returns a loaded MIME::Types container containing both the upstream
177
+ # mime-types-data and our own local data.
178
+ def self.types_loader
179
+ container = MIME::Types.new
180
+
181
+ # Load the upstream mime-types-data by providing a nil `path`:
182
+ # path || ENV['RUBY_MIME_TYPES_DATA'] || MIME::Types::Data::PATH
183
+ loader = MIME::Types::Loader.new(nil, container)
184
+ # TODO: Log this once I figure out a nice way to wrap Jekyll logger too.
185
+ # irb> loader.load_columnar => #<MIME::Types: 2277 variants, 1195 extensions>
186
+ loader.load_columnar
187
+
188
+ # Change default JPEG file extension from .jpeg to .jpg
189
+ # because it pisses me off lol
190
+ container['image/jpeg'].last.preferred_extension = 'jpg'
191
+
192
+ # Add a missing extension to MPEG-DASH manifests:
193
+ # irb> MIME::Types['application/dash+xml'].first
194
+ # => #<MIME::Type: application/dash+xml>
195
+ # irb> MIME::Types['application/dash+xml'].first.preferred_extension
196
+ # => nil
197
+ # https://www.iana.org/assignments/media-types/application/dash+xml
198
+ container['application/dash+xml'].last.preferred_extension = 'mpd'
199
+
200
+ # Override the loader's path with the path to our local data directory
201
+ # after we've loaded the upstream data.
202
+ # :@path is set up in Loader::initialize and only has an attr_reader
203
+ # but we can reach in and change it.
204
+ loader.instance_variable_set(:@path, File.join(__dir__, 'checking_you_out'.freeze))
205
+
206
+ # Load our local types data. The YAML files are separated by type,
207
+ # and :load_yaml will load all of them in the :@path we just set.
208
+ # MAYBE: Integrate MIME::Types YAML conversion scripts and commit
209
+ # JSON/Columnar artifacts for SPEEEEEED, but YAML is probably fine
210
+ # since we will have so few custom types compared to upstream.
211
+ # Convert.from_yaml_to_json
212
+ # Convert::Columnar.from_yaml_to_columnar
213
+ loader.load_yaml
214
+
215
+ container
216
+ end
217
+
218
+ end
219
+ end
@@ -0,0 +1,4 @@
1
+ # Some of these custom MIME::Types will be just for DD, but
2
+ # I will submit some others of these to the mime-types-data project
3
+ # if and when it makes sense to do so. Here's how:
4
+ # https://github.com/mime-types/mime-types-data/blob/master/Contributing.md
@@ -0,0 +1,33 @@
1
+ # Define our own type to trigger fallback copy instead of abusing application/x-imagemap :)
2
+ - !ruby/object:MIME::Type
3
+ content-type: application/x.distorted.never-let-you-down
4
+ xrefs:
5
+ person:
6
+ - okeeblow
7
+ registered: false
8
+
9
+
10
+ # MATLAB files supported by VIPS Magick foreign loader.
11
+ - !ruby/object:MIME::Type
12
+ content-type: application/x-matlab-data
13
+ encoding: 8bit
14
+ extensions:
15
+ - mat
16
+ xrefs_urls:
17
+ - "http://justsolve.archiveteam.org/wiki/MAT"
18
+ registered: false
19
+
20
+
21
+ # Microsoft SilverLight multiple-zoom tiled-raster image archive formats.
22
+ # I made this content-type up based on the convention of other 'vnd.microsoft' Types,
23
+ # e.g. #<MIME::Type: application/vnd.microsoft.windows.thumbnail-cache>
24
+ - !ruby/object:MIME::Type
25
+ content-type: application/vnd.microsoft.deep-zoom
26
+ extensions:
27
+ - dz
28
+ - dzi
29
+ - dzc
30
+ - xml
31
+ xrefs_urls:
32
+ - "http://msdn.microsoft.com/en-us/library/cc645077(VS.95).aspx"
33
+ registered: false
@@ -0,0 +1,29 @@
1
+
2
+ # RFC8081 defines the top-level `font` media-type and includes
3
+ # definitions for font/ttf, font/otf, and font/collection,
4
+ # but neglects to include the OpenType Collection (.otc)
5
+ # file extension, meaning the IANA DB's font/collection
6
+ # only covers TTC and not OTC:
7
+ #
8
+ # https://tools.ietf.org/html/rfc8081#section-4.4.4
9
+ # "Type name: font
10
+ # Subtype name: collection
11
+ # Required parameters: None
12
+ # Optional parameters
13
+ # Name: outlines
14
+ # Values: a comma-separated subset of TTF, CFF, and SVG
15
+ #
16
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/otff
17
+ # "OpenType fonts may have the extension .OTF, .TTF, .OTC or .TTC.
18
+ # The extensions .OTC and .TTC should only be used for font collection files."
19
+ - !ruby/object:MIME::Type
20
+ content-type: font/collection
21
+ encoding: base64
22
+ extensions:
23
+ - otc
24
+ xrefs:
25
+ template:
26
+ - font/collection
27
+ xref_urls:
28
+ - "https://docs.microsoft.com/en-us/typography/opentype/spec/otff#filenames"
29
+ registered: false
@@ -0,0 +1,108 @@
1
+ # Define our own type to trigger a legacy <img> element src for <picture>
2
+ - !ruby/object:MIME::Type
3
+ content-type: image/x.distorted.fallback
4
+ xrefs:
5
+ person:
6
+ - okeeblow
7
+ registered: false
8
+
9
+
10
+ # libvips' internal format, unlikely to be used but may as well be supported :)
11
+ - !ruby/object:MIME::Type
12
+ content-type: image/vips
13
+ extensions:
14
+ - v
15
+ - vips
16
+ xrefs_urls:
17
+ - "http://fileformats.archiveteam.org/wiki/VIPS"
18
+ registered: false
19
+
20
+
21
+ # OpenEXR stuff
22
+ - !ruby/object:MIME::Type
23
+ content-type: image/x-exr
24
+ extensions:
25
+ - exr
26
+ xrefs_urls:
27
+ - "https://www.nationalarchives.gov.uk/PRONOM/fmt/1001"
28
+ - "https://en.wikipedia.org/wiki/OpenEXR"
29
+ - "http://fileformats.archiveteam.org/wiki/OpenEXR"
30
+
31
+ - !ruby/object:MIME::Type
32
+ content-type: image/vnd.radiance
33
+ extensions:
34
+ - hdr
35
+ xrefs_urls:
36
+ - "https://en.wikipedia.org/wiki/RGBE_image_format"
37
+ registered: false
38
+
39
+ - !ruby/object:MIME::Type
40
+ content-type: image/fits
41
+ extensions:
42
+ - fits
43
+ - fit
44
+ - fts
45
+ xrefs_urls:
46
+ - "https://www.iana.org/assignments/media-types/image/fits"
47
+ - "https://www.cv.nrao.edu/fits/"
48
+ registered: true
49
+
50
+ # End OpenEXR stuff
51
+
52
+
53
+ # OpenSlide stuff — https://openslide.org/formats/
54
+
55
+ - !ruby/object:MIME::Type
56
+ content-type: image/vnd.scanscope.virtual.slide
57
+ extensions:
58
+ - svs # Excluding tiff since that has a generic entry.
59
+ xrefs_urls:
60
+ - "https://openslide.org/formats/aperio/"
61
+ - "https://docs.openmicroscopy.org/bio-formats/latest/formats/aperio-svs-tiff.html"
62
+ - "http://justsolve.archiveteam.org/wiki/Aperio_SVS"
63
+ registered: false
64
+
65
+ - !ruby/object:MIME::Type
66
+ content-type: image/vnd.hamamatsu
67
+ extensions:
68
+ - vms
69
+ - vmu
70
+ - ndpi
71
+ xrefs_urls:
72
+ - "https://openslide.org/formats/hamamatsu/"
73
+ registered: false
74
+
75
+ - !ruby/object:MIME::Type
76
+ content-type: image/vnd.sakura
77
+ extensions:
78
+ - svslide
79
+ xrefs_urls:
80
+ - "https://openslide.org/formats/sakura/"
81
+ registered: false
82
+
83
+ - !ruby/object:MIME::Type
84
+ content-type: image/vnd.mirax
85
+ extensions:
86
+ - mrxs
87
+ xrefs_urls:
88
+ - "https://openslide.org/formats/mirax/"
89
+ registered: false
90
+
91
+ - !ruby/object:MIME::Type
92
+ content-type: image/vnd.leica
93
+ extensions:
94
+ - scn
95
+ xrefs_urls:
96
+ - "https://openslide.org/formats/leica/"
97
+ registered: false
98
+
99
+ - !ruby/object:MIME::Type
100
+ content-type: image/vnd.ventana
101
+ extensions:
102
+ - bif
103
+ xrefs_urls:
104
+ - "https://openslide.org/formats/ventana/"
105
+ registered: false
106
+
107
+
108
+ # End OpenSlide stuff
@@ -0,0 +1,333 @@
1
+ require 'set'
2
+ require 'distorted/monkey_business/set'
3
+
4
+ require 'optparse'
5
+ require 'shellwords' # Necessary for inclusion in OptionParser coercions list.
6
+
7
+ require 'distorted/invoker'
8
+ require 'distorted/checking_you_out'
9
+ require 'distorted/element_of_media/compound'
10
+
11
+
12
+ module Cooltrainer; end
13
+ module Cooltrainer::DistorteD; end
14
+
15
+ class Cooltrainer::DistorteD::ClickAgain
16
+
17
+ include Cooltrainer::DistorteD::Invoker # MediaMolecule plugger
18
+
19
+ attr_reader :global_options, :lower_options, :outer_options
20
+
21
+
22
+ # Set up and parse a given Array of command-line switches based on
23
+ # our global OptionParser and its Type/Molecule-specific sub-commands.
24
+ #
25
+ # :argv will be operated on destructively!
26
+ # Consider passing a duplicate of ARGV instead of passing it directly.
27
+ def initialize(argv, exe_name)
28
+
29
+ # Partition argv into (switches and their arguments) and (filenames or wanted type Strings)
30
+ switches, @get_out = partition_argv(argv)
31
+
32
+ # Initialize Hashes to store our three types of Options using a small
33
+ # custom subclass that will store items as a Set but won't store :nil alone.
34
+ @global_options = Hash.new { |h,k| h[k] = h.class.new(&h.default_proc) }
35
+ @lower_options = Hash.new { |h,k| h[k] = h.class.new(&h.default_proc) }
36
+ @outer_options = Hash.new { |h,k| h[k] = h.class.new(&h.default_proc) }
37
+ # Temporary Array for unmatched Switches when parsing subcommands.
38
+ sorry_try_again = Array.new
39
+
40
+ # Pass our executable name in for the global OptionParser's banner String,
41
+ # then parse the complete/raw user-given-arguments-list first with this Parser.
42
+ #
43
+ # I am intentionally using OptionParser's non-POSIXy :permute! method
44
+ # instead of the POSIX-compatible :order! method,
45
+ # because I want to :)
46
+ # Otherwise users would have to define all switch arguments
47
+ # ahead of all positional arguments in the command,
48
+ # and I think that would be frustrating and silly.
49
+ #
50
+ # In strictly-POSIX mode, one would have to call e.g.
51
+ # `distorted -o image/png inputfile.webp outfilewithnofileextension`
52
+ # instead of
53
+ # `distorted inputfile.webp -o image/png outfilewithnofileextension`,
54
+ # which I find to be much more intuitive.
55
+ #
56
+ # Note that `:parse!` would call one of the other of :order!/:permute! based on
57
+ # an invironment variable `POSIXLY_CORRECT`. Talk about a footgun!
58
+ # Be explicit!!
59
+ global = global_options(exe_name)
60
+ begin
61
+ switches = global.permute!(switches, into: @global_options)
62
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::ParseError => nope
63
+ nope.recover(sorry_try_again) # Will :unshift the :nope value to the recovery Array.
64
+ #if switches&.first&.chr == '-'.freeze
65
+ # sorry_try_again.unshift(switches.shift)
66
+ #end
67
+ retry
68
+ end
69
+ switches.unshift(*sorry_try_again.reverse)
70
+
71
+ # The global OptionParser#permute! call will strip our `:argv` Array of
72
+ # any `--help` or Molecule-picking switches.
73
+ # Molecule-specific switches (both 'lower' and 'outer') and positional
74
+ # file-name arguments remain.
75
+ #
76
+ # The first remaining `argv` will be our input filename if one was given!
77
+ #
78
+ # NOTE: Never assume this filename will be a complete, absolute, usable path.
79
+ # POSIX shells do not do tilde expansion, for example, on quoted switch arguments,
80
+ # so a quoted filename argument '~/cover.png' will come through to Ruby-land
81
+ # as the literal String '~/cover.png' while the same filename argument sans-quotes
82
+ # will be expanded to e.g. '/home/okeeblow/cover.png' (based on `$HOME` env var).
83
+ # Additional Ruby-side path validation will almost certainly be needed!
84
+ # https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_01
85
+ @name = @get_out&.shift
86
+
87
+ # Print some sort of help message or list of supported input/output Types
88
+ # if no source filename was given.
89
+ unless @name
90
+ puts case
91
+ when @global_options.has_key?(:help) then global
92
+ when @global_options.has_key?(:"lower-world")
93
+ "Supported input media types:\n#{lower_world.keys.join("\n")}"
94
+ when @global_options.has_key?(:"outer-limits")
95
+ "Supported output media types:\n#{outer_limits(all: true).values.map{|m| m.keys}.join("\n")}"
96
+ else global
97
+ end
98
+ exit
99
+ end
100
+
101
+ # Here's that additional filename validation I was talking about.
102
+ # I don't do this as a one-shot with the argv.shift because
103
+ # File::expand_path raises an error on :nil argument,
104
+ # and we already checked for that when we checked for 'help' switches.
105
+ @name = File.expand_path(@name)
106
+
107
+ # Check for 'help' switches *again* now that we have a source file path,
108
+ # because the output can be file-specific instead of generic.
109
+ # This is where we display subcommands' help!
110
+ specific_help = case
111
+ when @get_out.empty?
112
+ # Only input filename given; no outputs; nothing left to do!
113
+ lower_subcommands.merge(outer_subcommands).values.unshift(Hash[:DistorteD => [global]]).map { |l|
114
+ l.values.join("\n")
115
+ }.join("\n")
116
+ when @global_options.has_key?(:help), @global_options.has_key?(:"lower-world")
117
+ lower_subcommands.values.map { |l|
118
+ l.values.join("\n")
119
+ }.join("\n")
120
+ when @global_options.has_key?(:"outer-limits")
121
+ # Trigger this help message on `-o` iff that switch is used bare.
122
+ # If `-o` is given an argument it will inform the MIME::Type
123
+ # of the same-index output file, e.g.
124
+ # `-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
+ def write(dest_root)
168
+ changes.each { |change|
169
+ if self.respond_to?(change.type.distorted_file_method)
170
+ # WISHLIST: Remove the empty final positional Hash argument once we require a Ruby version
171
+ # that will not perform the implicit Change-to-Hash conversion due to Change's
172
+ # implementation of :to_hash. Ruby 2.7 will complain but still do the conversion,
173
+ # breaking downstream callers that want a Struct they can call arbitrary key methods on.
174
+ # https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/
175
+ self.send(change.type.distorted_file_method, dest_root, change, **{})
176
+ else
177
+ raise MediaTypeOutputNotImplementedError.new(change.name, change.type, self.class.name)
178
+ end
179
+ }
180
+ end
181
+
182
+ private
183
+
184
+ # Partitions the raw `argv` into two buckets — outer_limits (String relative filenames or "media/type" Strings) and switches/arguments.
185
+ #
186
+ # References:
187
+ # - glibc Program Argument Syntax Conventions https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html
188
+ # - POSIX Utility Argument Syntax https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_01
189
+ # - Windows Command-line syntax key https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/command-line-syntax-key
190
+ #
191
+ # The filenames will be used as the source file (first member) and destination file(s) (any others).
192
+ # The switches/arguments will be passed to our global OptionParser's `:parse_in_order`
193
+ # which will return the unused remainder.
194
+ #
195
+ # I think it should be possible to achieve this same effect with our global OptionParser alone
196
+ # by specifying two required NoArgument Switches (source and first destination filename),
197
+ # specifying multiple optional NoArgument Switches (additional destinations),
198
+ # and parsing the unmodified `:argv` in permutation mode.
199
+ # I can't figure out how to wrangle OptionParser into doing that rn tho so welp here we are.
200
+ #
201
+ # There is a built-in Enumeraable#partition, but:
202
+ # - It doesn't take an accumulator variable natively.
203
+ # - I'm bad at chaining Enumerators and idk how to chain in a `:with_object` without returning only that object.
204
+ # - `:with_object` treats scalar types as immutable which precludes cleanly passing a boolean flag variable between iterations.
205
+ def partition_argv(argv)
206
+ switches, @get_out = argv.each_with_object(
207
+ # Accumulate to a three-key Hash containing the two wanted buckets and the flag that will be discarded.
208
+ Hash[:switches => Array.new, :get_out => Array.new, :want_value => false]
209
+ ) { |arg, partition|
210
+ # Switches and their values will be:
211
+ # - Any argument beginning with a single dash, e.g. long switches like '--crop' or short switches like '-Q90'.
212
+ # - Any non-dash argument if :want_value is flagged, e.g. the 'attention' value for the '--crop' switch.
213
+ # Filenames will be:
214
+ # - Anything else :)
215
+ if partition.fetch(:want_value) and not arg[0] == '-'.freeze
216
+ # `ARGV` Strings are frozen, so we have to replace instead of directly concat
217
+ partition[:switches].push(partition[:switches].pop.yield_self { |last| "#{last}#{'='.freeze unless arg[0] == '='.freeze}#{arg}" })
218
+ else
219
+ partition[(arg[0] == '-'.freeze or partition.fetch(:want_value)) ? :switches : :get_out].push(arg)
220
+ end
221
+ # The *next* argument should be a value for this iteration's argument iff:
222
+ # - This iteration is a long switch with no included value, e.g. '--crop' but not '--crop=attention'.
223
+ # - This iteration is a short switch with no included value, e.g. '-Q' but not '-Q90' or '-Q=90'.
224
+ partition.store(:want_value, [
225
+ arg[0] == '-'.freeze, # e.g. '--crop' or '-Q'
226
+ !arg.include?('='.freeze), # e.g. not '--crop=attention' or '-Q=90'
227
+ [
228
+ arg[1] == '-'.freeze, # e.g. '--crop'
229
+ [arg[1] == '-'.freeze, arg.length > 2].none? # e.g. not '-Q90'
230
+ ].any?
231
+ ].all?)
232
+ }.values.select(&Array.method(:===)) # Return only the Array members of the Hash
233
+ end
234
+
235
+ # Generic top-level OptionParser
236
+ def global_options(exe_name)
237
+ OptionParser.new do |opts|
238
+ opts.banner = "Usage: #{exe_name} [OPTION]… SOURCE DEST [DEST]…"
239
+ opts.on_tail('-h', '--help', 'Show this message')
240
+ opts.on('-v', '--[no-]verbose', 'Run verbosely')
241
+ opts.on('-l', '--lower-world', 'Show supported input media types')
242
+ opts.on('-o', '--outer-limits', 'Show supported output media types')
243
+ end
244
+ end
245
+
246
+ # Returns an Array[Change] for every intended output variation.
247
+ def changes
248
+ @changes ||= begin
249
+ # TODO: Consume @lower_options as well, and figure out how to specify Molecule
250
+ # for future situations where multiple Molecules may overlap.
251
+ # Until then, just collapse @outer_options to one Hash and take anything we find for our Type.
252
+ combined_outer_options = @outer_options.each_with_object(Array.new) { |(molecule,type_options),combined| combined.push(type_options) }.reduce(&:merge)
253
+ @get_out.each_with_object(Array[]) { |out, wanted|
254
+ # TODO: Nice way to check format for Type string here.
255
+ # Should be e.g. "image/png"
256
+ if CHECKING::YOU::OUT[out].nil?
257
+ name = out
258
+ type = CHECKING::YOU::OUT(out).first
259
+ else
260
+ name = @name
261
+ type = CHECKING::YOU::OUT[out]
262
+ end
263
+ wanted.push(Cooltrainer::Change.new(type, src: name, **(combined_outer_options.fetch(type, {}))))
264
+ }
265
+ end
266
+ end
267
+
268
+ # Returns an absolute String path to the source file.
269
+ def path
270
+ File.expand_path(@name)
271
+ end
272
+
273
+ # This is a CLI, so we always want to write new files when called.
274
+ def modified?
275
+ true
276
+ end
277
+
278
+ # And again.
279
+ def write?
280
+ true
281
+ end
282
+
283
+ # Generate an OptionParser for a flat Enumerable of Compounds
284
+ COMPOUND_OPTIONPARSER = Proc.new { |compounds, from, to|
285
+ OptionParser.new(banner = "#{from.to_s} ⟹ #{to.to_s}:") { |subopt|
286
+ compounds.map { |compound|
287
+ next if compound.nil?
288
+ parts = Array[
289
+ *compound.to_options,
290
+ ]
291
+ if compound.default == true
292
+ parts.append(TrueClass)
293
+ elsif compound.default == false
294
+ parts.append(FalseClass)
295
+ elsif compound.valid.is_a?(Range)
296
+ # TODO: decide how to handle Ranges that might be hundreds/thousands of items in length.
297
+ elsif compound.valid.is_a?(Enumerable)
298
+ parts.append(compound.valid.to_a)
299
+ elsif Cooltrainer::OPTIONPARSER_COERSIONS.include?(compound.valid.class)
300
+ parts.append(compound.valid)
301
+ end
302
+ parts.append(compound.blurb)
303
+ subopt.on(*parts)
304
+ }
305
+ subopt.on_tail('-h', '--help', 'Show this message')
306
+ }
307
+ }
308
+
309
+ # Generate a Hash[MIME::Type] => Hash[MediaMolecule] => OptionParser
310
+ # for file-specific input options.
311
+ def lower_subcommands
312
+ type_mars.each_with_object(Hash[]) { |type, commands|
313
+ lower_world[type].each_pair { |molecule, aka|
314
+ commands.update(type => {
315
+ molecule => COMPOUND_OPTIONPARSER.call(aka.values.to_set, type, molecule)
316
+ }) { |k,o,n| o.merge(n) }
317
+ }
318
+ }
319
+ end
320
+
321
+ # Generate a Hash[MediaMolecule] => Hash[MIME::Type] => OptionParser
322
+ # for file-specific output options.
323
+ def outer_subcommands(all: false)
324
+ outer_limits(all: all).each_with_object(Hash[]) { |(molecule, types), commands|
325
+ types.each_pair { |type, aka|
326
+ commands.update(molecule => {
327
+ type => COMPOUND_OPTIONPARSER.call(aka.values.to_set, molecule, type)
328
+ }) { |k,o,n| o.merge(n) }
329
+ }
330
+ }
331
+ end
332
+
333
+ end