shellfie 0.1.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +95 -236
- data/docs/.nojekyll +0 -0
- data/docs/index.html +205 -0
- data/docs/scripts.js +85 -0
- data/docs/styles.css +507 -0
- data/examples/simple.yml +3 -3
- data/lib/shellfie/animation_frame_builder.rb +178 -0
- data/lib/shellfie/animation_scroll_easing.rb +77 -0
- data/lib/shellfie/animation_timeline.rb +27 -0
- data/lib/shellfie/ansi_colors.rb +94 -0
- data/lib/shellfie/ansi_line_buffer.rb +87 -0
- data/lib/shellfie/ansi_normalizer.rb +51 -0
- data/lib/shellfie/ansi_parser.rb +50 -84
- data/lib/shellfie/cli.rb +22 -173
- data/lib/shellfie/cli_generate.rb +197 -0
- data/lib/shellfie/cli_info.rb +139 -0
- data/lib/shellfie/config.rb +108 -25
- data/lib/shellfie/config_defaults.rb +64 -0
- data/lib/shellfie/config_validation.rb +200 -0
- data/lib/shellfie/dependency_checker.rb +76 -0
- data/lib/shellfie/errors.rb +11 -1
- data/lib/shellfie/font_resolver.rb +58 -0
- data/lib/shellfie/format_resolver.rb +15 -0
- data/lib/shellfie/gif_generator.rb +83 -87
- data/lib/shellfie/gif_palette.rb +101 -0
- data/lib/shellfie/headless_theme_registry.rb +42 -0
- data/lib/shellfie/image_magick_command_builder.rb +75 -0
- data/lib/shellfie/line_layout.rb +137 -0
- data/lib/shellfie/output_writer.rb +41 -0
- data/lib/shellfie/parser.rb +113 -23
- data/lib/shellfie/parser_validation.rb +145 -0
- data/lib/shellfie/raster_painter.rb +157 -0
- data/lib/shellfie/render_chrome_cache.rb +40 -0
- data/lib/shellfie/render_geometry.rb +114 -0
- data/lib/shellfie/render_segment.rb +59 -0
- data/lib/shellfie/renderer.rb +79 -149
- data/lib/shellfie/rendering/shape_helpers.rb +42 -0
- data/lib/shellfie/rendering/text_painter.rb +187 -0
- data/lib/shellfie/rendering/window_chrome.rb +196 -0
- data/lib/shellfie/svg_raster_wrapper.rb +35 -0
- data/lib/shellfie/text_metrics.rb +96 -0
- data/lib/shellfie/theme_data.rb +80 -0
- data/lib/shellfie/theme_registry.rb +131 -0
- data/lib/shellfie/themes/base.rb +10 -1
- data/lib/shellfie/themes/configured.rb +61 -0
- data/lib/shellfie/themes/macos.rb +3 -1
- data/lib/shellfie/themes/ubuntu.rb +2 -1
- data/lib/shellfie/themes/windows_terminal.rb +7 -1
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie.rb +37 -3
- metadata +37 -2
data/lib/shellfie/ansi_parser.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
46
|
-
|
|
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
|