shellfie 1.0.0 → 1.1.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 +56 -2
- data/README.md +258 -97
- data/lib/shellfie/animation_frame_builder.rb +31 -7
- data/lib/shellfie/animation_timeline.rb +2 -1
- data/lib/shellfie/ansi_line_buffer.rb +20 -4
- data/lib/shellfie/ansi_normalizer.rb +18 -8
- data/lib/shellfie/ansi_parser.rb +120 -7
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +65 -2
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +244 -40
- data/lib/shellfie/cli_info.rb +106 -6
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +5 -1
- data/lib/shellfie/config_defaults.rb +21 -2
- data/lib/shellfie/config_validation.rb +93 -4
- data/lib/shellfie/dependency_checker.rb +74 -3
- data/lib/shellfie/errors.rb +1 -0
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +11 -1
- data/lib/shellfie/gif_generator.rb +157 -11
- data/lib/shellfie/gif_palette.rb +8 -4
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/line_layout.rb +19 -10
- data/lib/shellfie/output_writer.rb +7 -2
- data/lib/shellfie/parser.rb +82 -19
- data/lib/shellfie/parser_validation.rb +48 -15
- data/lib/shellfie/render_geometry.rb +2 -1
- data/lib/shellfie/render_segment.rb +17 -5
- data/lib/shellfie/renderer.rb +28 -11
- data/lib/shellfie/rendering/text_painter.rb +23 -15
- data/lib/shellfie/rendering/window_chrome.rb +2 -2
- data/lib/shellfie/reproducibility_manifest.rb +41 -0
- data/lib/shellfie/session.rb +111 -0
- data/lib/shellfie/session_config.rb +562 -0
- data/lib/shellfie/session_runner.rb +689 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +74 -15
- data/lib/shellfie/transcript_renderer.rb +92 -0
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie/yaml_safety.rb +147 -0
- data/lib/shellfie.rb +8 -1
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +27 -24
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/docs/.nojekyll +0 -0
- data/docs/index.html +0 -205
- data/docs/scripts.js +0 -85
- data/docs/styles.css +0 -507
- data/examples/animation.yml +0 -33
- data/examples/colored.yml +0 -20
- data/examples/demo.gif +0 -0
- data/examples/demo.png +0 -0
- data/examples/demo_animation.yml +0 -31
- data/examples/headless.png +0 -0
- data/examples/headless.yml +0 -16
- data/examples/scrolling.yml +0 -48
- data/examples/simple.yml +0 -21
- data/examples/theme_macos.png +0 -0
- data/examples/theme_ubuntu.png +0 -0
- data/examples/theme_windows.png +0 -0
- data/shellfie.gemspec +0 -32
data/lib/shellfie/parser.rb
CHANGED
|
@@ -4,32 +4,53 @@ require "yaml"
|
|
|
4
4
|
require_relative "config"
|
|
5
5
|
require_relative "errors"
|
|
6
6
|
require_relative "parser_validation"
|
|
7
|
+
require_relative "yaml_safety"
|
|
7
8
|
|
|
8
9
|
module Shellfie
|
|
9
10
|
class Parser
|
|
11
|
+
MAX_INCLUDE_BYTES = 1_048_576
|
|
12
|
+
MAX_INCLUDE_DEPTH = 50
|
|
13
|
+
MAX_INCLUDE_FILES = 100
|
|
14
|
+
MAX_TOTAL_INCLUDE_BYTES = 10 * MAX_INCLUDE_BYTES
|
|
15
|
+
|
|
10
16
|
class << self
|
|
11
17
|
include ParserValidation
|
|
12
18
|
|
|
13
19
|
def parse(path)
|
|
14
|
-
return parse_string($stdin.read, base_dir: Dir.pwd) if path == "-"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
parse_string(content, base_dir: File.dirname(
|
|
20
|
+
return parse_string($stdin.read(MAX_INCLUDE_BYTES + 1), base_dir: Dir.pwd) if path == "-"
|
|
21
|
+
source_path = File.realpath(path)
|
|
22
|
+
content = read_config(source_path)
|
|
23
|
+
state = { files: 1, bytes: content.bytesize, cache: {}, sources: {} }
|
|
24
|
+
parse_string(content, base_dir: File.dirname(source_path), include_stack: [source_path], source_name: source_path,
|
|
25
|
+
include_state: state)
|
|
26
|
+
rescue Errno::ENOENT
|
|
27
|
+
raise ParseError, "Configuration file not found: #{path}"
|
|
19
28
|
end
|
|
20
29
|
|
|
21
|
-
def parse_string(content, base_dir: nil)
|
|
30
|
+
def parse_string(content, base_dir: nil, include_stack: [], source_name: nil, include_state: nil)
|
|
31
|
+
raise ParseError, "Configuration is too large (max #{MAX_INCLUDE_BYTES} bytes)" if content.bytesize > MAX_INCLUDE_BYTES
|
|
32
|
+
|
|
22
33
|
raw = YAML.safe_load(content, symbolize_names: true, aliases: true)
|
|
23
|
-
|
|
34
|
+
YamlSafety.validate_tree!(raw)
|
|
35
|
+
if base_dir
|
|
36
|
+
include_state ||= { files: 1, bytes: content.bytesize, cache: {}, sources: {} }
|
|
37
|
+
raw = apply_includes(raw, base_dir, stack: include_stack, root: base_dir, state: include_state)
|
|
38
|
+
end
|
|
24
39
|
validate_config(raw)
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
sources = (include_stack + Array(include_state&.dig(:cache)&.keys)).uniq
|
|
41
|
+
build_config(raw, source_paths: sources)
|
|
42
|
+
rescue Psych::Exception => e
|
|
27
43
|
raise ParseError, "Invalid YAML syntax: #{e.message}"
|
|
44
|
+
rescue ValidationError => e
|
|
45
|
+
raise e unless source_name
|
|
46
|
+
|
|
47
|
+
documents = [[source_name, content]] + include_state.fetch(:sources, {}).to_a.reverse
|
|
48
|
+
raise YamlSafety.annotate_validation_error(e, documents)
|
|
28
49
|
end
|
|
29
50
|
|
|
30
51
|
private
|
|
31
52
|
|
|
32
|
-
def build_config(raw)
|
|
53
|
+
def build_config(raw, source_paths: [])
|
|
33
54
|
options = {
|
|
34
55
|
version: raw[:version],
|
|
35
56
|
theme: raw[:theme],
|
|
@@ -45,29 +66,68 @@ module Shellfie
|
|
|
45
66
|
cursor: symbolize_hash(raw[:cursor]),
|
|
46
67
|
limits: symbolize_hash(raw[:limits]),
|
|
47
68
|
frames: parse_frames(raw[:frames]),
|
|
48
|
-
headless: raw[:headless] || false
|
|
69
|
+
headless: raw[:headless] || false,
|
|
70
|
+
source_paths: source_paths
|
|
49
71
|
}.compact
|
|
50
72
|
|
|
51
73
|
Config.new(options)
|
|
52
74
|
end
|
|
53
75
|
|
|
54
|
-
def apply_includes(raw, base_dir, depth: 0)
|
|
76
|
+
def apply_includes(raw, base_dir, stack: [], root: base_dir, policy: nil, state:, depth: 0)
|
|
55
77
|
return raw unless raw.is_a?(Hash) && raw[:include]
|
|
56
|
-
raise ParseError, "YAML include depth
|
|
78
|
+
raise ParseError, "YAML include depth exceeds #{MAX_INCLUDE_DEPTH}" if depth >= MAX_INCLUDE_DEPTH
|
|
57
79
|
|
|
80
|
+
policy ||= raw[:include_policy] || "allow"
|
|
81
|
+
raise ParseError, "include_policy must be allow or root" unless %w[allow root].include?(policy)
|
|
58
82
|
includes = Array(raw[:include])
|
|
59
83
|
included_config = includes.reduce({}) do |merged, include_path|
|
|
84
|
+
raise ParseError, "Included configuration path must be a string" unless include_path.is_a?(String)
|
|
85
|
+
|
|
60
86
|
include_file = File.expand_path(include_path, base_dir)
|
|
61
87
|
raise ParseError, "Included configuration file not found: #{include_path}" unless File.exist?(include_file)
|
|
62
88
|
|
|
63
|
-
|
|
64
|
-
|
|
89
|
+
include_file = File.realpath(include_file)
|
|
90
|
+
if policy == "root" && include_file != root && !include_file.start_with?("#{root}#{File::SEPARATOR}")
|
|
91
|
+
raise ParseError, "Included configuration escapes the configuration root: #{include_path}"
|
|
92
|
+
end
|
|
93
|
+
if stack.include?(include_file)
|
|
94
|
+
chain = (stack + [include_file]).map { |path| File.basename(path) }.join(" -> ")
|
|
95
|
+
raise ParseError, "Circular YAML include: #{chain}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
state[:files] += 1
|
|
99
|
+
raise ParseError, "Too many YAML includes (max #{MAX_INCLUDE_FILES})" if state[:files] > MAX_INCLUDE_FILES
|
|
100
|
+
included_raw = state[:cache][include_file]
|
|
101
|
+
unless included_raw
|
|
102
|
+
included_content = read_config(include_file)
|
|
103
|
+
state[:sources][include_file] = included_content
|
|
104
|
+
state[:bytes] += included_content.bytesize
|
|
105
|
+
if state[:bytes] > MAX_TOTAL_INCLUDE_BYTES
|
|
106
|
+
raise ParseError, "Included YAML is too large in total (max #{MAX_TOTAL_INCLUDE_BYTES} bytes)"
|
|
107
|
+
end
|
|
108
|
+
included_raw = YAML.safe_load(included_content, symbolize_names: true, aliases: true)
|
|
109
|
+
YamlSafety.validate_tree!(included_raw)
|
|
110
|
+
state[:cache][include_file] = included_raw
|
|
111
|
+
end
|
|
112
|
+
included_raw = apply_includes(
|
|
113
|
+
included_raw,
|
|
114
|
+
File.dirname(include_file),
|
|
115
|
+
stack: stack + [include_file],
|
|
116
|
+
root: root,
|
|
117
|
+
policy: policy,
|
|
118
|
+
state: state,
|
|
119
|
+
depth: depth + 1
|
|
120
|
+
)
|
|
65
121
|
deep_merge(merged, included_raw || {})
|
|
66
122
|
end
|
|
67
123
|
|
|
68
124
|
deep_merge(included_config, raw.reject { |key, _value| key == :include })
|
|
69
125
|
end
|
|
70
126
|
|
|
127
|
+
def read_config(path)
|
|
128
|
+
YamlSafety.read_file(path, max_bytes: MAX_INCLUDE_BYTES)
|
|
129
|
+
end
|
|
130
|
+
|
|
71
131
|
def deep_merge(base, overrides)
|
|
72
132
|
base.merge(overrides) do |_key, left, right|
|
|
73
133
|
left.is_a?(Hash) && right.is_a?(Hash) ? deep_merge(left, right) : right
|
|
@@ -104,6 +164,7 @@ module Shellfie
|
|
|
104
164
|
prompt: frame[:prompt],
|
|
105
165
|
type: frame[:type],
|
|
106
166
|
output: frame[:output],
|
|
167
|
+
screen: frame[:screen],
|
|
107
168
|
delay: frame[:delay] || 0,
|
|
108
169
|
prompt_color: frame[:prompt_color],
|
|
109
170
|
command_color: frame[:command_color],
|
|
@@ -147,10 +208,10 @@ module Shellfie
|
|
|
147
208
|
end
|
|
148
209
|
|
|
149
210
|
class Frame
|
|
150
|
-
attr_reader :prompt, :type, :output, :delay, :prompt_color, :command_color, :output_color
|
|
211
|
+
attr_reader :prompt, :type, :output, :delay, :prompt_color, :command_color, :output_color, :screen
|
|
151
212
|
|
|
152
213
|
def initialize(prompt: nil, type: nil, output: nil, delay: 0, prompt_color: nil, command_color: nil,
|
|
153
|
-
output_color: nil)
|
|
214
|
+
output_color: nil, screen: nil)
|
|
154
215
|
@prompt = prompt
|
|
155
216
|
@type = type
|
|
156
217
|
@output = output
|
|
@@ -158,6 +219,7 @@ module Shellfie
|
|
|
158
219
|
@prompt_color = prompt_color
|
|
159
220
|
@command_color = command_color
|
|
160
221
|
@output_color = output_color
|
|
222
|
+
@screen = screen
|
|
161
223
|
freeze
|
|
162
224
|
end
|
|
163
225
|
|
|
@@ -169,12 +231,13 @@ module Shellfie
|
|
|
169
231
|
delay: delay,
|
|
170
232
|
prompt_color: prompt_color,
|
|
171
233
|
command_color: command_color,
|
|
172
|
-
output_color: output_color
|
|
234
|
+
output_color: output_color,
|
|
235
|
+
screen: screen
|
|
173
236
|
}.compact
|
|
174
237
|
end
|
|
175
238
|
|
|
176
239
|
def to_s
|
|
177
|
-
[prompt, type, output, delay].compact.join("\n")
|
|
240
|
+
[prompt, type, output, screen, delay].compact.join("\n")
|
|
178
241
|
end
|
|
179
242
|
end
|
|
180
243
|
end
|
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
3
5
|
module Shellfie
|
|
4
6
|
module ParserValidation
|
|
7
|
+
MAX_FRAME_DELAY_MS = 86_400_000
|
|
5
8
|
TOP_LEVEL_KEYS = %i[
|
|
6
|
-
version include theme window_theme color_scheme colors window_decoration title window font animation cursor lines frames
|
|
9
|
+
version include include_policy theme window_theme color_scheme colors window_decoration title window font animation cursor lines frames
|
|
7
10
|
headless limits
|
|
8
11
|
].freeze
|
|
9
12
|
WINDOW_KEYS = %i[
|
|
10
|
-
width padding opacity visible_lines max_lines max_height wrap overflow margin exact_size trim tab_width
|
|
11
|
-
ansi_state background_gradient scroll_offset
|
|
13
|
+
width height padding opacity visible_lines max_lines max_height wrap overflow margin exact_size trim tab_width
|
|
14
|
+
ambiguous_width osc_policy graphics_policy ansi_state background_gradient scroll_offset
|
|
12
15
|
].freeze
|
|
13
16
|
FONT_KEYS = %i[family size line_height fallback_family italic_family emoji_family].freeze
|
|
14
17
|
ANIMATION_KEYS = %i[
|
|
15
|
-
typing_speed command_delay cursor_blink loop typing_jitter typing_chunk_size output_delay final_delay max_frames
|
|
16
|
-
dither palette
|
|
18
|
+
typing_speed command_delay cursor_blink loop typing_jitter seed typing_chunk_size output_delay final_delay max_frames
|
|
19
|
+
dither palette gif_colors gif_optimize webp_lossless webp_quality webp_method webp_near_lossless
|
|
20
|
+
apng_prediction loop_count scroll_easing
|
|
21
|
+
direction loop_offset
|
|
22
|
+
framerate playback_speed
|
|
17
23
|
].freeze
|
|
18
24
|
CURSOR_KEYS = %i[style color].freeze
|
|
19
|
-
LIMIT_KEYS = %i[max_lines max_frames max_render_frames max_characters max_pixels].freeze
|
|
25
|
+
LIMIT_KEYS = %i[max_lines max_frames max_render_frames max_characters max_pixels max_total_pixels max_temp_bytes].freeze
|
|
20
26
|
LINE_KEYS = %i[prompt command output prompt_color command_color output_color selected].freeze
|
|
21
|
-
FRAME_KEYS = %i[prompt type output delay prompt_color command_color output_color].freeze
|
|
27
|
+
FRAME_KEYS = %i[prompt type output screen delay prompt_color command_color output_color].freeze
|
|
28
|
+
COLOR_KEYS = %i[
|
|
29
|
+
background foreground title_bar title_text title_bar_border border selection black red green yellow blue magenta cyan
|
|
30
|
+
white bright_black bright_red bright_green bright_yellow bright_blue bright_magenta bright_cyan bright_white
|
|
31
|
+
].freeze
|
|
32
|
+
WINDOW_DECORATION_KEYS = %i[
|
|
33
|
+
title_bar_height button_size button_spacing button_width corner_radius shadow
|
|
34
|
+
].freeze
|
|
35
|
+
SHADOW_KEYS = %i[blur offset_x offset_y color].freeze
|
|
22
36
|
|
|
23
37
|
private
|
|
24
38
|
|
|
@@ -32,11 +46,17 @@ module Shellfie
|
|
|
32
46
|
validate_nested_hash!(raw, :animation, ANIMATION_KEYS)
|
|
33
47
|
validate_nested_hash!(raw, :cursor, CURSOR_KEYS)
|
|
34
48
|
validate_nested_hash!(raw, :limits, LIMIT_KEYS)
|
|
35
|
-
validate_nested_hash!(raw, :colors,
|
|
36
|
-
validate_nested_hash!(raw, :window_decoration,
|
|
49
|
+
validate_nested_hash!(raw, :colors, COLOR_KEYS)
|
|
50
|
+
validate_nested_hash!(raw, :window_decoration, WINDOW_DECORATION_KEYS)
|
|
51
|
+
if raw.dig(:window_decoration, :shadow)
|
|
52
|
+
validate_nested_hash!(raw[:window_decoration], :shadow, SHADOW_KEYS, "window_decoration.shadow")
|
|
53
|
+
end
|
|
37
54
|
validate_theme!(raw[:theme]) if raw[:theme]
|
|
38
55
|
validate_window_theme!(raw[:window_theme]) if raw[:window_theme]
|
|
39
56
|
validate_color_scheme!(raw[:color_scheme]) if raw.key?(:color_scheme)
|
|
57
|
+
if raw[:include_policy] && !%w[allow root].include?(raw[:include_policy])
|
|
58
|
+
raise ValidationError, "include_policy must be allow or root"
|
|
59
|
+
end
|
|
40
60
|
|
|
41
61
|
raise ValidationError, "Configuration must have either 'lines' or 'frames'" if raw[:lines].nil? && raw[:frames].nil?
|
|
42
62
|
|
|
@@ -62,18 +82,23 @@ module Shellfie
|
|
|
62
82
|
raise ValidationError, "Invalid color_scheme '#{scheme}'"
|
|
63
83
|
end
|
|
64
84
|
|
|
65
|
-
def validate_nested_hash!(raw, key, allowed_keys)
|
|
85
|
+
def validate_nested_hash!(raw, key, allowed_keys, context = key.to_s)
|
|
66
86
|
return unless raw.key?(key)
|
|
67
87
|
raise ValidationError, "#{key} must be a mapping" unless raw[key].is_a?(Hash)
|
|
68
88
|
|
|
69
|
-
validate_keys!(raw[key], allowed_keys,
|
|
89
|
+
validate_keys!(raw[key], allowed_keys, context) if allowed_keys
|
|
70
90
|
end
|
|
71
91
|
|
|
72
92
|
def validate_keys!(hash, allowed_keys, context)
|
|
73
93
|
unknown_keys = hash.keys - allowed_keys
|
|
74
94
|
return if unknown_keys.empty?
|
|
75
95
|
|
|
76
|
-
|
|
96
|
+
suggestions = unknown_keys.filter_map do |key|
|
|
97
|
+
match = DidYouMean::SpellChecker.new(dictionary: allowed_keys.map(&:to_s)).correct(key.to_s).first
|
|
98
|
+
"#{key} -> #{match}" if match
|
|
99
|
+
end
|
|
100
|
+
hint = suggestions.empty? ? "" : " (did you mean #{suggestions.join(", ")}?)"
|
|
101
|
+
raise ValidationError, "Unknown #{context} key(s): #{unknown_keys.join(", ")}#{hint}"
|
|
77
102
|
end
|
|
78
103
|
|
|
79
104
|
def validate_lines!(lines)
|
|
@@ -111,17 +136,25 @@ module Shellfie
|
|
|
111
136
|
def validate_frame_shape!(frame, index)
|
|
112
137
|
raise ValidationError, "frames[#{index}].prompt requires type" if frame[:prompt] && frame[:type].nil?
|
|
113
138
|
|
|
114
|
-
if frame.values_at(:type, :output, :delay).all?(&:nil?)
|
|
115
|
-
raise ValidationError, "frames[#{index}] must include type, output, or delay"
|
|
139
|
+
if frame.values_at(:type, :output, :screen, :delay).all?(&:nil?)
|
|
140
|
+
raise ValidationError, "frames[#{index}] must include type, output, screen, or delay"
|
|
116
141
|
end
|
|
117
142
|
|
|
118
143
|
validate_string_value!(frame[:prompt], "frames[#{index}].prompt") if frame.key?(:prompt)
|
|
119
144
|
validate_string_value!(frame[:type], "frames[#{index}].type") if frame.key?(:type)
|
|
120
145
|
validate_string_value!(frame[:output], "frames[#{index}].output") if frame.key?(:output)
|
|
146
|
+
if frame.key?(:screen) && (!frame[:screen].is_a?(Array) || !frame[:screen].all?(String))
|
|
147
|
+
raise ValidationError, "frames[#{index}].screen must be an array of strings"
|
|
148
|
+
end
|
|
121
149
|
validate_string_value!(frame[:prompt_color], "frames[#{index}].prompt_color") if frame.key?(:prompt_color)
|
|
122
150
|
validate_string_value!(frame[:command_color], "frames[#{index}].command_color") if frame.key?(:command_color)
|
|
123
151
|
validate_string_value!(frame[:output_color], "frames[#{index}].output_color") if frame.key?(:output_color)
|
|
124
|
-
|
|
152
|
+
if frame.key?(:delay)
|
|
153
|
+
validate_non_negative_integer!(frame[:delay], "frames[#{index}].delay")
|
|
154
|
+
if frame[:delay] > MAX_FRAME_DELAY_MS
|
|
155
|
+
raise ValidationError, "frames[#{index}].delay must be at most #{MAX_FRAME_DELAY_MS}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
125
158
|
end
|
|
126
159
|
|
|
127
160
|
def validate_string_value!(value, name)
|
|
@@ -13,7 +13,7 @@ module Shellfie
|
|
|
13
13
|
font_config = @theme.font
|
|
14
14
|
line_height = font_config[:size] * font_config[:line_height]
|
|
15
15
|
display_lines, visible_count = display_lines(lines, font_config, line_height)
|
|
16
|
-
total_height = title_bar_height + [visible_count, 1].max * line_height + padding * 2
|
|
16
|
+
total_height = @config.window[:height] || title_bar_height + [visible_count, 1].max * line_height + padding * 2
|
|
17
17
|
margin = canvas_margin(scale, shadow && !exact_size?)
|
|
18
18
|
geometry = geometry_hash(
|
|
19
19
|
display_lines,
|
|
@@ -66,6 +66,7 @@ module Shellfie
|
|
|
66
66
|
scaled_title_bar: (title_bar_height * scale).to_i,
|
|
67
67
|
scaled_radius: (corner_radius * scale).to_i,
|
|
68
68
|
scroll_offset: @config.window[:scroll_offset].to_f,
|
|
69
|
+
ambiguous_width: @config.window[:ambiguous_width],
|
|
69
70
|
margin: margin,
|
|
70
71
|
canvas_width: (width * scale).to_i + margin * 2,
|
|
71
72
|
canvas_height: (total_height * scale).ceil + margin * 2,
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
module Shellfie
|
|
4
4
|
class RenderSegment
|
|
5
5
|
ATTRIBUTES = %i[
|
|
6
|
-
foreground background bold italic underline dim reverse strikethrough overline
|
|
6
|
+
foreground background bold italic underline underline_style underline_color dim reverse strikethrough overline blink conceal link
|
|
7
7
|
].freeze
|
|
8
8
|
|
|
9
|
-
attr_reader :text, :foreground, :background, :bold, :italic, :underline, :
|
|
9
|
+
attr_reader :text, :foreground, :background, :bold, :italic, :underline, :underline_style, :underline_color,
|
|
10
|
+
:dim, :reverse, :strikethrough, :overline, :blink, :conceal, :link
|
|
10
11
|
|
|
11
12
|
def self.from_segment(segment, default_color:)
|
|
12
13
|
new(
|
|
@@ -16,10 +17,15 @@ module Shellfie
|
|
|
16
17
|
bold: segment.bold,
|
|
17
18
|
italic: segment.italic,
|
|
18
19
|
underline: segment.underline,
|
|
20
|
+
underline_style: segment.underline_style,
|
|
21
|
+
underline_color: segment.underline_color,
|
|
19
22
|
dim: segment.dim,
|
|
20
23
|
reverse: segment.reverse,
|
|
21
24
|
strikethrough: segment.strikethrough,
|
|
22
|
-
overline: segment.overline
|
|
25
|
+
overline: segment.overline,
|
|
26
|
+
blink: segment.blink,
|
|
27
|
+
conceal: segment.conceal,
|
|
28
|
+
link: segment.link
|
|
23
29
|
)
|
|
24
30
|
end
|
|
25
31
|
|
|
@@ -37,18 +43,24 @@ module Shellfie
|
|
|
37
43
|
end
|
|
38
44
|
end
|
|
39
45
|
|
|
40
|
-
def initialize(text:, foreground: nil, background: nil, bold: false, italic: false, underline: false,
|
|
41
|
-
|
|
46
|
+
def initialize(text:, foreground: nil, background: nil, bold: false, italic: false, underline: false,
|
|
47
|
+
underline_style: nil, underline_color: nil, dim: false, reverse: false, strikethrough: false,
|
|
48
|
+
overline: false, blink: false, conceal: false, link: nil)
|
|
42
49
|
@text = text
|
|
43
50
|
@foreground = foreground
|
|
44
51
|
@background = background
|
|
45
52
|
@bold = bold
|
|
46
53
|
@italic = italic
|
|
47
54
|
@underline = underline
|
|
55
|
+
@underline_style = underline_style
|
|
56
|
+
@underline_color = underline_color
|
|
48
57
|
@dim = dim
|
|
49
58
|
@reverse = reverse
|
|
50
59
|
@strikethrough = strikethrough
|
|
51
60
|
@overline = overline
|
|
61
|
+
@blink = blink
|
|
62
|
+
@conceal = conceal
|
|
63
|
+
@link = link
|
|
52
64
|
freeze
|
|
53
65
|
end
|
|
54
66
|
|
data/lib/shellfie/renderer.rb
CHANGED
|
@@ -5,12 +5,14 @@ require_relative "ansi_parser"
|
|
|
5
5
|
require_relative "dependency_checker"
|
|
6
6
|
require_relative "font_resolver"
|
|
7
7
|
require_relative "format_resolver"
|
|
8
|
+
require_relative "html_renderer"
|
|
8
9
|
require_relative "output_writer"
|
|
9
10
|
require_relative "raster_painter"
|
|
10
11
|
require_relative "render_chrome_cache"
|
|
11
12
|
require_relative "render_geometry"
|
|
12
13
|
require_relative "render_segment"
|
|
13
14
|
require_relative "svg_raster_wrapper"
|
|
15
|
+
require_relative "svg_renderer"
|
|
14
16
|
require_relative "theme_registry"
|
|
15
17
|
|
|
16
18
|
module Shellfie
|
|
@@ -21,16 +23,21 @@ module Shellfie
|
|
|
21
23
|
@config = config
|
|
22
24
|
@chrome_cache = chrome_cache
|
|
23
25
|
@theme = ThemeRegistry.build(config)
|
|
24
|
-
@ansi_parser = AnsiParser.new(
|
|
26
|
+
@ansi_parser = AnsiParser.new(
|
|
27
|
+
state_mode: config.window[:ansi_state] || :persistent,
|
|
28
|
+
tab_width: config.window[:tab_width],
|
|
29
|
+
osc_policy: config.window[:osc_policy],
|
|
30
|
+
graphics_policy: config.window[:graphics_policy]
|
|
31
|
+
)
|
|
25
32
|
@font_resolver = FontResolver.new(-> { imagemagick_command })
|
|
26
33
|
end
|
|
27
34
|
|
|
28
|
-
def render(output_path, scale: 1, shadow: true, transparent: false, format: nil)
|
|
29
|
-
check_dependencies!
|
|
30
|
-
lines = build_lines
|
|
35
|
+
def render(output_path, scale: 1, shadow: true, transparent: false, format: nil, io: nil)
|
|
31
36
|
extension = FormatResolver.resolve(output_path, explicit: format, default: "png")
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
check_dependencies! unless %w[svg html].include?(extension)
|
|
38
|
+
lines = build_lines
|
|
39
|
+
OutputWriter.write(output_path, extension: extension, io: io) do |temporary_path|
|
|
40
|
+
render_method = { "svg" => :create_svg_image, "svg-raster" => :create_svg_raster_image, "html" => :create_html }.fetch(extension, :create_image)
|
|
34
41
|
send(render_method, lines, temporary_path, scale: scale, shadow: shadow, transparent: transparent)
|
|
35
42
|
end
|
|
36
43
|
rescue MiniMagick::Error => e
|
|
@@ -42,6 +49,10 @@ module Shellfie
|
|
|
42
49
|
geometry.slice(:canvas_width, :canvas_height, :scaled_width, :scaled_height, :logical_width, :logical_height, :scale)
|
|
43
50
|
end
|
|
44
51
|
|
|
52
|
+
def font_info
|
|
53
|
+
font_resolver.details(theme.font)
|
|
54
|
+
end
|
|
55
|
+
|
|
45
56
|
private
|
|
46
57
|
|
|
47
58
|
def check_dependencies!
|
|
@@ -76,7 +87,7 @@ module Shellfie
|
|
|
76
87
|
end
|
|
77
88
|
|
|
78
89
|
def parse_with_default(text, default_color)
|
|
79
|
-
@ansi_parser.parse(
|
|
90
|
+
@ansi_parser.parse(text).map do |segment|
|
|
80
91
|
RenderSegment.from_segment(segment, default_color: default_color)
|
|
81
92
|
end
|
|
82
93
|
end
|
|
@@ -85,10 +96,6 @@ module Shellfie
|
|
|
85
96
|
RenderSegment.coalesce(segments)
|
|
86
97
|
end
|
|
87
98
|
|
|
88
|
-
def expand_tabs(text)
|
|
89
|
-
text.to_s.gsub("\t", " " * config.window[:tab_width])
|
|
90
|
-
end
|
|
91
|
-
|
|
92
99
|
def create_image(lines, output_path, scale:, shadow:, transparent:)
|
|
93
100
|
geometry = build_geometry(lines, scale: scale, shadow: shadow)
|
|
94
101
|
|
|
@@ -96,9 +103,19 @@ module Shellfie
|
|
|
96
103
|
end
|
|
97
104
|
|
|
98
105
|
def create_svg_image(lines, output_path, scale:, shadow:, transparent:)
|
|
106
|
+
geometry = build_geometry(lines, scale: scale, shadow: shadow)
|
|
107
|
+
SvgRenderer.new(config: config, theme: theme).render(geometry, output_path, transparent: transparent)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def create_svg_raster_image(lines, output_path, scale:, shadow:, transparent:)
|
|
99
111
|
SvgRasterWrapper.write(output_path) { |png_path| create_image(lines, png_path, scale: scale, shadow: shadow, transparent: transparent) }
|
|
100
112
|
end
|
|
101
113
|
|
|
114
|
+
def create_html(lines, output_path, scale:, shadow:, transparent:)
|
|
115
|
+
geometry = build_geometry(lines, scale: scale, shadow: shadow)
|
|
116
|
+
HtmlRenderer.new(config: config, theme: theme).render(geometry, output_path, transparent: transparent)
|
|
117
|
+
end
|
|
118
|
+
|
|
102
119
|
def build_geometry(lines, scale:, shadow:)
|
|
103
120
|
geometry_builder.build(lines, scale: scale, shadow: shadow)
|
|
104
121
|
end
|
|
@@ -31,7 +31,7 @@ module Shellfie
|
|
|
31
31
|
text = segment.text.to_s
|
|
32
32
|
next if text.empty?
|
|
33
33
|
|
|
34
|
-
width = TextMetrics.pixel_width(text, geometry[:scaled_font_size])
|
|
34
|
+
width = TextMetrics.pixel_width(text, geometry[:scaled_font_size], ambiguous_width: geometry[:ambiguous_width])
|
|
35
35
|
top = baseline - geometry[:scaled_font_size]
|
|
36
36
|
foreground, background = segment_colors(segment)
|
|
37
37
|
result << {
|
|
@@ -63,17 +63,19 @@ module Shellfie
|
|
|
63
63
|
|
|
64
64
|
def draw_positioned_segments(convert, positioned_segments, geometry)
|
|
65
65
|
positioned_segments.each do |item|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
66
|
+
unless item[:segment].conceal
|
|
67
|
+
draw_text(
|
|
68
|
+
convert,
|
|
69
|
+
item[:text],
|
|
70
|
+
item[:x],
|
|
71
|
+
item[:top],
|
|
72
|
+
item[:foreground],
|
|
73
|
+
geometry[:scaled_font_size],
|
|
74
|
+
geometry[:font_config],
|
|
75
|
+
bold: item[:segment].bold,
|
|
76
|
+
italic: item[:segment].italic
|
|
77
|
+
)
|
|
78
|
+
end
|
|
77
79
|
draw_text_decoration(
|
|
78
80
|
convert,
|
|
79
81
|
item[:segment],
|
|
@@ -110,15 +112,20 @@ module Shellfie
|
|
|
110
112
|
end
|
|
111
113
|
|
|
112
114
|
def draw_text_decoration(convert, segment, x, width, baseline, geometry)
|
|
115
|
+
return if segment.conceal
|
|
113
116
|
return unless segment.underline || segment.strikethrough || segment.overline
|
|
114
117
|
|
|
115
118
|
line_width = [(geometry[:scaled_font_size] / 12.0).ceil, 1].max
|
|
116
|
-
|
|
119
|
+
decoration_color = segment.underline_color ? theme.color_for(segment.underline_color) : segment_colors(segment).first
|
|
120
|
+
convert.stroke decoration_color
|
|
117
121
|
convert.strokewidth line_width
|
|
122
|
+
convert.stroke_dasharray "#{line_width},#{line_width * 2}" if segment.underline_style == :dotted
|
|
123
|
+
convert.stroke_dasharray "#{line_width * 3},#{line_width * 2}" if segment.underline_style == :dashed
|
|
118
124
|
|
|
119
125
|
if segment.underline
|
|
120
126
|
y = baseline + (geometry[:scaled_font_size] * 0.12).ceil
|
|
121
127
|
ImageMagickCommandBuilder.line(convert, x, y, x + width, y)
|
|
128
|
+
ImageMagickCommandBuilder.line(convert, x, y + line_width * 2, x + width, y + line_width * 2) if segment.underline_style == :double
|
|
122
129
|
end
|
|
123
130
|
if segment.strikethrough
|
|
124
131
|
y = baseline - (geometry[:scaled_font_size] * 0.35).ceil
|
|
@@ -130,6 +137,7 @@ module Shellfie
|
|
|
130
137
|
end
|
|
131
138
|
|
|
132
139
|
convert.stroke "none"
|
|
140
|
+
convert.stroke_dasharray "none" if %i[dotted dashed].include?(segment.underline_style)
|
|
133
141
|
end
|
|
134
142
|
|
|
135
143
|
def draw_selected_backgrounds(convert, geometry, content_y)
|
|
@@ -158,10 +166,10 @@ module Shellfie
|
|
|
158
166
|
|
|
159
167
|
def fit_text(text, max_width, font_size)
|
|
160
168
|
return "" if max_width <= 0
|
|
161
|
-
return text if TextMetrics.pixel_width(text, font_size) <= max_width
|
|
169
|
+
return text if TextMetrics.pixel_width(text, font_size, ambiguous_width: config.window[:ambiguous_width]) <= max_width
|
|
162
170
|
|
|
163
171
|
max_cells = [(max_width / (font_size * 0.6)).floor - 3, 0].max
|
|
164
|
-
"#{TextMetrics.take_cells(text, max_cells)}..."
|
|
172
|
+
"#{TextMetrics.take_cells(text, max_cells, ambiguous_width: config.window[:ambiguous_width])}..."
|
|
165
173
|
end
|
|
166
174
|
|
|
167
175
|
def color_with_opacity(color, opacity, allow_rgba)
|
|
@@ -159,12 +159,12 @@ module Shellfie
|
|
|
159
159
|
reserve_right = theme.buttons_position == :right ? group_width + (12 * geometry[:scale]).to_i : (12 * geometry[:scale]).to_i
|
|
160
160
|
available_width = geometry[:scaled_width] - reserve_left - reserve_right
|
|
161
161
|
title = fit_text(config.title.to_s, available_width, scaled_font_size)
|
|
162
|
-
title_width = TextMetrics.pixel_width(title, scaled_font_size)
|
|
162
|
+
title_width = TextMetrics.pixel_width(title, scaled_font_size, ambiguous_width: config.window[:ambiguous_width])
|
|
163
163
|
min_x = geometry[:margin] + reserve_left
|
|
164
164
|
max_x = geometry[:margin] + geometry[:scaled_width] - reserve_right - title_width
|
|
165
165
|
centered_x = geometry[:margin] + (geometry[:scaled_width] - title_width) / 2
|
|
166
166
|
x = title_x(min_x, max_x, centered_x)
|
|
167
|
-
y = geometry[:margin] + geometry[:scaled_title_bar] / 2 + scaled_font_size
|
|
167
|
+
y = geometry[:margin] + geometry[:scaled_title_bar] / 2 + (scaled_font_size * 0.6).round
|
|
168
168
|
|
|
169
169
|
draw_text(convert, title, x, y - scaled_font_size, theme.colors[:title_text], scaled_font_size, geometry[:font_config])
|
|
170
170
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
|
|
7
|
+
module Shellfie
|
|
8
|
+
class ReproducibilityManifest
|
|
9
|
+
def self.build(config, output_path:, format:)
|
|
10
|
+
renderer = Renderer.new(config)
|
|
11
|
+
{
|
|
12
|
+
schema: 1,
|
|
13
|
+
config_sha256: Digest::SHA256.hexdigest(JSON.generate(config.to_h)),
|
|
14
|
+
output: output_path,
|
|
15
|
+
output_sha256: output_digest(output_path),
|
|
16
|
+
format: format,
|
|
17
|
+
ruby: RUBY_DESCRIPTION,
|
|
18
|
+
platform: RbConfig::CONFIG["host_os"],
|
|
19
|
+
unicode: {
|
|
20
|
+
version: TextMetrics::UNICODE_VERSION,
|
|
21
|
+
width_table: TextMetrics::WIDTH_TABLE_VERSION,
|
|
22
|
+
ambiguous_width: config.window[:ambiguous_width]
|
|
23
|
+
},
|
|
24
|
+
imagemagick: DependencyChecker.imagemagick_details[:version],
|
|
25
|
+
ffmpeg: DependencyChecker.ffmpeg_version,
|
|
26
|
+
fonts: renderer.font_info
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.output_digest(path)
|
|
31
|
+
return Digest::SHA256.file(path).hexdigest if File.file?(path)
|
|
32
|
+
return unless File.directory?(path)
|
|
33
|
+
|
|
34
|
+
digest = Digest::SHA256.new
|
|
35
|
+
Dir.glob(File.join(path, "**", "*"), File::FNM_DOTMATCH).select { |entry| File.file?(entry) }.sort.each do |entry|
|
|
36
|
+
digest << entry.delete_prefix("#{path}#{File::SEPARATOR}") << "\0" << Digest::SHA256.file(entry).digest
|
|
37
|
+
end
|
|
38
|
+
digest.hexdigest
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|