shellfie 0.1.1 → 1.0.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +95 -236
  3. data/docs/.nojekyll +0 -0
  4. data/docs/index.html +205 -0
  5. data/docs/scripts.js +85 -0
  6. data/docs/styles.css +507 -0
  7. data/examples/simple.yml +3 -3
  8. data/lib/shellfie/animation_frame_builder.rb +178 -0
  9. data/lib/shellfie/animation_scroll_easing.rb +77 -0
  10. data/lib/shellfie/animation_timeline.rb +27 -0
  11. data/lib/shellfie/ansi_colors.rb +94 -0
  12. data/lib/shellfie/ansi_line_buffer.rb +87 -0
  13. data/lib/shellfie/ansi_normalizer.rb +51 -0
  14. data/lib/shellfie/ansi_parser.rb +50 -84
  15. data/lib/shellfie/cli.rb +22 -173
  16. data/lib/shellfie/cli_generate.rb +197 -0
  17. data/lib/shellfie/cli_info.rb +139 -0
  18. data/lib/shellfie/config.rb +108 -25
  19. data/lib/shellfie/config_defaults.rb +64 -0
  20. data/lib/shellfie/config_validation.rb +200 -0
  21. data/lib/shellfie/dependency_checker.rb +76 -0
  22. data/lib/shellfie/errors.rb +11 -1
  23. data/lib/shellfie/font_resolver.rb +58 -0
  24. data/lib/shellfie/format_resolver.rb +15 -0
  25. data/lib/shellfie/gif_generator.rb +83 -87
  26. data/lib/shellfie/gif_palette.rb +101 -0
  27. data/lib/shellfie/headless_theme_registry.rb +42 -0
  28. data/lib/shellfie/image_magick_command_builder.rb +75 -0
  29. data/lib/shellfie/line_layout.rb +137 -0
  30. data/lib/shellfie/output_writer.rb +41 -0
  31. data/lib/shellfie/parser.rb +113 -23
  32. data/lib/shellfie/parser_validation.rb +145 -0
  33. data/lib/shellfie/raster_painter.rb +157 -0
  34. data/lib/shellfie/render_chrome_cache.rb +40 -0
  35. data/lib/shellfie/render_geometry.rb +114 -0
  36. data/lib/shellfie/render_segment.rb +59 -0
  37. data/lib/shellfie/renderer.rb +79 -149
  38. data/lib/shellfie/rendering/shape_helpers.rb +42 -0
  39. data/lib/shellfie/rendering/text_painter.rb +187 -0
  40. data/lib/shellfie/rendering/window_chrome.rb +196 -0
  41. data/lib/shellfie/svg_raster_wrapper.rb +35 -0
  42. data/lib/shellfie/text_metrics.rb +96 -0
  43. data/lib/shellfie/theme_data.rb +80 -0
  44. data/lib/shellfie/theme_registry.rb +131 -0
  45. data/lib/shellfie/themes/base.rb +10 -1
  46. data/lib/shellfie/themes/configured.rb +61 -0
  47. data/lib/shellfie/themes/macos.rb +3 -1
  48. data/lib/shellfie/themes/ubuntu.rb +2 -1
  49. data/lib/shellfie/themes/windows_terminal.rb +7 -1
  50. data/lib/shellfie/version.rb +1 -1
  51. data/lib/shellfie.rb +37 -3
  52. metadata +37 -2
@@ -1,61 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "strscan"
4
+ require_relative "ansi_colors"
5
+ require_relative "ansi_normalizer"
4
6
 
5
7
  module Shellfie
6
- Segment = Struct.new(:text, :foreground, :background, :bold, :italic, :underline, keyword_init: true)
8
+ Segment = Struct.new(
9
+ :text,
10
+ :foreground,
11
+ :background,
12
+ :bold,
13
+ :italic,
14
+ :underline,
15
+ :dim,
16
+ :reverse,
17
+ :strikethrough,
18
+ :overline,
19
+ keyword_init: true
20
+ )
7
21
 
8
22
  class AnsiParser
9
23
  ANSI_REGEX = /\e\[([0-9;]*)m/
10
24
 
11
- COLORS = {
12
- 30 => :black,
13
- 31 => :red,
14
- 32 => :green,
15
- 33 => :yellow,
16
- 34 => :blue,
17
- 35 => :magenta,
18
- 36 => :cyan,
19
- 37 => :white,
20
- 90 => :bright_black,
21
- 91 => :bright_red,
22
- 92 => :bright_green,
23
- 93 => :bright_yellow,
24
- 94 => :bright_blue,
25
- 95 => :bright_magenta,
26
- 96 => :bright_cyan,
27
- 97 => :bright_white
28
- }.freeze
29
-
30
- BG_COLORS = {
31
- 40 => :black,
32
- 41 => :red,
33
- 42 => :green,
34
- 43 => :yellow,
35
- 44 => :blue,
36
- 45 => :magenta,
37
- 46 => :cyan,
38
- 47 => :white,
39
- 100 => :bright_black,
40
- 101 => :bright_red,
41
- 102 => :bright_green,
42
- 103 => :bright_yellow,
43
- 104 => :bright_blue,
44
- 105 => :bright_magenta,
45
- 106 => :bright_cyan,
46
- 107 => :bright_white
47
- }.freeze
48
-
49
- def initialize
25
+ def initialize(state_mode: :persistent)
26
+ @state_mode = state_mode.to_sym
50
27
  reset_state
51
28
  end
52
29
 
53
30
  def parse(text)
31
+ reset_state if @state_mode == :line
32
+
54
33
  segments = []
55
- scanner = StringScanner.new(text)
34
+ scanner = StringScanner.new(AnsiNormalizer.normalize(text.to_s))
56
35
  current_text = +""
57
36
 
58
- while !scanner.eos?
37
+ until scanner.eos?
59
38
  if scanner.scan(ANSI_REGEX)
60
39
  unless current_text.empty?
61
40
  segments << create_segment(current_text)
@@ -79,6 +58,10 @@ module Shellfie
79
58
  @bold = false
80
59
  @italic = false
81
60
  @underline = false
61
+ @dim = false
62
+ @reverse = false
63
+ @strikethrough = false
64
+ @overline = false
82
65
  end
83
66
 
84
67
  def create_segment(text)
@@ -88,14 +71,18 @@ module Shellfie
88
71
  background: @background,
89
72
  bold: @bold,
90
73
  italic: @italic,
91
- underline: @underline
74
+ underline: @underline,
75
+ dim: @dim,
76
+ reverse: @reverse,
77
+ strikethrough: @strikethrough,
78
+ overline: @overline
92
79
  )
93
80
  end
94
81
 
95
82
  def process_codes(codes_str)
96
83
  return reset_state if codes_str.empty?
97
84
 
98
- codes = codes_str.split(";").map(&:to_i)
85
+ codes = codes_str.split(";").map { |code| code.empty? ? 0 : code.to_i }
99
86
  i = 0
100
87
 
101
88
  while i < codes.length
@@ -106,69 +93,48 @@ module Shellfie
106
93
  reset_state
107
94
  when 1
108
95
  @bold = true
96
+ when 2
97
+ @dim = true
109
98
  when 3
110
99
  @italic = true
111
100
  when 4
112
101
  @underline = true
102
+ when 7
103
+ @reverse = true
104
+ when 9
105
+ @strikethrough = true
113
106
  when 22
114
107
  @bold = false
108
+ @dim = false
115
109
  when 23
116
110
  @italic = false
117
111
  when 24
118
112
  @underline = false
113
+ when 27
114
+ @reverse = false
115
+ when 29
116
+ @strikethrough = false
119
117
  when 30..37, 90..97
120
- @foreground = COLORS[code]
118
+ @foreground = AnsiColors::COLORS[code]
121
119
  when 38
122
- i, @foreground = parse_extended_color(codes, i)
120
+ i, @foreground = AnsiColors.parse_extended_color(codes, i)
123
121
  when 39
124
122
  @foreground = nil
125
123
  when 40..47, 100..107
126
- @background = BG_COLORS[code]
124
+ @background = AnsiColors::BG_COLORS[code]
127
125
  when 48
128
- i, @background = parse_extended_color(codes, i)
126
+ i, @background = AnsiColors.parse_extended_color(codes, i)
129
127
  when 49
130
128
  @background = nil
129
+ when 53
130
+ @overline = true
131
+ when 55
132
+ @overline = false
131
133
  end
132
134
 
133
135
  i += 1
134
136
  end
135
137
  end
136
138
 
137
- def parse_extended_color(codes, i)
138
- return [i, nil] if codes[i + 1].nil?
139
-
140
- case codes[i + 1]
141
- when 5
142
- color_index = codes[i + 2]
143
- [i + 2, color_256(color_index)]
144
- when 2
145
- r, g, b = codes[i + 2], codes[i + 3], codes[i + 4]
146
- [i + 4, format("#%02x%02x%02x", r, g, b)]
147
- else
148
- [i, nil]
149
- end
150
- end
151
-
152
- def color_256(index)
153
- return nil unless index
154
-
155
- if index < 16
156
- standard_colors = %i[
157
- black red green yellow blue magenta cyan white
158
- bright_black bright_red bright_green bright_yellow
159
- bright_blue bright_magenta bright_cyan bright_white
160
- ]
161
- standard_colors[index]
162
- elsif index < 232
163
- index -= 16
164
- r = (index / 36) * 51
165
- g = ((index % 36) / 6) * 51
166
- b = (index % 6) * 51
167
- format("#%02x%02x%02x", r, g, b)
168
- else
169
- gray = (index - 232) * 10 + 8
170
- format("#%02x%02x%02x", gray, gray, gray)
171
- end
172
- end
173
139
  end
174
140
  end
data/lib/shellfie/cli.rb CHANGED
@@ -2,13 +2,19 @@
2
2
 
3
3
  require "optparse"
4
4
  require_relative "../shellfie"
5
+ require_relative "cli_generate"
6
+ require_relative "cli_info"
7
+ require_relative "dependency_checker"
5
8
 
6
9
  module Shellfie
7
10
  class CLI
8
- COMMANDS = %w[generate init themes validate version help].freeze
11
+ include CLIGenerate
12
+ include CLIInfo
13
+
14
+ COMMANDS = %w[generate init themes validate inspect doctor version help].freeze
9
15
 
10
16
  def initialize(args)
11
- @args = args
17
+ @args = args.dup
12
18
  @options = {}
13
19
  end
14
20
 
@@ -26,190 +32,31 @@ module Shellfie
26
32
  run_themes
27
33
  when "validate"
28
34
  run_validate
35
+ when "inspect"
36
+ run_inspect
37
+ when "doctor"
38
+ run_doctor
29
39
  when "version", "-v", "--version"
30
40
  run_version
31
41
  when "help", "-h", "--help"
32
42
  show_help
33
43
  else
34
- puts "Unknown command: #{command}"
35
- puts "Run 'shellfie help' for usage information."
44
+ warn_error "Unknown command: #{command}"
45
+ warn_error "Run 'shellfie help' for usage information."
36
46
  exit 1
37
47
  end
38
48
  rescue Shellfie::Error => e
39
- puts "Error: #{e.message}"
49
+ warn_error "Error: #{e.message}"
40
50
  exit determine_exit_code(e)
51
+ rescue OptionParser::ParseError => e
52
+ warn_error "Error: #{e.message}"
53
+ exit 1
41
54
  end
42
55
 
43
56
  private
44
57
 
45
- def run_generate
46
- parser = OptionParser.new do |opts|
47
- opts.banner = "Usage: shellfie generate INPUT_FILE [options]"
48
-
49
- opts.on("-o", "--output PATH", "Output file path (required)") do |path|
50
- @options[:output] = path
51
- end
52
-
53
- opts.on("-t", "--theme NAME", "Override theme (macos, ubuntu, windows)") do |theme|
54
- @options[:theme] = theme
55
- end
56
-
57
- opts.on("-a", "--animate", "Generate animated GIF") do
58
- @options[:animate] = true
59
- end
60
-
61
- opts.on("-s", "--scale FACTOR", "Output scale (1, 2, 3)") do |scale|
62
- @options[:scale] = scale.to_i
63
- end
64
-
65
- opts.on("-w", "--width PIXELS", Integer, "Override width") do |width|
66
- @options[:width] = width
67
- end
68
-
69
- opts.on("--no-shadow", "Disable shadow effect") do
70
- @options[:shadow] = false
71
- end
72
-
73
- opts.on("--transparent", "Transparent background") do
74
- @options[:transparent] = true
75
- end
76
-
77
- opts.on("--no-header", "Disable window header (headless mode)") do
78
- @options[:headless] = true
79
- end
80
- end
81
-
82
- parser.parse!(@args)
83
-
84
- input_file = @args.shift
85
- raise ConfigError, "Input file is required" unless input_file
86
- raise ConfigError, "Output file is required (use -o option)" unless @options[:output]
87
-
88
- config = Parser.parse(input_file)
89
-
90
- if @options[:theme] || @options[:width] || @options[:headless]
91
- config = Config.new(
92
- theme: @options[:theme] || config.theme,
93
- title: config.title,
94
- window: config.window.merge(@options[:width] ? { width: @options[:width] } : {}),
95
- font: config.font,
96
- lines: config.lines,
97
- animation: config.animation,
98
- frames: config.frames,
99
- headless: @options[:headless] || config.headless
100
- )
101
- end
102
-
103
- if @options[:animate] || config.animated?
104
- generator = GifGenerator.new(config)
105
- output = generator.generate(
106
- @options[:output],
107
- scale: @options[:scale] || 1,
108
- shadow: @options[:shadow] != false
109
- )
110
- else
111
- renderer = Renderer.new(config)
112
- output = renderer.render(
113
- @options[:output],
114
- scale: @options[:scale] || 1,
115
- shadow: @options[:shadow] != false,
116
- transparent: @options[:transparent] || false
117
- )
118
- end
119
-
120
- puts "Generated: #{output}"
121
- end
122
-
123
- def run_init
124
- sample_config = <<~YAML
125
- # Shellfie configuration file
126
- theme: macos
127
- title: "Terminal — zsh"
128
-
129
- window:
130
- width: 600
131
- padding: 20
132
-
133
- lines:
134
- - prompt: "$ "
135
- command: "gem install shellfie"
136
-
137
- - output: |
138
- Fetching shellfie-#{VERSION}.gem
139
- Successfully installed shellfie-#{VERSION}
140
- 1 gem installed
141
-
142
- - prompt: "$ "
143
- command: "shellfie --version"
144
-
145
- - output: "shellfie #{VERSION}"
146
- YAML
147
-
148
- puts sample_config
149
- end
150
-
151
- def run_themes
152
- puts "Available themes:"
153
- puts
154
- puts " macos - macOS Terminal style (red/yellow/green buttons, left side)"
155
- puts " ubuntu - Ubuntu Terminal style (buttons on right side)"
156
- puts " windows - Windows Terminal style (square corners, icons)"
157
- puts
158
- puts "Use: shellfie generate config.yml -o output.png -t THEME_NAME"
159
- end
160
-
161
- def run_validate
162
- input_file = @args.shift
163
- raise ConfigError, "Input file is required" unless input_file
164
-
165
- config = Parser.parse(input_file)
166
- puts "✓ Configuration is valid"
167
- puts " Theme: #{config.theme}"
168
- puts " Title: #{config.title}"
169
- puts " Lines: #{config.lines.size}"
170
- puts " Mode: #{config.animated? ? "animated" : "static"}"
171
- end
172
-
173
- def run_version
174
- puts "shellfie #{VERSION}"
175
- end
176
-
177
- def show_help
178
- puts <<~HELP
179
- Shellfie - Terminal screenshot-style image generator
180
-
181
- Usage: shellfie <command> [options]
182
- shf <command> [options]
183
-
184
- Commands:
185
- generate Generate image from configuration file
186
- init Output sample configuration
187
- themes List available themes
188
- validate Validate configuration file
189
- version Show version
190
- help Show this help
191
-
192
- Generate Options:
193
- -o, --output PATH Output file path (required)
194
- -t, --theme NAME Override theme (macos, ubuntu, windows)
195
- -a, --animate Generate animated GIF
196
- -s, --scale FACTOR Output scale (1, 2, 3)
197
- -w, --width PIXELS Override width
198
- --no-shadow Disable shadow effect
199
- --no-header Disable window header (headless mode)
200
- --transparent Transparent background
201
-
202
- Examples:
203
- shellfie generate config.yml -o terminal.png
204
- shellfie generate config.yml -o demo.gif --animate
205
- shellfie generate config.yml -o retina.png --scale 2
206
- shellfie init > my-config.yml
207
- shellfie themes
208
-
209
- # Short form
210
- shf generate config.yml -o terminal.png
211
- shf init > config.yml
212
- HELP
58
+ def warn_error(message)
59
+ $stderr.puts message
213
60
  end
214
61
 
215
62
  def determine_exit_code(error)
@@ -220,6 +67,8 @@ module Shellfie
220
67
  3
221
68
  when DependencyError
222
69
  4
70
+ when FileSystemError
71
+ 5
223
72
  else
224
73
  1
225
74
  end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+
6
+ module Shellfie
7
+ module CLIGenerate
8
+ ANIMATED_FORMATS = %w[gif webp apng].freeze
9
+ STATIC_FORMATS = %w[png svg webp].freeze
10
+ SUPPORTED_FORMATS = %w[png gif svg webp apng].freeze
11
+
12
+ private
13
+
14
+ def run_generate
15
+ build_generate_parser.parse!(@args)
16
+ input_files = expand_input_paths(@args)
17
+ raise ConfigError, "Input file is required" if input_files.empty?
18
+ raise ConfigError, "Output file is required (use -o option)" unless @options[:output]
19
+ raise ConfigError, "stdout output supports only one input file" if @options[:output] == "-" && input_files.size > 1
20
+ raise ConfigError, "--format is required when writing to stdout" if @options[:output] == "-" && !@options[:format]
21
+ input_files.each do |input_file|
22
+ config = apply_overrides(Parser.parse(input_file))
23
+ animate = animation_output?(config)
24
+ format = output_format_for(@options[:output], animate)
25
+ output_path = output_path_for(input_file, format, multiple: input_files.size > 1)
26
+ validate_output_mode!(format, animate)
27
+ ensure_output_writable!(output_path)
28
+ write_rendered_output(config, output_path, animate: animate, format: format)
29
+ end
30
+ end
31
+
32
+ def build_generate_parser
33
+ OptionParser.new do |opts|
34
+ opts.banner = "Usage: shellfie generate INPUT_FILE [options]"
35
+ opts.on("-o", "--output PATH", "Output file path (required)") { |path| @options[:output] = path }
36
+ opts.on("-t", "--theme NAME", "Override theme (macos, ubuntu, windows)") { |theme| @options[:theme] = theme }
37
+ opts.on("-a", "--animate", "Generate animated GIF") { @options[:animate] = true }
38
+ opts.on("-s", "--scale FACTOR", "Output scale (1, 2, 3)") { |scale| @options[:scale] = parse_scale(scale) }
39
+ opts.on("-w", "--width PIXELS", Integer, "Override width") { |width| @options[:width] = width }
40
+ opts.on("--fps FPS", Integer, "Typing speed override for animations") { |fps| @options[:fps] = parse_fps(fps) }
41
+ opts.on("--overflow MODE", "Line overflow mode (clip, wrap, scroll)") { |mode| @options[:overflow] = mode }
42
+ opts.on("--wrap", "Wrap long lines") { @options[:wrap] = true }
43
+ opts.on("--no-wrap", "Clip long lines") { @options[:wrap] = false }
44
+ opts.on("--exact-size", "Make output canvas match the configured window size") { @options[:exact_size] = true }
45
+ opts.on("--no-shadow", "Disable shadow effect") { @options[:shadow] = false }
46
+ opts.on("--transparent", "Transparent background") { @options[:transparent] = true }
47
+ opts.on("--no-header", "Disable window header (headless mode)") { @options[:headless] = true }
48
+ opts.on("--format FORMAT", "Output format (png, gif, svg, webp, apng)") { |format| @options[:format] = parse_format(format) }
49
+ opts.on("--force", "Overwrite existing output files") { @options[:force] = true }
50
+ opts.on("--quiet", "Suppress non-error output") { @options[:quiet] = true }
51
+ opts.on("--verbose", "Print extra progress information") { @options[:verbose] = true }
52
+ end
53
+ end
54
+
55
+ def write_rendered_output(config, output_path, animate:, format:)
56
+ result = animate ? generate_animation(config, output_path, format) : generate_static_image(config, output_path, format)
57
+ if output_path == "-"
58
+ $stdout.binmode
59
+ $stdout.write(result)
60
+ else
61
+ puts "Generated: #{result}" unless @options[:quiet]
62
+ end
63
+ end
64
+
65
+ def generate_animation(config, output_path, format)
66
+ warn_verbose "Rendering animation to #{output_path}"
67
+ GifGenerator.new(config).generate(
68
+ output_path,
69
+ scale: @options[:scale] || 1,
70
+ shadow: @options[:shadow] != false,
71
+ transparent: @options[:transparent] || false,
72
+ format: format
73
+ )
74
+ end
75
+
76
+ def generate_static_image(config, output_path, format)
77
+ warn_verbose "Rendering image to #{output_path}"
78
+ Renderer.new(config).render(
79
+ output_path,
80
+ scale: @options[:scale] || 1,
81
+ shadow: @options[:shadow] != false,
82
+ transparent: @options[:transparent] || false,
83
+ format: format
84
+ )
85
+ end
86
+
87
+ def apply_overrides(config)
88
+ window_overrides = build_window_overrides
89
+ animation_overrides = build_animation_overrides
90
+ return config if @options.values_at(:theme, :headless).all?(&:nil?) &&
91
+ window_overrides.empty? &&
92
+ animation_overrides.empty?
93
+
94
+ options = config.to_h.merge(
95
+ theme: @options[:theme] || config.theme,
96
+ window: config.window.merge(window_overrides),
97
+ animation: config.animation.merge(animation_overrides),
98
+ lines: config.lines,
99
+ frames: config.frames,
100
+ headless: @options[:headless] || config.headless
101
+ )
102
+ Config.new(options)
103
+ end
104
+
105
+ def build_window_overrides
106
+ {}.tap do |overrides|
107
+ overrides[:width] = @options[:width] if @options[:width]
108
+ overrides[:overflow] = @options[:overflow] if @options[:overflow]
109
+ overrides[:wrap] = @options[:wrap] unless @options[:wrap].nil?
110
+ overrides[:exact_size] = true if @options[:exact_size]
111
+ end
112
+ end
113
+
114
+ def build_animation_overrides
115
+ {}.tap do |overrides|
116
+ overrides[:typing_speed] = (1_000.0 / @options[:fps]).round if @options[:fps]
117
+ end
118
+ end
119
+
120
+ def parse_scale(value)
121
+ scale = Integer(value, exception: false)
122
+ return scale if [1, 2, 3].include?(scale)
123
+ raise ValidationError, "scale must be 1, 2, or 3"
124
+ end
125
+
126
+ def parse_fps(value)
127
+ fps = Integer(value, exception: false)
128
+ return fps if fps && fps.between?(1, 60)
129
+ raise ValidationError, "fps must be between 1 and 60"
130
+ end
131
+
132
+ def parse_format(value)
133
+ format = value.to_s.downcase
134
+ return format if SUPPORTED_FORMATS.include?(format)
135
+ raise ValidationError, "format must be one of: #{SUPPORTED_FORMATS.join(", ")}"
136
+ end
137
+
138
+ def validate_output_mode!(format, animate)
139
+ if animate && ANIMATED_FORMATS.include?(format)
140
+ return
141
+ elsif !animate && STATIC_FORMATS.include?(format)
142
+ return
143
+ end
144
+
145
+ mode = animate ? "animated" : "static"
146
+ raise ConfigError, "#{mode} output does not support .#{format}"
147
+ end
148
+
149
+ def ensure_output_writable!(path)
150
+ return if path == "-"
151
+
152
+ directory = File.dirname(path)
153
+ FileUtils.mkdir_p(directory) unless directory == "." || Dir.exist?(directory)
154
+ return if @options[:force] || !File.exist?(path)
155
+
156
+ raise FileSystemError, "Output file already exists: #{path} (use --force to overwrite)"
157
+ end
158
+
159
+ def expand_input_paths(args)
160
+ args.flat_map do |path|
161
+ next path if path == "-"
162
+
163
+ matches = path.match?(/[*?\[]/) ? Dir.glob(path) : [path]
164
+ matches.sort
165
+ end
166
+ end
167
+
168
+ def animation_output?(config)
169
+ return true if ANIMATED_FORMATS.include?(@options[:format])
170
+
171
+ @options[:animate] || config.animated?
172
+ end
173
+
174
+ def output_format_for(path, animate)
175
+ return @options[:format] if @options[:format]
176
+ return animate ? "gif" : "png" if path == "-" || batch_directory?(path)
177
+
178
+ extension = File.extname(path).delete_prefix(".").downcase
179
+ extension.empty? ? (animate ? "gif" : "png") : extension
180
+ end
181
+
182
+ def output_path_for(input_file, format, multiple:)
183
+ return @options[:output] if @options[:output] == "-"
184
+ return @options[:output] unless multiple || batch_directory?(@options[:output])
185
+
186
+ File.join(@options[:output], "#{File.basename(input_file, File.extname(input_file))}.#{format}")
187
+ end
188
+
189
+ def batch_directory?(path)
190
+ path.end_with?(File::SEPARATOR) || Dir.exist?(path)
191
+ end
192
+
193
+ def warn_verbose(message)
194
+ $stderr.puts message if @options[:verbose] && !@options[:quiet]
195
+ end
196
+ end
197
+ end