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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +95 -236
  4. data/docs/.nojekyll +0 -0
  5. data/docs/index.html +205 -0
  6. data/docs/scripts.js +85 -0
  7. data/docs/styles.css +507 -0
  8. data/examples/simple.yml +3 -3
  9. data/lib/shellfie/animation_frame_builder.rb +178 -0
  10. data/lib/shellfie/animation_scroll_easing.rb +77 -0
  11. data/lib/shellfie/animation_timeline.rb +27 -0
  12. data/lib/shellfie/ansi_colors.rb +94 -0
  13. data/lib/shellfie/ansi_line_buffer.rb +87 -0
  14. data/lib/shellfie/ansi_normalizer.rb +51 -0
  15. data/lib/shellfie/ansi_parser.rb +50 -84
  16. data/lib/shellfie/cli.rb +22 -173
  17. data/lib/shellfie/cli_generate.rb +197 -0
  18. data/lib/shellfie/cli_info.rb +139 -0
  19. data/lib/shellfie/config.rb +108 -25
  20. data/lib/shellfie/config_defaults.rb +64 -0
  21. data/lib/shellfie/config_validation.rb +200 -0
  22. data/lib/shellfie/dependency_checker.rb +76 -0
  23. data/lib/shellfie/errors.rb +11 -1
  24. data/lib/shellfie/font_resolver.rb +58 -0
  25. data/lib/shellfie/format_resolver.rb +15 -0
  26. data/lib/shellfie/gif_generator.rb +83 -87
  27. data/lib/shellfie/gif_palette.rb +101 -0
  28. data/lib/shellfie/headless_theme_registry.rb +42 -0
  29. data/lib/shellfie/image_magick_command_builder.rb +75 -0
  30. data/lib/shellfie/line_layout.rb +137 -0
  31. data/lib/shellfie/output_writer.rb +41 -0
  32. data/lib/shellfie/parser.rb +113 -23
  33. data/lib/shellfie/parser_validation.rb +145 -0
  34. data/lib/shellfie/raster_painter.rb +157 -0
  35. data/lib/shellfie/render_chrome_cache.rb +40 -0
  36. data/lib/shellfie/render_geometry.rb +114 -0
  37. data/lib/shellfie/render_segment.rb +59 -0
  38. data/lib/shellfie/renderer.rb +79 -149
  39. data/lib/shellfie/rendering/shape_helpers.rb +42 -0
  40. data/lib/shellfie/rendering/text_painter.rb +187 -0
  41. data/lib/shellfie/rendering/window_chrome.rb +196 -0
  42. data/lib/shellfie/svg_raster_wrapper.rb +35 -0
  43. data/lib/shellfie/text_metrics.rb +96 -0
  44. data/lib/shellfie/theme_data.rb +80 -0
  45. data/lib/shellfie/theme_registry.rb +131 -0
  46. data/lib/shellfie/themes/base.rb +10 -1
  47. data/lib/shellfie/themes/configured.rb +61 -0
  48. data/lib/shellfie/themes/macos.rb +3 -1
  49. data/lib/shellfie/themes/ubuntu.rb +2 -1
  50. data/lib/shellfie/themes/windows_terminal.rb +7 -1
  51. data/lib/shellfie/version.rb +1 -1
  52. data/lib/shellfie.rb +37 -3
  53. metadata +37 -2
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ class RenderSegment
5
+ ATTRIBUTES = %i[
6
+ foreground background bold italic underline dim reverse strikethrough overline
7
+ ].freeze
8
+
9
+ attr_reader :text, :foreground, :background, :bold, :italic, :underline, :dim, :reverse, :strikethrough, :overline
10
+
11
+ def self.from_segment(segment, default_color:)
12
+ new(
13
+ text: segment.text.to_s,
14
+ foreground: segment.foreground || default_color,
15
+ background: segment.background,
16
+ bold: segment.bold,
17
+ italic: segment.italic,
18
+ underline: segment.underline,
19
+ dim: segment.dim,
20
+ reverse: segment.reverse,
21
+ strikethrough: segment.strikethrough,
22
+ overline: segment.overline
23
+ )
24
+ end
25
+
26
+ def self.copy(segment, text)
27
+ new(**ATTRIBUTES.each_with_object(text: text.dup) { |attribute, values| values[attribute] = segment.public_send(attribute) })
28
+ end
29
+
30
+ def self.coalesce(segments)
31
+ segments.each_with_object([]) do |segment, result|
32
+ if result.last&.same_style?(segment)
33
+ result[-1] = copy(result.last, result.last.text + segment.text)
34
+ else
35
+ result << segment
36
+ end
37
+ end
38
+ end
39
+
40
+ def initialize(text:, foreground: nil, background: nil, bold: false, italic: false, underline: false, dim: false,
41
+ reverse: false, strikethrough: false, overline: false)
42
+ @text = text
43
+ @foreground = foreground
44
+ @background = background
45
+ @bold = bold
46
+ @italic = italic
47
+ @underline = underline
48
+ @dim = dim
49
+ @reverse = reverse
50
+ @strikethrough = strikethrough
51
+ @overline = overline
52
+ freeze
53
+ end
54
+
55
+ def same_style?(other)
56
+ ATTRIBUTES.all? { |attribute| public_send(attribute) == other.public_send(attribute) }
57
+ end
58
+ end
59
+ end
@@ -2,192 +2,122 @@
2
2
 
3
3
  require "mini_magick"
4
4
  require_relative "ansi_parser"
5
- require_relative "themes/base"
6
- require_relative "themes/macos"
7
- require_relative "themes/ubuntu"
8
- require_relative "themes/windows_terminal"
5
+ require_relative "dependency_checker"
6
+ require_relative "font_resolver"
7
+ require_relative "format_resolver"
8
+ require_relative "output_writer"
9
+ require_relative "raster_painter"
10
+ require_relative "render_chrome_cache"
11
+ require_relative "render_geometry"
12
+ require_relative "render_segment"
13
+ require_relative "svg_raster_wrapper"
14
+ require_relative "theme_registry"
9
15
 
10
16
  module Shellfie
11
17
  class Renderer
12
- THEMES = {
13
- "macos" => Themes::MacOS,
14
- "ubuntu" => Themes::Ubuntu,
15
- "windows" => Themes::WindowsTerminal
16
- }.freeze
18
+ attr_reader :config, :theme, :font_resolver
17
19
 
18
- attr_reader :config, :theme
19
-
20
- def initialize(config)
20
+ def initialize(config, chrome_cache: nil)
21
21
  @config = config
22
- @theme = load_theme(config.theme)
23
- @ansi_parser = AnsiParser.new
22
+ @chrome_cache = chrome_cache
23
+ @theme = ThemeRegistry.build(config)
24
+ @ansi_parser = AnsiParser.new(state_mode: config.window[:ansi_state] || :persistent)
25
+ @font_resolver = FontResolver.new(-> { imagemagick_command })
24
26
  end
25
27
 
26
- def render(output_path, scale: 1, shadow: true, transparent: false)
28
+ def render(output_path, scale: 1, shadow: true, transparent: false, format: nil)
27
29
  check_dependencies!
28
-
29
30
  lines = build_lines
30
- create_image(lines, output_path, scale: scale, shadow: shadow, transparent: transparent)
31
- output_path
31
+ 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
34
+ send(render_method, lines, temporary_path, scale: scale, shadow: shadow, transparent: transparent)
35
+ end
36
+ rescue MiniMagick::Error => e
37
+ raise RenderError.new("ImageMagick render failed: #{e.message}", category: :render)
38
+ end
39
+
40
+ def estimate(scale: 1, shadow: true)
41
+ geometry = build_geometry(build_lines, scale: scale, shadow: shadow)
42
+ geometry.slice(:canvas_width, :canvas_height, :scaled_width, :scaled_height, :logical_width, :logical_height, :scale)
32
43
  end
33
44
 
34
45
  private
35
46
 
36
- def load_theme(name)
37
- klass = THEMES[name] || THEMES["macos"]
38
- klass.new
47
+ def check_dependencies!
48
+ DependencyChecker.configure_mini_magick!
49
+ DependencyChecker.ensure_imagemagick!
39
50
  end
40
51
 
41
- def check_dependencies!
42
- result = `which magick 2>/dev/null || which convert 2>/dev/null`.strip
43
- if result.empty?
44
- raise DependencyError, <<~MSG
45
- ImageMagick not found
46
- → Please install ImageMagick: brew install imagemagick
47
- → Or visit: https://imagemagick.org/script/download.php
48
- MSG
49
- end
52
+ def imagemagick_command
53
+ @imagemagick_command ||= DependencyChecker.imagemagick_path.to_s
50
54
  end
51
55
 
52
56
  def build_lines
53
57
  config.lines.flat_map do |line|
54
- result = []
55
- if line.prompt
56
- prompt_segments = @ansi_parser.parse(line.prompt)
57
- command_segments = line.command ? @ansi_parser.parse(line.command) : []
58
- result << { segments: prompt_segments + command_segments }
59
- end
60
- if line.output
61
- line.output.split("\n").each do |output_line|
62
- result << { segments: @ansi_parser.parse(output_line) }
63
- end
64
- end
65
- result
66
- end
67
- end
68
-
69
- def create_image(lines, output_path, scale:, shadow:, transparent:)
70
- decoration = theme.window_decoration
71
- font_config = config.font.is_a?(Hash) ? config.font : theme.font
72
- padding = config.window[:padding] || 20
73
- width = config.window[:width] || 600
74
- line_height = (font_config[:size] || 14) * (font_config[:line_height] || 1.4)
75
- title_bar_height = config.headless ? 0 : decoration[:title_bar_height]
76
- visible_lines = config.window[:visible_lines]
77
-
78
- display_line_count = visible_lines || lines.size
79
- content_height = display_line_count * line_height + padding * 2
80
- total_height = title_bar_height + content_height
81
- corner_radius = decoration[:corner_radius]
82
-
83
- if visible_lines && lines.size > visible_lines
84
- lines = lines.last(visible_lines)
85
- end
86
-
87
- scaled_width = (width * scale).to_i
88
- scaled_height = (total_height * scale).to_i
89
- scaled_padding = (padding * scale).to_i
90
- scaled_title_bar = (title_bar_height * scale).to_i
91
- scaled_line_height = (line_height * scale).to_i
92
- scaled_font_size = ((font_config[:size] || 14) * scale).to_i
93
- scaled_radius = (corner_radius * scale).to_i
94
-
95
- margin = shadow ? (50 * scale).to_i : (10 * scale).to_i
96
- canvas_width = scaled_width + margin * 2
97
- canvas_height = scaled_height + margin * 2
98
-
99
- MiniMagick.convert do |convert|
100
- convert.size "#{canvas_width}x#{canvas_height}"
101
- convert << "xc:transparent"
102
-
103
- if shadow
104
- convert.fill "rgba(0,0,0,0.3)"
105
- shadow_offset = (10 * scale).to_i
106
- convert.draw "roundrectangle #{margin + shadow_offset},#{margin + shadow_offset} " \
107
- "#{margin + scaled_width - 1 + shadow_offset},#{margin + scaled_height - 1 + shadow_offset} " \
108
- "#{scaled_radius},#{scaled_radius}"
109
- convert.blur "0x#{(15 * scale).to_i}"
58
+ rendered_lines = []
59
+ if line.prompt || line.command
60
+ rendered_lines << {
61
+ segments: coalesce_segments(
62
+ parse_with_default(line.prompt.to_s, line.prompt_color) +
63
+ parse_with_default(line.command.to_s, line.command_color)
64
+ ),
65
+ selected: line.selected
66
+ }
110
67
  end
111
68
 
112
- convert.fill theme.colors[:background]
113
- convert.draw "roundrectangle #{margin},#{margin} " \
114
- "#{margin + scaled_width - 1},#{margin + scaled_height - 1} " \
115
- "#{scaled_radius},#{scaled_radius}"
116
-
117
- unless config.headless
118
- convert.fill theme.colors[:title_bar]
119
- title_y2 = margin + scaled_title_bar - 1
120
- convert.draw "roundrectangle #{margin},#{margin} " \
121
- "#{margin + scaled_width - 1},#{title_y2} " \
122
- "#{scaled_radius},#{scaled_radius}"
123
- convert.fill theme.colors[:title_bar]
124
- convert.draw "rectangle #{margin},#{margin + scaled_radius} " \
125
- "#{margin + scaled_width - 1},#{title_y2}"
126
-
127
- button_x, button_y = button_positions(margin, scaled_title_bar, scale)
128
- button_radius = ((theme.window_decoration[:button_size] / 2) * scale).to_i
129
-
130
- theme.button_colors.each_with_index do |color, i|
131
- spacing = ((theme.window_decoration[:button_spacing] + theme.window_decoration[:button_size]) * scale).to_i
132
- bx = button_x + i * spacing
133
- convert.fill color
134
- convert.draw "circle #{bx},#{button_y} #{bx + button_radius},#{button_y}"
135
- end
136
-
137
- convert.fill theme.colors[:title_text]
138
- convert.pointsize scaled_font_size
139
- title_y = margin + scaled_title_bar / 2 + scaled_font_size / 3
140
- title_x = margin + scaled_width / 2
141
- convert.gravity "NorthWest"
142
- convert.draw "text #{title_x - config.title.to_s.length * scaled_font_size / 4},#{title_y - scaled_font_size} '#{escape_text(config.title.to_s)}'"
143
- end
69
+ next rendered_lines unless line.output
144
70
 
145
- content_y = margin + scaled_title_bar + scaled_padding
146
- lines.each_with_index do |line, idx|
147
- y = content_y + idx * scaled_line_height + scaled_font_size
148
- x = margin + scaled_padding
149
- draw_line_segments(convert, line[:segments], x, y, scaled_font_size, font_config)
71
+ line.output.to_s.split("\n", -1).each do |output_line|
72
+ rendered_lines << { segments: coalesce_segments(parse_with_default(output_line, line.output_color)), selected: line.selected }
150
73
  end
151
-
152
- convert << output_path
74
+ rendered_lines
153
75
  end
154
76
  end
155
77
 
156
- def button_positions(margin, title_bar_height, scale)
157
- button_size = (theme.window_decoration[:button_size] * scale).to_i
158
- scaled_width = ((config.window[:width] || 600) * scale).to_i
159
-
160
- if theme.buttons_position == :left
161
- x = margin + (16 * scale).to_i
162
- else
163
- x = margin + scaled_width - (16 * scale).to_i - button_size * 3
78
+ def parse_with_default(text, default_color)
79
+ @ansi_parser.parse(expand_tabs(text)).map do |segment|
80
+ RenderSegment.from_segment(segment, default_color: default_color)
164
81
  end
165
- y = margin + title_bar_height / 2
166
-
167
- [x, y]
168
82
  end
169
83
 
170
- def draw_line_segments(convert, segments, x, y, font_size, font_config)
171
- current_x = x
84
+ def coalesce_segments(segments)
85
+ RenderSegment.coalesce(segments)
86
+ end
172
87
 
173
- segments.each do |segment|
174
- color = segment.foreground ? theme.color_for(segment.foreground) : theme.colors[:foreground]
175
- text = escape_text(segment.text)
88
+ def expand_tabs(text)
89
+ text.to_s.gsub("\t", " " * config.window[:tab_width])
90
+ end
176
91
 
177
- next if text.empty?
92
+ def create_image(lines, output_path, scale:, shadow:, transparent:)
93
+ geometry = build_geometry(lines, scale: scale, shadow: shadow)
178
94
 
179
- convert.fill color
180
- convert.pointsize font_size
95
+ raster_painter.paint(geometry, output_path, transparent: transparent)
96
+ end
181
97
 
182
- convert.draw "text #{current_x},#{y} '#{text}'"
98
+ def create_svg_image(lines, output_path, scale:, shadow:, transparent:)
99
+ SvgRasterWrapper.write(output_path) { |png_path| create_image(lines, png_path, scale: scale, shadow: shadow, transparent: transparent) }
100
+ end
183
101
 
184
- char_width = font_size * 0.6
185
- current_x += (segment.text.length * char_width).to_i
186
- end
102
+ def build_geometry(lines, scale:, shadow:)
103
+ geometry_builder.build(lines, scale: scale, shadow: shadow)
187
104
  end
188
105
 
189
106
  def escape_text(text)
190
- text.to_s.gsub("'", "\\\\'").gsub("\\", "\\\\\\\\")
107
+ raster_painter.send(:escape_text, text)
108
+ end
109
+
110
+ def geometry_builder
111
+ @geometry_builder ||= RenderGeometry.new(config: config, theme: theme)
112
+ end
113
+
114
+ def raster_painter
115
+ @raster_painter ||= RasterPainter.new(
116
+ config: config,
117
+ theme: theme,
118
+ font_resolver: font_resolver,
119
+ chrome_cache: @chrome_cache
120
+ )
191
121
  end
192
122
  end
193
123
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../image_magick_command_builder"
4
+
5
+ module Shellfie
6
+ module Rendering
7
+ module ShapeHelpers
8
+ def draw_roundrect(convert, x1, y1, x2, y2, radius)
9
+ if radius.positive?
10
+ ImageMagickCommandBuilder.round_rectangle(convert, x1, y1, x2, y2, radius)
11
+ else
12
+ ImageMagickCommandBuilder.rectangle(convert, x1, y1, x2, y2)
13
+ end
14
+ end
15
+
16
+ def draw_windows_icon(convert, index, center_x, center_y, icon_size)
17
+ case index
18
+ when 0
19
+ ImageMagickCommandBuilder.line(convert, center_x - icon_size / 2, center_y, center_x + icon_size / 2, center_y)
20
+ when 1
21
+ ImageMagickCommandBuilder.rectangle(
22
+ convert,
23
+ center_x - icon_size / 2,
24
+ center_y - icon_size / 2,
25
+ center_x + icon_size / 2,
26
+ center_y + icon_size / 2
27
+ )
28
+ when 2
29
+ ImageMagickCommandBuilder.lines(
30
+ convert,
31
+ [
32
+ { x1: center_x - icon_size / 2, y1: center_y - icon_size / 2,
33
+ x2: center_x + icon_size / 2, y2: center_y + icon_size / 2 },
34
+ { x1: center_x + icon_size / 2, y1: center_y - icon_size / 2,
35
+ x2: center_x - icon_size / 2, y2: center_y + icon_size / 2 }
36
+ ]
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../image_magick_command_builder"
4
+ require_relative "../text_metrics"
5
+
6
+ module Shellfie
7
+ module Rendering
8
+ module TextPainter
9
+ def draw_content(convert, geometry)
10
+ content_y = content_origin_y(geometry)
11
+ draw_selected_backgrounds(convert, geometry, content_y)
12
+
13
+ geometry[:lines].each_with_index do |line, index|
14
+ top = content_y + index * geometry[:scaled_line_height]
15
+ baseline = top + geometry[:scaled_font_size]
16
+ x = geometry[:margin] + geometry[:scaled_padding]
17
+
18
+ draw_line_segments(convert, line[:segments], x, baseline, geometry)
19
+ end
20
+ end
21
+
22
+ def draw_line_segments(convert, segments, x, baseline, geometry)
23
+ positioned_segments = position_segments(segments, x, baseline, geometry)
24
+ draw_segment_backgrounds(convert, positioned_segments)
25
+ draw_positioned_segments(convert, positioned_segments, geometry)
26
+ end
27
+
28
+ def position_segments(segments, x, baseline, geometry)
29
+ current_x = x
30
+ segments.each_with_object([]) do |segment, result|
31
+ text = segment.text.to_s
32
+ next if text.empty?
33
+
34
+ width = TextMetrics.pixel_width(text, geometry[:scaled_font_size])
35
+ top = baseline - geometry[:scaled_font_size]
36
+ foreground, background = segment_colors(segment)
37
+ result << {
38
+ segment: segment,
39
+ text: text,
40
+ x: current_x,
41
+ width: width,
42
+ top: top,
43
+ bottom: top + geometry[:scaled_line_height],
44
+ foreground: foreground,
45
+ background: background
46
+ }
47
+ current_x += width
48
+ end
49
+ end
50
+
51
+ def draw_segment_backgrounds(convert, positioned_segments)
52
+ positioned_segments
53
+ .select { |item| item[:background] }
54
+ .group_by { |item| item[:background] }
55
+ .each do |background, items|
56
+ convert.fill background
57
+ ImageMagickCommandBuilder.rectangles(
58
+ convert,
59
+ items.map { |item| { x1: item[:x], y1: item[:top], x2: item[:x] + item[:width], y2: item[:bottom] } }
60
+ )
61
+ end
62
+ end
63
+
64
+ def draw_positioned_segments(convert, positioned_segments, geometry)
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
+ )
77
+ draw_text_decoration(
78
+ convert,
79
+ item[:segment],
80
+ item[:x],
81
+ item[:width],
82
+ item[:top] + geometry[:scaled_font_size],
83
+ geometry
84
+ )
85
+ end
86
+ end
87
+
88
+ def segment_colors(segment)
89
+ foreground = segment.foreground ? theme.color_for(segment.foreground) : theme.colors[:foreground]
90
+ background = segment.background ? theme.color_for(segment.background) : nil
91
+
92
+ if segment.reverse
93
+ foreground, background = background || theme.colors[:background], foreground
94
+ end
95
+
96
+ foreground = color_with_opacity(foreground, 0.6, true) if segment.dim
97
+ [foreground, background]
98
+ end
99
+
100
+ def draw_text(convert, text, x, y, color, font_size, font_config, bold: false, italic: false)
101
+ convert.gravity "NorthWest"
102
+ convert.fill color
103
+ convert.stroke "none"
104
+ font = font_resolver.resolve(font_config, italic: italic)
105
+ convert.font font if font
106
+ convert.pointsize font_size
107
+ convert.weight(bold ? 700 : 400)
108
+ convert.style(italic ? "Italic" : "Normal")
109
+ convert.annotate "+#{x}+#{y}", escape_text(text)
110
+ end
111
+
112
+ def draw_text_decoration(convert, segment, x, width, baseline, geometry)
113
+ return unless segment.underline || segment.strikethrough || segment.overline
114
+
115
+ line_width = [(geometry[:scaled_font_size] / 12.0).ceil, 1].max
116
+ convert.stroke segment_colors(segment).first
117
+ convert.strokewidth line_width
118
+
119
+ if segment.underline
120
+ y = baseline + (geometry[:scaled_font_size] * 0.12).ceil
121
+ ImageMagickCommandBuilder.line(convert, x, y, x + width, y)
122
+ end
123
+ if segment.strikethrough
124
+ y = baseline - (geometry[:scaled_font_size] * 0.35).ceil
125
+ ImageMagickCommandBuilder.line(convert, x, y, x + width, y)
126
+ end
127
+ if segment.overline
128
+ y = baseline - geometry[:scaled_font_size]
129
+ ImageMagickCommandBuilder.line(convert, x, y, x + width, y)
130
+ end
131
+
132
+ convert.stroke "none"
133
+ end
134
+
135
+ def draw_selected_backgrounds(convert, geometry, content_y)
136
+ rectangles = geometry[:lines].each_with_index.each_with_object([]) do |(line, index), result|
137
+ next unless line[:selected]
138
+
139
+ x = geometry[:margin] + geometry[:scaled_padding]
140
+ top = content_y + index * geometry[:scaled_line_height]
141
+ result << {
142
+ x1: x,
143
+ y1: top,
144
+ x2: x + geometry[:scaled_width] - geometry[:scaled_padding] * 2,
145
+ y2: top + geometry[:scaled_line_height]
146
+ }
147
+ end
148
+ return if rectangles.empty?
149
+
150
+ convert.fill theme.colors[:selection]
151
+ ImageMagickCommandBuilder.rectangles(convert, rectangles)
152
+ end
153
+
154
+ def content_origin_y(geometry)
155
+ base_y = geometry[:margin] + geometry[:scaled_title_bar] + geometry[:scaled_padding]
156
+ base_y - (geometry[:scroll_offset].to_f * geometry[:scaled_line_height]).round
157
+ end
158
+
159
+ def fit_text(text, max_width, font_size)
160
+ return "" if max_width <= 0
161
+ return text if TextMetrics.pixel_width(text, font_size) <= max_width
162
+
163
+ max_cells = [(max_width / (font_size * 0.6)).floor - 3, 0].max
164
+ "#{TextMetrics.take_cells(text, max_cells)}..."
165
+ end
166
+
167
+ def color_with_opacity(color, opacity, allow_rgba)
168
+ return color unless allow_rgba && opacity < 1.0
169
+
170
+ if color.to_s.match?(/\A#[0-9a-fA-F]{6}\z/)
171
+ r = color[1, 2].to_i(16)
172
+ g = color[3, 2].to_i(16)
173
+ b = color[5, 2].to_i(16)
174
+ "rgba(#{r},#{g},#{b},#{opacity})"
175
+ else
176
+ color
177
+ end
178
+ end
179
+
180
+ def escape_text(text)
181
+ text.to_s
182
+ .encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
183
+ .gsub(/[\u0000-\u001f\u007f]/, " ")
184
+ end
185
+ end
186
+ end
187
+ end