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
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rbconfig"
4
+ require "open3"
5
+ require "tempfile"
6
+ require_relative "font_resolver"
4
7
 
5
8
  module Shellfie
6
9
  class DependencyChecker
@@ -15,6 +18,25 @@ module Shellfie
15
18
  !imagemagick_path.to_s.empty?
16
19
  end
17
20
 
21
+ def ffmpeg_path
22
+ @ffmpeg_path ||= find_executable(%w[ffmpeg], verify_imagemagick: false)
23
+ end
24
+
25
+ def ensure_ffmpeg!
26
+ return if ffmpeg_path
27
+
28
+ raise DependencyError, "ffmpeg not found; install ffmpeg to generate APNG, MP4, or WebM"
29
+ end
30
+
31
+ def ffmpeg_version
32
+ return unless ffmpeg_path
33
+
34
+ output, = Open3.capture3(ffmpeg_path, "-version")
35
+ output.lines.first&.strip
36
+ rescue SystemCallError
37
+ nil
38
+ end
39
+
18
40
  def ensure_imagemagick!
19
41
  return if imagemagick_available?
20
42
 
@@ -32,21 +54,48 @@ module Shellfie
32
54
  end
33
55
 
34
56
  def doctor(output_dir: Dir.pwd)
57
+ details = imagemagick_details
58
+ video_version = ffmpeg_version
35
59
  [
36
60
  check("Ruby", RUBY_VERSION, Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.0.0")),
37
- check("ImageMagick", imagemagick_path || "not found", imagemagick_available?),
61
+ check("ImageMagick", details[:version] || "not found", imagemagick_available?),
62
+ check("Image formats", details[:formats].empty? ? "unavailable" : details[:formats].join(", "),
63
+ (required_formats - details[:formats]).empty?),
64
+ check("Image render", details[:render_ok] ? "1x1 PNG generated" : "failed", details[:render_ok]),
65
+ check("Fonts", details[:font_count].to_s, details[:font_count].positive?),
66
+ check("Security policy", details[:policy] || "unavailable", !details[:policy].nil?),
67
+ check("ffmpeg (optional)", video_version || "not found; required only for APNG/MP4/WebM", true),
38
68
  check("Writable output", output_dir, File.writable?(output_dir)),
39
69
  check("Encoding", Encoding.default_external.name, true)
40
70
  ]
41
71
  end
42
72
 
73
+ def imagemagick_details
74
+ return { version: nil, formats: [], font_count: 0, render_ok: false, policy: nil } unless imagemagick_available?
75
+
76
+ version_output, _error, version_status = Open3.capture3(imagemagick_path, "-version")
77
+ formats_output, _error, formats_status = Open3.capture3(imagemagick_path, "-list", "format")
78
+ fonts_output, _error, fonts_status = Open3.capture3(imagemagick_path, "-list", "font")
79
+ policy_output, _error, policy_status = Open3.capture3(imagemagick_path, "-list", "policy")
80
+ {
81
+ version: version_status.success? ? version_output.lines.first&.strip : nil,
82
+ formats: formats_status.success? ? required_formats.select { |format| formats_output.match?(/^\s*#{format}\*?\s/im) } : [],
83
+ font_count: (fonts_status.success? ? fonts_output.scan(/^\s*Font:/).size : 0) +
84
+ FontResolver::FONT_FILES.values.uniq.count { |path| File.file?(path) },
85
+ render_ok: render_smoke_test,
86
+ policy: policy_status.success? ? "#{policy_output.scan(/^\s*Policy:/).size} rules loaded" : nil
87
+ }
88
+ rescue SystemCallError
89
+ { version: nil, formats: [], font_count: 0, render_ok: false, policy: nil }
90
+ end
91
+
43
92
  private
44
93
 
45
94
  def check(name, detail, ok)
46
95
  { name: name, detail: detail, ok: ok }
47
96
  end
48
97
 
49
- def find_executable(names)
98
+ def find_executable(names, verify_imagemagick: true)
50
99
  paths = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
51
100
  extensions = executable_extensions
52
101
 
@@ -54,7 +103,9 @@ module Shellfie
54
103
  paths.each do |path|
55
104
  extensions.each do |extension|
56
105
  candidate = File.join(path, "#{name}#{extension}")
57
- return candidate if File.file?(candidate) && File.executable?(candidate)
106
+ next unless File.file?(candidate) && File.executable?(candidate)
107
+ return candidate unless verify_imagemagick
108
+ return candidate if imagemagick_executable?(candidate)
58
109
  end
59
110
  end
60
111
  end
@@ -62,6 +113,26 @@ module Shellfie
62
113
  nil
63
114
  end
64
115
 
116
+ def imagemagick_executable?(candidate)
117
+ output, = Open3.capture3(candidate, "-version")
118
+ output.include?("ImageMagick")
119
+ rescue SystemCallError
120
+ false
121
+ end
122
+
123
+ def required_formats
124
+ %w[PNG GIF WEBP SVG]
125
+ end
126
+
127
+ def render_smoke_test
128
+ Tempfile.create(["shellfie-doctor", ".png"]) do |file|
129
+ _output, _error, status = Open3.capture3(imagemagick_path, "-size", "1x1", "xc:none", file.path)
130
+ return status.success? && File.binread(file.path, 8) == "\x89PNG\r\n\x1a\n".b
131
+ end
132
+ rescue SystemCallError, EOFError
133
+ false
134
+ end
135
+
65
136
  def executable_extensions
66
137
  return [""] unless windows?
67
138
 
@@ -22,4 +22,5 @@ module Shellfie
22
22
 
23
23
  class DependencyError < Error; end
24
24
  class FileSystemError < Error; end
25
+ class ExecutionError < Error; end
25
26
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+ require_relative "errors"
6
+
7
+ module Shellfie
8
+ class FfmpegEncoder
9
+ def self.encode(images, output_path, format:, command:, framerate:, playback_speed:, loop:, loop_count: nil,
10
+ apng_prediction: nil)
11
+ list = Tempfile.new(["shellfie-frames", ".txt"])
12
+ minimum_delay = 1_000.0 / framerate
13
+ images.each do |image|
14
+ list.puts "file '#{image[:path].gsub("'", "'\\''")}'"
15
+ list.puts "duration #{[image[:delay].to_f, minimum_delay].max / 1_000.0 / playback_speed}"
16
+ end
17
+ list.puts "file '#{images.last[:path].gsub("'", "'\\''")}'"
18
+ list.close
19
+
20
+ total_duration = images.sum { |image| [image[:delay].to_f, minimum_delay].max } / 1_000.0 / playback_speed
21
+ filters = ["fps=#{framerate}"]
22
+ codec = case format
23
+ when "mp4"
24
+ filters << "pad=ceil(iw/2)*2:ceil(ih/2)*2"
25
+ %w[-c:v libx264 -pix_fmt yuv420p -movflags +faststart]
26
+ when "webm"
27
+ filters << "pad=ceil(iw/2)*2:ceil(ih/2)*2"
28
+ %w[-c:v libvpx-vp9 -pix_fmt yuva420p]
29
+ when "apng"
30
+ filters << "format=rgba"
31
+ options = ["-plays", (loop_count || (loop ? 0 : 1)).to_s]
32
+ options.concat(["-pred", apng_prediction]) if apng_prediction
33
+ options.concat(["-f", "apng"])
34
+ else raise RenderError, "Unsupported ffmpeg format: #{format}"
35
+ end
36
+ timing = ["-vf", filters.join(","), "-fps_mode", "cfr", "-t", total_duration.to_s]
37
+ _stdout, stderr, status = Open3.capture3(command, "-y", "-f", "concat", "-safe", "0", "-i", list.path,
38
+ *timing, *codec, output_path)
39
+ raise RenderError, "ffmpeg encode failed: #{stderr}" unless status.success?
40
+
41
+ output_path
42
+ ensure
43
+ list&.close!
44
+ end
45
+ end
46
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+ require "digest"
5
+
3
6
  module Shellfie
4
7
  class FontResolver
5
8
  FONT_FILES = {
@@ -31,6 +34,12 @@ module Shellfie
31
34
  .find { |candidate| font_available?(candidate) }
32
35
  end
33
36
 
37
+ def details(font_config)
38
+ { regular: resolve(font_config, italic: false), italic: resolve(font_config, italic: true) }.transform_values do |font|
39
+ { name: font, sha256: File.file?(font.to_s) ? Digest::SHA256.file(font).hexdigest : nil }
40
+ end
41
+ end
42
+
34
43
  private
35
44
 
36
45
  def font_available?(font)
@@ -50,7 +59,8 @@ module Shellfie
50
59
  if command.empty?
51
60
  []
52
61
  else
53
- `#{command} -list font 2>/dev/null`.scan(/^\s*Font:\s+(.+)$/).flatten
62
+ stdout, = Open3.capture3(command, "-list", "font")
63
+ stdout.scan(/^\s*Font:\s+(.+)$/).flatten
54
64
  end
55
65
  end
56
66
  end
@@ -2,10 +2,12 @@
2
2
 
3
3
  require "mini_magick"
4
4
  require "fileutils"
5
+ require "json"
5
6
  require "tmpdir"
6
7
  require_relative "animation_frame_builder"
7
8
  require_relative "dependency_checker"
8
9
  require_relative "format_resolver"
10
+ require_relative "ffmpeg_encoder"
9
11
  require_relative "gif_palette"
10
12
  require_relative "image_magick_command_builder"
11
13
  require_relative "output_writer"
@@ -23,18 +25,21 @@ module Shellfie
23
25
  @frame_builder = AnimationFrameBuilder.new(config)
24
26
  end
25
27
 
26
- def generate(output_path, scale: 1, shadow: true, transparent: false, format: nil)
28
+ def generate(output_path, scale: 1, shadow: true, transparent: false, format: nil, io: nil)
27
29
  check_dependencies!
28
30
 
29
31
  images = []
30
32
  chrome_cache = RenderChromeCache.new
31
33
  begin
32
- frames = build_animation_frames
34
+ frames = playback_frames(coalesce_frames(build_animation_frames))
33
35
  validate_frame_limit!(frames)
36
+ validate_workload!(frames, scale: scale, shadow: shadow)
34
37
  warn_frame_count(frames)
35
38
  images = render_frames(frames, scale: scale, shadow: shadow, transparent: transparent, chrome_cache: chrome_cache)
36
39
  extension = FormatResolver.resolve(output_path, explicit: format, default: "gif")
37
- OutputWriter.write(output_path, extension: extension) do |temporary_path|
40
+ return write_png_sequence(images, output_path) if extension == "png-sequence"
41
+
42
+ OutputWriter.write(output_path, extension: extension, io: io) do |temporary_path|
38
43
  combine_to_animation(images, temporary_path, format: extension)
39
44
  end
40
45
  ensure
@@ -63,16 +68,24 @@ module Shellfie
63
68
  def render_frames(frames, scale:, shadow:, transparent:, chrome_cache:)
64
69
  temp_dir = Dir.mktmpdir("shellfie")
65
70
  images = []
71
+ complete = false
72
+ visible_lines = fixed_visible_lines(frames)
66
73
 
67
74
  frames.each_with_index do |frame, idx|
68
- frame_config = create_frame_config(frame[:lines], window_overrides: frame[:window] || {})
75
+ frame_config = create_frame_config(
76
+ frame[:lines],
77
+ window_overrides: { visible_lines: visible_lines }.merge(frame[:window] || {})
78
+ )
69
79
  renderer = Renderer.new(frame_config, chrome_cache: chrome_cache)
70
80
  output_path = File.join(temp_dir, "frame_#{format("%04d", idx)}.png")
71
81
  renderer.render(output_path, scale: scale, shadow: shadow, transparent: transparent)
72
82
  images << { path: output_path, delay: frame[:delay] }
73
83
  end
74
84
 
85
+ complete = true
75
86
  images
87
+ ensure
88
+ FileUtils.rm_rf(temp_dir) if temp_dir && !complete
76
89
  end
77
90
 
78
91
  def create_frame_config(lines, window_overrides: {})
@@ -94,13 +107,28 @@ module Shellfie
94
107
  end
95
108
 
96
109
  def combine_to_animation(images, output_path, format:)
110
+ if %w[mp4 webm apng].include?(format)
111
+ DependencyChecker.ensure_ffmpeg!
112
+ return FfmpegEncoder.encode(
113
+ images,
114
+ output_path,
115
+ format: format,
116
+ command: DependencyChecker.ffmpeg_path,
117
+ framerate: config.animation[:framerate],
118
+ playback_speed: config.animation[:playback_speed],
119
+ loop: config.animation[:loop],
120
+ loop_count: config.animation[:loop_count],
121
+ apng_prediction: config.animation[:apng_prediction]
122
+ )
123
+ end
124
+
97
125
  palette = GifPalette.new(config: config, theme: theme) if format == "gif"
98
126
  ImageMagickCommandBuilder.convert do |convert|
99
127
  convert.dispose "none" if format == "gif"
100
- convert.loop config.animation[:loop] ? 0 : 1
128
+ convert.loop animation_loop_count
101
129
 
102
- images.each do |img|
103
- convert.delay gif_delay(img[:delay])
130
+ animation_entries(images).each do |delay, img|
131
+ convert.delay delay
104
132
  convert << img[:path]
105
133
  end
106
134
 
@@ -115,19 +143,80 @@ module Shellfie
115
143
  case format
116
144
  when "gif"
117
145
  palette.apply(convert, images: images)
118
- convert.layers "optimize"
146
+ convert.layers "optimize" if config.animation[:gif_optimize]
119
147
  when "webp"
120
- convert.define "webp:lossless=true"
148
+ convert.define "webp:lossless=#{config.animation[:webp_lossless]}"
149
+ convert.define "webp:method=#{config.animation[:webp_method]}"
150
+ convert.define "webp:near-lossless=#{config.animation[:webp_near_lossless]}"
151
+ convert.quality config.animation[:webp_quality]
121
152
  end
122
153
  end
123
154
 
155
+ def animation_loop_count
156
+ config.animation[:loop_count] || (config.animation[:loop] ? 0 : 1)
157
+ end
158
+
124
159
  def cleanup_temp_files(images)
125
160
  temp_dir = File.dirname(images.first[:path]) if images.any?
126
161
  FileUtils.rm_rf(temp_dir) if temp_dir && Dir.exist?(temp_dir)
127
162
  end
128
163
 
129
- def gif_delay(milliseconds)
130
- [(milliseconds / 10.0).round, 1].max
164
+ def write_png_sequence(images, output_path)
165
+ if Dir.exist?(output_path) && !replaceable_sequence_directory?(output_path)
166
+ raise FileSystemError, "Refusing to replace a non-Shellfie directory: #{output_path}"
167
+ end
168
+
169
+ parent = File.dirname(File.expand_path(output_path))
170
+ FileUtils.mkdir_p(parent)
171
+ temporary = Dir.mktmpdir(".shellfie-sequence-", parent)
172
+ frames = images.each_with_index.map do |image, index|
173
+ filename = "frame_#{format("%04d", index)}.png"
174
+ FileUtils.cp(image[:path], File.join(temporary, filename))
175
+ { file: filename, delay_ms: image[:delay] / config.animation[:playback_speed] }
176
+ end
177
+ File.write(File.join(temporary, "timeline.json"), JSON.pretty_generate(version: 1, frames: frames))
178
+ backup = "#{temporary}-previous"
179
+ File.rename(output_path, backup) if File.exist?(output_path)
180
+ File.rename(temporary, output_path)
181
+ FileUtils.rm_rf(backup)
182
+ output_path
183
+ rescue StandardError
184
+ File.rename(backup, output_path) if backup && File.exist?(backup) && !File.exist?(output_path)
185
+ raise
186
+ ensure
187
+ FileUtils.rm_rf(temporary) if temporary && Dir.exist?(temporary)
188
+ FileUtils.rm_rf(backup) if backup && File.exist?(backup)
189
+ end
190
+
191
+ def replaceable_sequence_directory?(path)
192
+ entries = Dir.children(path)
193
+ return true if entries.empty?
194
+ return false unless entries.include?("timeline.json")
195
+
196
+ entries.all? { |entry| entry == "timeline.json" || entry.match?(/\Aframe_\d{4}\.png\z/) }
197
+ end
198
+
199
+ def animation_delays(images)
200
+ elapsed = 0.0
201
+ emitted = 0
202
+ images.map do |image|
203
+ elapsed += image[:delay] / config.animation[:playback_speed]
204
+ target = [(elapsed / 10.0).round, emitted + 1].max
205
+ (target - emitted).tap { emitted = target }
206
+ end
207
+ end
208
+
209
+ def animation_entries(images)
210
+ target_ticks = [(images.sum { |image| image[:delay] } / config.animation[:playback_speed] / 10.0).round, 1].max
211
+ return animation_delays(images).zip(images) if target_ticks >= images.size
212
+
213
+ elapsed = 0.0
214
+ ends = images.map { |image| elapsed += image[:delay] / config.animation[:playback_speed] }
215
+ indexes = (1..target_ticks).map do |tick|
216
+ ends.index { |finish| finish >= tick * 10 } || images.size - 1
217
+ end
218
+ indexes[-1] = images.size - 1
219
+ indexes.chunk(&:itself).map { |index, ticks| [ticks.size, images[index]] }
131
220
  end
132
221
 
133
222
  def warn_frame_count(frames)
@@ -143,5 +232,62 @@ module Shellfie
143
232
  raise ResourceLimitError, "Animation would generate #{frames.size} frames (max #{config.limits[:max_render_frames]})"
144
233
  end
145
234
 
235
+ def coalesce_frames(frames)
236
+ frames.each_with_object([]) do |frame, result|
237
+ if result.last && frame_key(result.last) == frame_key(frame)
238
+ result.last[:delay] += frame[:delay]
239
+ else
240
+ result << frame.merge(lines: frame[:lines].dup)
241
+ end
242
+ end
243
+ end
244
+
245
+ def frame_key(frame)
246
+ [frame[:lines].map(&:to_h), frame[:window]]
247
+ end
248
+
249
+ def playback_frames(frames)
250
+ frames = frames.reverse if config.animation[:direction] == "reverse"
251
+ offset = config.animation[:loop_offset]
252
+ if offset >= frames.size
253
+ raise ValidationError, "animation.loop_offset must be less than the #{frames.size} rendered frames"
254
+ end
255
+ frames = frames.rotate(offset)
256
+ return frames unless config.animation[:direction] == "ping_pong" && frames.size > 2
257
+
258
+ frames + frames[1...-1].reverse
259
+ end
260
+
261
+ def validate_workload!(frames, scale:, shadow:)
262
+ speed = Rational(config.animation[:playback_speed].to_s)
263
+ encoded_frames = (frames.sum { |frame| frame[:delay] } * config.animation[:framerate] / (1_000 * speed)).ceil
264
+ if encoded_frames > config.limits[:max_render_frames]
265
+ raise ResourceLimitError,
266
+ "Animation timeline would encode #{encoded_frames} frames (max #{config.limits[:max_render_frames]})"
267
+ end
268
+
269
+ visible_lines = fixed_visible_lines(frames)
270
+ max_pixels = frames.map do |frame|
271
+ frame_config = create_frame_config(frame[:lines], window_overrides: { visible_lines: visible_lines }.merge(frame[:window] || {}))
272
+ geometry = Renderer.new(frame_config).estimate(scale: scale, shadow: shadow)
273
+ geometry[:canvas_width] * geometry[:canvas_height]
274
+ end.max || 0
275
+ total_pixels = max_pixels * frames.size
276
+ if total_pixels > config.limits[:max_total_pixels]
277
+ raise ResourceLimitError,
278
+ "Animation workload is too large (#{total_pixels} pixels, max #{config.limits[:max_total_pixels]})"
279
+ end
280
+
281
+ estimated_bytes = total_pixels * 4
282
+ return if estimated_bytes <= config.limits[:max_temp_bytes]
283
+
284
+ raise ResourceLimitError,
285
+ "Animation temporary data is too large (#{estimated_bytes} bytes, max #{config.limits[:max_temp_bytes]})"
286
+ end
287
+
288
+ def fixed_visible_lines(frames)
289
+ config.window[:visible_lines] || [frames.map { |frame| frame[:lines].size }.max.to_i, 1].max
290
+ end
291
+
146
292
  end
147
293
  end
@@ -21,7 +21,7 @@ module Shellfie
21
21
  when "theme"
22
22
  apply_theme_palette(convert)
23
23
  else
24
- convert.colors 256
24
+ convert.colors color_count
25
25
  end
26
26
  end
27
27
 
@@ -35,7 +35,7 @@ module Shellfie
35
35
  def apply_global_palette(convert, images)
36
36
  palette_path = build_global_palette(images)
37
37
  convert.remap palette_path if palette_path
38
- convert.colors 256
38
+ convert.colors color_count
39
39
  end
40
40
 
41
41
  def apply_theme_palette(convert)
@@ -59,7 +59,7 @@ module Shellfie
59
59
  end
60
60
 
61
61
  def build_theme_palette
62
- colors = theme_colors.first(256)
62
+ colors = theme_colors.first(color_count)
63
63
  return nil if colors.empty?
64
64
 
65
65
  path = palette_path
@@ -86,7 +86,7 @@ module Shellfie
86
86
  end
87
87
 
88
88
  def theme_color_count
89
- [[theme_colors.size, 16].max, 256].min
89
+ [[theme_colors.size, [16, color_count].min].max, color_count].min
90
90
  end
91
91
 
92
92
  def theme_colors
@@ -97,5 +97,9 @@ module Shellfie
97
97
  @theme.window_decoration[:border]
98
98
  ].flatten.compact.uniq
99
99
  end
100
+
101
+ def color_count
102
+ @config.animation[:gif_colors]
103
+ end
100
104
  end
101
105
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require_relative "svg_renderer"
5
+
6
+ module Shellfie
7
+ class HtmlRenderer
8
+ def initialize(config:, theme:)
9
+ @config = config
10
+ @svg_renderer = SvgRenderer.new(config: config, theme: theme)
11
+ end
12
+
13
+ def render(geometry, output_path, transparent: false)
14
+ svg = @svg_renderer.to_svg(geometry, transparent: transparent).sub(/\A<\?xml[^>]+>\s*/, "")
15
+ transcript = geometry[:lines].map { |line| line[:segments].map(&:text).join }.join("\n")
16
+ File.write(output_path, <<~HTML)
17
+ <!doctype html>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="utf-8">
21
+ <meta name="viewport" content="width=device-width, initial-scale=1">
22
+ <title>#{escape(@config.title)}</title>
23
+ <style>
24
+ :root { color-scheme: light dark; }
25
+ :root[data-theme="light"] { color-scheme: light; }
26
+ :root[data-theme="dark"] { color-scheme: dark; }
27
+ body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: Canvas; }
28
+ main { max-width: 100%; overflow: auto; }
29
+ button { position: fixed; inset: 1rem 1rem auto auto; font: inherit; }
30
+ svg { display: block; max-width: 100%; height: auto; }
31
+ .transcript { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: pre; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <button type="button" aria-label="Toggle color theme">Theme</button>
36
+ <main aria-label="#{escape(@config.title)}">#{svg}<pre class="transcript">#{escape(transcript)}</pre></main>
37
+ <script>
38
+ document.querySelector('button').addEventListener('click', () => {
39
+ const root = document.documentElement;
40
+ root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark';
41
+ });
42
+ </script>
43
+ </body>
44
+ </html>
45
+ HTML
46
+ end
47
+
48
+ private
49
+
50
+ def escape(value)
51
+ CGI.escapeHTML(value.to_s)
52
+ end
53
+ end
54
+ end
@@ -39,8 +39,8 @@ module Shellfie
39
39
 
40
40
  def vertical_line_limit(title_bar_height, padding, line_height)
41
41
  limits = [@config.window[:visible_lines], @config.window[:max_lines]].compact
42
- if @config.window[:max_height]
43
- available_height = @config.window[:max_height] - title_bar_height - padding * 2
42
+ [@config.window[:height], @config.window[:max_height]].compact.each do |height|
43
+ available_height = height - title_bar_height - padding * 2
44
44
  limits << [(available_height / line_height).floor, 1].max
45
45
  end
46
46
 
@@ -58,8 +58,8 @@ module Shellfie
58
58
  used_cells = 0
59
59
 
60
60
  line[:segments].each do |segment|
61
- segment.text.to_s.each_char do |char|
62
- width = TextMetrics.char_width(char)
61
+ TextMetrics.graphemes(segment.text).each do |char|
62
+ width = TextMetrics.grapheme_width(char, ambiguous_width: ambiguous_width)
63
63
  if used_cells.positive? && used_cells + width > max_cells
64
64
  wrapped << { segments: [], selected: line[:selected] }
65
65
  used_cells = 0
@@ -78,29 +78,29 @@ module Shellfie
78
78
  segments.each_with_object([]) do |segment, result|
79
79
  break result if remaining <= 0
80
80
 
81
- text = TextMetrics.take_cells(segment.text, remaining)
81
+ text = TextMetrics.take_cells(segment.text, remaining, ambiguous_width: ambiguous_width)
82
82
  next if text.empty?
83
83
 
84
84
  result << copy_segment(segment, text)
85
- remaining -= TextMetrics.cell_width(text)
85
+ remaining -= TextMetrics.cell_width(text, ambiguous_width: ambiguous_width)
86
86
  end
87
87
  end
88
88
 
89
89
  def scroll_segments(segments, max_cells)
90
- total_cells = segments.sum { |segment| TextMetrics.cell_width(segment.text) }
90
+ total_cells = segments.sum { |segment| TextMetrics.cell_width(segment.text, ambiguous_width: ambiguous_width) }
91
91
  return clip_segments(segments, max_cells) if total_cells <= max_cells
92
92
 
93
93
  cells_to_drop = total_cells - max_cells
94
94
  scrolled = []
95
95
 
96
96
  segments.each do |segment|
97
- segment_cells = TextMetrics.cell_width(segment.text)
97
+ segment_cells = TextMetrics.cell_width(segment.text, ambiguous_width: ambiguous_width)
98
98
  if cells_to_drop >= segment_cells
99
99
  cells_to_drop -= segment_cells
100
100
  next
101
101
  end
102
102
 
103
- text = cells_to_drop.positive? ? TextMetrics.drop_cells(segment.text, cells_to_drop) : segment.text
103
+ text = cells_to_drop.positive? ? TextMetrics.drop_cells(segment.text, cells_to_drop, ambiguous_width: ambiguous_width) : segment.text
104
104
  cells_to_drop = 0
105
105
  scrolled << copy_segment(segment, text) unless text.empty?
106
106
  end
@@ -127,11 +127,20 @@ module Shellfie
127
127
  segment.bold,
128
128
  segment.italic,
129
129
  segment.underline,
130
+ segment.underline_style,
131
+ segment.underline_color,
130
132
  segment.dim,
131
133
  segment.reverse,
132
134
  segment.strikethrough,
133
- segment.overline
135
+ segment.overline,
136
+ segment.blink,
137
+ segment.conceal,
138
+ segment.link
134
139
  ]
135
140
  end
141
+
142
+ def ambiguous_width
143
+ @config.window[:ambiguous_width]
144
+ end
136
145
  end
137
146
  end
@@ -7,13 +7,18 @@ require "tmpdir"
7
7
  module Shellfie
8
8
  class OutputWriter
9
9
  class << self
10
- def write(path, extension:)
10
+ def write(path, extension:, io: nil)
11
11
  FileUtils.mkdir_p(output_directory(path)) unless stdout?(path)
12
12
  temp = Tempfile.new(["shellfie", ".#{extension}"], output_directory(path), binmode: true)
13
13
  temp.close
14
14
  yield temp.path
15
15
 
16
- return File.binread(temp.path) if stdout?(path)
16
+ if stdout?(path)
17
+ return File.binread(temp.path) unless io
18
+
19
+ File.open(temp.path, "rb") { |source| IO.copy_stream(source, io) }
20
+ return path
21
+ end
17
22
 
18
23
  FileUtils.mv(temp.path, path)
19
24
  path