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,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "text_metrics"
4
+
3
5
  module Shellfie
4
6
  class AnsiLineBuffer
5
- def initialize
7
+ CONTINUATION = Object.new.freeze
8
+
9
+ def initialize(tab_width: 8)
6
10
  @cells = []
7
11
  @column = 0
8
12
  @pending_escape = +""
13
+ @tab_width = tab_width
9
14
  end
10
15
 
11
16
  def write_escape(sequence)
@@ -18,13 +23,19 @@ module Shellfie
18
23
  @column = 0
19
24
  when "\b"
20
25
  @column = [@column - 1, 0].max
21
- @cells[@column] = nil
26
+ when "\t"
27
+ @column += @tab_width - (@column % @tab_width)
22
28
  when "\a"
23
29
  nil
24
30
  else
31
+ width = TextMetrics.grapheme_width(char)
32
+ return if width.zero?
33
+
34
+ clear_wide_cell(@column)
25
35
  @cells[@column] = "#{@pending_escape}#{char}"
36
+ @cells[@column + 1] = CONTINUATION if width == 2
26
37
  @pending_escape = +""
27
- @column += 1
38
+ @column += width
28
39
  end
29
40
  end
30
41
 
@@ -55,7 +66,7 @@ module Shellfie
55
66
  last = @cells.rindex { |cell| !cell.nil? }
56
67
  return @pending_escape unless last
57
68
 
58
- @cells[0..last].map { |cell| cell || " " }.join + @pending_escape
69
+ @cells[0..last].map { |cell| cell.equal?(CONTINUATION) ? "" : (cell || " ") }.join + @pending_escape
59
70
  end
60
71
 
61
72
  private
@@ -83,5 +94,10 @@ module Shellfie
83
94
  def clear_range(range)
84
95
  range.each { |index| @cells[index] = nil if index < @cells.length }
85
96
  end
97
+
98
+ def clear_wide_cell(column)
99
+ @cells[column - 1] = nil if column.positive? && @cells[column].equal?(CONTINUATION)
100
+ @cells[column + 1] = nil if @cells[column + 1].equal?(CONTINUATION)
101
+ end
86
102
  end
87
103
  end
@@ -5,20 +5,25 @@ require_relative "ansi_line_buffer"
5
5
 
6
6
  module Shellfie
7
7
  module AnsiNormalizer
8
- ANSI_REGEX = /\e\[([0-9;]*)m/
8
+ ANSI_REGEX = /\e\[([0-9;:]*)m/
9
9
  OSC_REGEX = /\e\].*?(?:\a|\e\\)/
10
- CSI_CONTROL_REGEX = /\e\[[0-9;?]*[A-Za-ln-z]/
10
+ STRING_CONTROL_REGEX = /\e(?:P|_|\^).*?(?:\a|\e\\)/m
11
+ INCOMPLETE_STRING_CONTROL_REGEX = /\e(?:P|_|\^).*\z/m
12
+ GRAPHICS_CONTROL_PREFIX = /\e(?:_G|P[0-9;?]*q|\]1337;File=)/
13
+ CSI_CONTROL_REGEX = /\e\[[0-9;:?]*[A-Za-ln-z]/
11
14
 
12
15
  module_function
13
16
 
14
- def normalize(text)
15
- text = text.gsub(OSC_REGEX, "")
16
- text = apply_line_controls(text)
17
+ def normalize(text, tab_width: 8, osc_policy: "ignore")
18
+ text = text.gsub(STRING_CONTROL_REGEX, "").gsub(INCOMPLETE_STRING_CONTROL_REGEX, "")
19
+ text = TextMetrics.graphemes(text).join
20
+ text = text.gsub(OSC_REGEX) { |sequence| osc_policy == "ignore" || !sequence.start_with?("\e]8;") ? "" : sequence }
21
+ text = apply_line_controls(text, tab_width: tab_width)
17
22
  text.gsub(CSI_CONTROL_REGEX, "")
18
23
  end
19
24
 
20
- def apply_line_controls(text)
21
- buffer = AnsiLineBuffer.new
25
+ def apply_line_controls(text, tab_width: 8)
26
+ buffer = AnsiLineBuffer.new(tab_width: tab_width)
22
27
  scanner = StringScanner.new(text)
23
28
 
24
29
  until scanner.eos?
@@ -27,6 +32,11 @@ module Shellfie
27
32
  next
28
33
  end
29
34
 
35
+ if scanner.scan(OSC_REGEX)
36
+ buffer.write_escape(scanner.matched)
37
+ next
38
+ end
39
+
30
40
  if scanner.scan(/\e\[([0-9;?]*)[JK]/)
31
41
  buffer.clear(scanner.matched[-1], scanner[1])
32
42
  next
@@ -42,7 +52,7 @@ module Shellfie
42
52
  next
43
53
  end
44
54
 
45
- buffer.write_character(scanner.getch)
55
+ buffer.write_character(scanner.scan(/\X/))
46
56
  end
47
57
 
48
58
  buffer.to_s
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "strscan"
4
+ require "uri"
4
5
  require_relative "ansi_colors"
5
6
  require_relative "ansi_normalizer"
7
+ require_relative "errors"
6
8
 
7
9
  module Shellfie
8
10
  Segment = Struct.new(
@@ -12,30 +14,57 @@ module Shellfie
12
14
  :bold,
13
15
  :italic,
14
16
  :underline,
17
+ :underline_style,
18
+ :underline_color,
15
19
  :dim,
16
20
  :reverse,
17
21
  :strikethrough,
18
22
  :overline,
23
+ :blink,
24
+ :conceal,
25
+ :link,
19
26
  keyword_init: true
20
27
  )
21
28
 
22
29
  class AnsiParser
23
- ANSI_REGEX = /\e\[([0-9;]*)m/
30
+ ANSI_REGEX = /\e\[([0-9;:]*)m/
24
31
 
25
- def initialize(state_mode: :persistent)
32
+ OSC_REGEX = /\e\](.*?)?(?:\a|\e\\)/m
33
+ MAX_LINK_BYTES = 2_048
34
+ MAX_OSC_BYTES = MAX_LINK_BYTES + 64
35
+ LINK_SCHEMES = %w[http https mailto].freeze
36
+
37
+ def initialize(state_mode: :persistent, tab_width: 8, osc_policy: "ignore", graphics_policy: "ignore")
26
38
  @state_mode = state_mode.to_sym
39
+ @tab_width = tab_width
40
+ @osc_policy = osc_policy.to_s
41
+ @graphics_policy = graphics_policy.to_s
42
+ @link = nil
43
+ @pending_osc = +""
27
44
  reset_state
28
45
  end
29
46
 
30
47
  def parse(text)
31
- reset_state if @state_mode == :line
48
+ if @state_mode == :line
49
+ reset_state
50
+ @link = nil
51
+ end
32
52
 
53
+ input = @pending_osc + text.to_s
54
+ reject_terminal_graphics!(input)
55
+ input, @pending_osc = split_incomplete_osc(input)
33
56
  segments = []
34
- scanner = StringScanner.new(AnsiNormalizer.normalize(text.to_s))
57
+ scanner = StringScanner.new(AnsiNormalizer.normalize(input, tab_width: @tab_width, osc_policy: @osc_policy))
35
58
  current_text = +""
36
59
 
37
60
  until scanner.eos?
38
- if scanner.scan(ANSI_REGEX)
61
+ if scanner.scan(OSC_REGEX)
62
+ unless current_text.empty?
63
+ segments << create_segment(current_text)
64
+ current_text = +""
65
+ end
66
+ process_osc(scanner[1])
67
+ elsif scanner.scan(ANSI_REGEX)
39
68
  unless current_text.empty?
40
69
  segments << create_segment(current_text)
41
70
  current_text = +""
@@ -52,16 +81,26 @@ module Shellfie
52
81
 
53
82
  private
54
83
 
84
+ def reject_terminal_graphics!(text)
85
+ return unless @graphics_policy == "error" && text.match?(AnsiNormalizer::GRAPHICS_CONTROL_PREFIX)
86
+
87
+ raise ValidationError, "Terminal graphics are not supported; use window.graphics_policy: ignore to discard them"
88
+ end
89
+
55
90
  def reset_state
56
91
  @foreground = nil
57
92
  @background = nil
58
93
  @bold = false
59
94
  @italic = false
60
95
  @underline = false
96
+ @underline_style = nil
97
+ @underline_color = nil
61
98
  @dim = false
62
99
  @reverse = false
63
100
  @strikethrough = false
64
101
  @overline = false
102
+ @blink = false
103
+ @conceal = false
65
104
  end
66
105
 
67
106
  def create_segment(text)
@@ -72,22 +111,34 @@ module Shellfie
72
111
  bold: @bold,
73
112
  italic: @italic,
74
113
  underline: @underline,
114
+ underline_style: @underline_style,
115
+ underline_color: @underline_color,
75
116
  dim: @dim,
76
117
  reverse: @reverse,
77
118
  strikethrough: @strikethrough,
78
- overline: @overline
119
+ overline: @overline,
120
+ blink: @blink,
121
+ conceal: @conceal,
122
+ link: @link
79
123
  )
80
124
  end
81
125
 
82
126
  def process_codes(codes_str)
83
127
  return reset_state if codes_str.empty?
84
128
 
85
- codes = codes_str.split(";").map { |code| code.empty? ? 0 : code.to_i }
129
+ codes = sgr_codes(codes_str)
86
130
  i = 0
87
131
 
88
132
  while i < codes.length
89
133
  code = codes[i]
90
134
 
135
+ if code.is_a?(Array) && code.first == :underline_style
136
+ @underline_style = %i[none single double curly dotted dashed][code.last]
137
+ @underline = @underline_style && @underline_style != :none
138
+ i += 1
139
+ next
140
+ end
141
+
91
142
  case code
92
143
  when 0
93
144
  reset_state
@@ -99,6 +150,11 @@ module Shellfie
99
150
  @italic = true
100
151
  when 4
101
152
  @underline = true
153
+ @underline_style = :single
154
+ when 5, 6
155
+ @blink = true
156
+ when 8
157
+ @conceal = true
102
158
  when 7
103
159
  @reverse = true
104
160
  when 9
@@ -110,8 +166,13 @@ module Shellfie
110
166
  @italic = false
111
167
  when 24
112
168
  @underline = false
169
+ @underline_style = nil
170
+ when 25
171
+ @blink = false
113
172
  when 27
114
173
  @reverse = false
174
+ when 28
175
+ @conceal = false
115
176
  when 29
116
177
  @strikethrough = false
117
178
  when 30..37, 90..97
@@ -126,15 +187,67 @@ module Shellfie
126
187
  i, @background = AnsiColors.parse_extended_color(codes, i)
127
188
  when 49
128
189
  @background = nil
190
+ when 21
191
+ @underline = true
192
+ @underline_style = :double
129
193
  when 53
130
194
  @overline = true
131
195
  when 55
132
196
  @overline = false
197
+ when 58
198
+ i, @underline_color = AnsiColors.parse_extended_color(codes, i)
199
+ when 59
200
+ @underline_color = nil
133
201
  end
134
202
 
135
203
  i += 1
136
204
  end
137
205
  end
138
206
 
207
+ def sgr_codes(value)
208
+ value.split(";").flat_map do |field|
209
+ parts = field.split(":", -1)
210
+ next(field.empty? ? 0 : field.to_i) if parts.size == 1
211
+
212
+ code, mode = parts.values_at(0, 1).map(&:to_i)
213
+ if code == 4 && parts.size == 2 && parts.last.match?(/\A[0-5]\z/)
214
+ [[:underline_style, parts.last.to_i]]
215
+ elsif [38, 48, 58].include?(code) && mode == 2 && [5, 6].include?(parts.size) && parts.last(3).all? { |item| item.match?(/\A\d+\z/) }
216
+ [code, mode, *parts.last(3).map(&:to_i)]
217
+ elsif [38, 48, 58].include?(code) && mode == 5 && parts.size == 3 && parts.last.match?(/\A\d+\z/)
218
+ [code, mode, parts.last.to_i]
219
+ else
220
+ []
221
+ end
222
+ end
223
+ end
224
+
225
+ def process_osc(value)
226
+ return unless value.to_s.start_with?("8;")
227
+
228
+ uri = value.to_s.split(";", 3)[2].to_s
229
+ @link = uri.empty? ? nil : safe_link(uri)
230
+ end
231
+
232
+ def safe_link(value)
233
+ return if value.bytesize > MAX_LINK_BYTES || value.match?(/[\x00-\x1f\x7f]/)
234
+
235
+ uri = URI.parse(value)
236
+ value if LINK_SCHEMES.include?(uri.scheme&.downcase)
237
+ rescue URI::InvalidURIError
238
+ nil
239
+ end
240
+
241
+ def split_incomplete_osc(value)
242
+ start = value.rindex("\e]")
243
+ return [value, +""] unless start
244
+
245
+ tail = value[start..]
246
+ return [value, +""] if tail.match?(OSC_REGEX)
247
+ return [value[0...start], tail] if tail.bytesize <= MAX_OSC_BYTES
248
+
249
+ [value[0...start], +""]
250
+ end
251
+
139
252
  end
140
253
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "errors"
5
+ require_relative "output_writer"
6
+ require_relative "session"
7
+
8
+ module Shellfie
9
+ class Cassette
10
+ MAX_BYTES = 32 * 1_048_576
11
+ MAX_EVENTS = 10_000
12
+ TOP_LEVEL_KEYS = %i[version title columns rows events captures exit_status].freeze
13
+ EVENT_KEYS = %i[text delay visible status screen].freeze
14
+
15
+ def self.write(path, session)
16
+ OutputWriter.write(path, extension: "json") do |temporary_path|
17
+ json = JSON.pretty_generate(session.to_h)
18
+ raise ResourceLimitError, "Cassette is too large (max #{MAX_BYTES} bytes)" if json.bytesize > MAX_BYTES
19
+
20
+ File.write(temporary_path, json)
21
+ end
22
+ end
23
+
24
+ def self.read(path)
25
+ raise ParseError, "Cassette file not found: #{path}" unless File.file?(path)
26
+ content = File.open(path, "rb") { |file| file.read(MAX_BYTES + 1) }
27
+ raise ParseError, "Cassette is too large (max #{MAX_BYTES} bytes)" if content.bytesize > MAX_BYTES
28
+
29
+ raw = JSON.parse(content, symbolize_names: true)
30
+ validate!(raw)
31
+
32
+ Session.new(
33
+ columns: raw[:columns],
34
+ rows: raw[:rows],
35
+ title: raw[:title],
36
+ events: raw[:events] || [],
37
+ captures: raw[:captures] || {},
38
+ exit_status: raw[:exit_status]
39
+ )
40
+ rescue JSON::ParserError => e
41
+ raise ParseError, "Invalid cassette: #{e.message}"
42
+ end
43
+
44
+ def self.validate!(raw)
45
+ raise ParseError, "Cassette must be a JSON object" unless raw.is_a?(Hash)
46
+ raise ParseError, "Unsupported cassette version" unless raw[:version] == 1
47
+ raise ParseError, "Unknown cassette key" unless (raw.keys - TOP_LEVEL_KEYS).empty?
48
+ raise ParseError, "Cassette title must be a string" unless raw[:title].is_a?(String)
49
+ unless raw[:columns].is_a?(Integer) && raw[:columns].between?(1, 500) &&
50
+ raw[:rows].is_a?(Integer) && raw[:rows].between?(1, 200)
51
+ raise ParseError, "Invalid cassette dimensions"
52
+ end
53
+
54
+ events = raw[:events] || []
55
+ raise ParseError, "Cassette events must be an array" unless events.is_a?(Array)
56
+ raise ParseError, "Cassette has too many events" if events.size > MAX_EVENTS
57
+ events.each_with_index do |event, index|
58
+ unless event.is_a?(Hash) && (event.keys - EVENT_KEYS).empty? && event[:text].is_a?(String) &&
59
+ event[:delay].is_a?(Numeric) && event[:delay].finite? && event[:delay] >= 0 &&
60
+ [true, false].include?(event.fetch(:visible, true)) &&
61
+ (!event.key?(:screen) || event[:screen].is_a?(Array) && event[:screen].all?(String)) &&
62
+ (event[:status].nil? || event[:status].is_a?(Integer) && event[:status].between?(0, 255))
63
+ raise ParseError, "Invalid cassette event at index #{index}"
64
+ end
65
+ end
66
+
67
+ captures = raw[:captures] || {}
68
+ unless captures.is_a?(Hash) && captures.all? { |name, lines| name.is_a?(Symbol) && lines.is_a?(Array) && lines.all?(String) }
69
+ raise ParseError, "Cassette captures must map names to arrays of strings"
70
+ end
71
+ unless raw[:exit_status].nil? || raw[:exit_status].is_a?(Integer) && raw[:exit_status].between?(0, 255)
72
+ raise ParseError, "Invalid cassette exit status"
73
+ end
74
+ end
75
+ end
76
+ end
data/lib/shellfie/cli.rb CHANGED
@@ -4,14 +4,19 @@ require "optparse"
4
4
  require_relative "../shellfie"
5
5
  require_relative "cli_generate"
6
6
  require_relative "cli_info"
7
+ require_relative "cli_run"
8
+ require_relative "cli_authoring"
7
9
  require_relative "dependency_checker"
10
+ require_relative "transcript_renderer"
8
11
 
9
12
  module Shellfie
10
13
  class CLI
11
14
  include CLIGenerate
12
15
  include CLIInfo
16
+ include CLIRun
17
+ include CLIAuthoring
13
18
 
14
- COMMANDS = %w[generate init themes validate inspect doctor version help].freeze
19
+ COMMANDS = %w[generate run record replay new format compile schema completion watch init themes validate inspect doctor version help].freeze
15
20
 
16
21
  def initialize(args)
17
22
  @args = args.dup
@@ -28,6 +33,24 @@ module Shellfie
28
33
  run_generate
29
34
  when "init"
30
35
  run_init
36
+ when "run"
37
+ run_session
38
+ when "record"
39
+ run_session(record: true)
40
+ when "replay"
41
+ replay_session
42
+ when "new"
43
+ run_new
44
+ when "format"
45
+ run_format
46
+ when "compile"
47
+ run_compile
48
+ when "schema"
49
+ run_schema
50
+ when "completion"
51
+ run_completion
52
+ when "watch"
53
+ run_watch
31
54
  when "themes"
32
55
  run_themes
33
56
  when "validate"
@@ -46,15 +69,53 @@ module Shellfie
46
69
  exit 1
47
70
  end
48
71
  rescue Shellfie::Error => e
49
- warn_error "Error: #{e.message}"
72
+ if @options[:validation_format] && @options[:validation_format] != "text"
73
+ emit_validation_report(valid: false, error: e)
74
+ else
75
+ warn_error "Error: #{e.message}"
76
+ end
50
77
  exit determine_exit_code(e)
51
78
  rescue OptionParser::ParseError => e
52
79
  warn_error "Error: #{e.message}"
53
80
  exit 1
81
+ rescue SystemCallError => e
82
+ warn_error "Error: #{e.message}"
83
+ exit 5
54
84
  end
55
85
 
56
86
  private
57
87
 
88
+ def canonical_output_path(path)
89
+ expanded = File.expand_path(path)
90
+ missing = []
91
+ current = expanded
92
+ until File.exist?(current)
93
+ parent = File.dirname(current)
94
+ break if parent == current
95
+
96
+ missing.unshift(File.basename(current))
97
+ current = parent
98
+ end
99
+ File.join(File.realpath(current), *missing)
100
+ rescue Errno::ENOENT
101
+ expanded
102
+ end
103
+
104
+ def configuration_version(path)
105
+ raw = YamlSafety.load_file(path, max_bytes: Parser::MAX_INCLUDE_BYTES)
106
+ raw.is_a?(Hash) ? raw[:version] : nil
107
+ end
108
+
109
+ def replaceable_png_sequence_directory?(path)
110
+ entries = Dir.children(path)
111
+ entries.empty? || (entries.include?("timeline.json") &&
112
+ entries.all? { |entry| entry == "timeline.json" || entry.match?(/\Aframe_\d{4}\.png\z/) })
113
+ end
114
+
115
+ def path_within?(path, directory)
116
+ path == directory || path.start_with?("#{directory}#{File::SEPARATOR}")
117
+ end
118
+
58
119
  def warn_error(message)
59
120
  $stderr.puts message
60
121
  end
@@ -69,6 +130,8 @@ module Shellfie
69
130
  4
70
131
  when FileSystemError
71
132
  5
133
+ when ExecutionError
134
+ 6
72
135
  else
73
136
  1
74
137
  end