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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -2
  3. data/README.md +258 -97
  4. data/lib/shellfie/animation_frame_builder.rb +31 -7
  5. data/lib/shellfie/animation_timeline.rb +2 -1
  6. data/lib/shellfie/ansi_line_buffer.rb +20 -4
  7. data/lib/shellfie/ansi_normalizer.rb +18 -8
  8. data/lib/shellfie/ansi_parser.rb +120 -7
  9. data/lib/shellfie/cassette.rb +76 -0
  10. data/lib/shellfie/cli.rb +65 -2
  11. data/lib/shellfie/cli_authoring.rb +233 -0
  12. data/lib/shellfie/cli_generate.rb +244 -40
  13. data/lib/shellfie/cli_info.rb +106 -6
  14. data/lib/shellfie/cli_run.rb +167 -0
  15. data/lib/shellfie/config.rb +5 -1
  16. data/lib/shellfie/config_defaults.rb +21 -2
  17. data/lib/shellfie/config_validation.rb +93 -4
  18. data/lib/shellfie/dependency_checker.rb +74 -3
  19. data/lib/shellfie/errors.rb +1 -0
  20. data/lib/shellfie/ffmpeg_encoder.rb +46 -0
  21. data/lib/shellfie/font_resolver.rb +11 -1
  22. data/lib/shellfie/gif_generator.rb +157 -11
  23. data/lib/shellfie/gif_palette.rb +8 -4
  24. data/lib/shellfie/html_renderer.rb +54 -0
  25. data/lib/shellfie/line_layout.rb +19 -10
  26. data/lib/shellfie/output_writer.rb +7 -2
  27. data/lib/shellfie/parser.rb +82 -19
  28. data/lib/shellfie/parser_validation.rb +48 -15
  29. data/lib/shellfie/render_geometry.rb +2 -1
  30. data/lib/shellfie/render_segment.rb +17 -5
  31. data/lib/shellfie/renderer.rb +28 -11
  32. data/lib/shellfie/rendering/text_painter.rb +23 -15
  33. data/lib/shellfie/rendering/window_chrome.rb +2 -2
  34. data/lib/shellfie/reproducibility_manifest.rb +41 -0
  35. data/lib/shellfie/session.rb +111 -0
  36. data/lib/shellfie/session_config.rb +562 -0
  37. data/lib/shellfie/session_runner.rb +689 -0
  38. data/lib/shellfie/svg_renderer.rb +222 -0
  39. data/lib/shellfie/terminal_screen.rb +389 -0
  40. data/lib/shellfie/text_metrics.rb +74 -15
  41. data/lib/shellfie/transcript_renderer.rb +92 -0
  42. data/lib/shellfie/version.rb +1 -1
  43. data/lib/shellfie/yaml_safety.rb +147 -0
  44. data/lib/shellfie.rb +8 -1
  45. data/schema/shellfie-v1.schema.json +153 -0
  46. data/schema/shellfie-v2.schema.json +262 -0
  47. metadata +27 -24
  48. data/.rspec +0 -3
  49. data/Rakefile +0 -8
  50. data/docs/.nojekyll +0 -0
  51. data/docs/index.html +0 -205
  52. data/docs/scripts.js +0 -85
  53. data/docs/styles.css +0 -507
  54. data/examples/animation.yml +0 -33
  55. data/examples/colored.yml +0 -20
  56. data/examples/demo.gif +0 -0
  57. data/examples/demo.png +0 -0
  58. data/examples/demo_animation.yml +0 -31
  59. data/examples/headless.png +0 -0
  60. data/examples/headless.yml +0 -16
  61. data/examples/scrolling.yml +0 -48
  62. data/examples/simple.yml +0 -21
  63. data/examples/theme_macos.png +0 -0
  64. data/examples/theme_ubuntu.png +0 -0
  65. data/examples/theme_windows.png +0 -0
  66. data/shellfie.gemspec +0 -32
@@ -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
- raise ParseError, "Configuration file not found: #{path}" unless File.exist?(path)
16
-
17
- content = File.read(path)
18
- parse_string(content, base_dir: File.dirname(path))
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
- raw = apply_includes(raw, base_dir) if base_dir
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
- build_config(raw)
26
- rescue Psych::SyntaxError => e
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 exceeded" if depth > 5
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
- included_raw = YAML.safe_load(File.read(include_file), symbolize_names: true, aliases: true)
64
- included_raw = apply_includes(included_raw, File.dirname(include_file), depth: depth + 1)
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 scroll_easing
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, nil)
36
- validate_nested_hash!(raw, :window_decoration, nil)
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, key.to_s) if 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
- raise ValidationError, "Unknown #{context} key(s): #{unknown_keys.join(", ")}"
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
- validate_non_negative_integer!(frame[:delay], "frames[#{index}].delay") if frame.key?(:delay)
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, :dim, :reverse, :strikethrough, :overline
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, dim: false,
41
- reverse: false, strikethrough: false, overline: false)
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
 
@@ -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(state_mode: config.window[:ansi_state] || :persistent)
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
- OutputWriter.write(output_path, extension: extension) do |temporary_path|
33
- render_method = (extension == "svg") ? :create_svg_image : :create_image
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(expand_tabs(text)).map do |segment|
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
- draw_text(
67
- convert,
68
- item[:text],
69
- item[:x],
70
- item[:top],
71
- item[:foreground],
72
- geometry[:scaled_font_size],
73
- geometry[:font_config],
74
- bold: item[:segment].bold,
75
- italic: item[:segment].italic
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
- convert.stroke segment_colors(segment).first
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 / 3
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