captive 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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bf5006d6ac1443e871a8d081974beaa6eabbf72546a25e2fa7aadefd6b9c50f7
4
+ data.tar.gz: 0f4eadcf1a9cb07883cdf46f79ad371095339b8e2182ae842fbe0ffb1c833a47
5
+ SHA512:
6
+ metadata.gz: a3e7f5db587122c189bf7253e02ed79b0e4df13039aa3689d1b0a8c9062b16308bae85d5480b2b1574fb105688274c5dc0abaf8864416a14e8886ac02b4c36f2
7
+ data.tar.gz: 6622b6df7e11103bea6a511b2cf53e918023955e6e6fbb1fa3cbb7d6f81373f4896cb6504754eeaf0bff717f5608f64360dc050ae1f0e750c0aa6577da4a9c85
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 navin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,94 @@
1
+ # Captive - Ruby Subtitle Editor and Converter
2
+
3
+ Captive can read subtitles from various formats. Subtitles can be modified and exported to another format as well as JSON. Captive currently supports `SRT` and `WebVTT` formats.
4
+
5
+ ## Supported Features
6
+
7
+ - Read subtitles
8
+ - Convert to another format (SRT, WebVTT)
9
+ - Save to file
10
+ - Serialize as JSON
11
+
12
+
13
+ ## Usage
14
+
15
+ **Parse subtitles**
16
+
17
+ Subtitles can be read from a file using `from_file` and providing a filename
18
+
19
+ ```ruby
20
+ s = Captive::SRT.from_file(filename: 'test.srt')
21
+
22
+ s = Captive::VTT.from_file(filename: 'test.vtt')
23
+ ```
24
+
25
+ Alternately, subtitles can be parsed from a blob of text if you don't want to load from a file
26
+ ```ruby
27
+ s = Captive::SRT.from_blob(filename: 'test.srt')
28
+
29
+ s = Captive::VTT.from_blob(filename: 'test.vtt')
30
+ ```
31
+
32
+ Or to get instantiate an empty captive object simply
33
+ ```ruby
34
+ s = Captive::SRT.new
35
+
36
+ s = Captive::VTT.new
37
+ ```
38
+
39
+ **Save to File**
40
+
41
+ Subtitles can be saved to a file using `save_as`
42
+ ```ruby
43
+ s = Captive::VTT.from_file(filename: 'test.vtt')
44
+ s.save_as(filaname: 'output.vtt')
45
+ ```
46
+
47
+ **Serializing as JSON**
48
+
49
+ Need to store your subtitle data in a format agnostic way? `as_json` is your friend
50
+ ```ruby
51
+ s = Captive::VTT.from_file(filename: 'test.vtt')
52
+ s.as_json
53
+ ```
54
+
55
+ **Switch Formats**
56
+
57
+ Subtitles parsed in one format can be converted to another format. Currently, **SRT** and **WebVTT** are supported.
58
+
59
+ ```ruby
60
+ s = Captive::SRT.new('test.srt')
61
+ vtt = s.as_vtt # Will return a Captive::VTT instance
62
+ vtt.save_as(filename: 'conversion.vtt')
63
+ ```
64
+
65
+
66
+ ## Installation
67
+
68
+ Simply add `captive` to your Gemfile
69
+
70
+ ```ruby
71
+ gem 'captive'
72
+ ```
73
+
74
+ Or install it yourself with:
75
+
76
+ $ gem install captive
77
+
78
+ ## Dependencies
79
+
80
+ Captive is lightweight and has no external dependencies.
81
+
82
+ ## Development
83
+
84
+ After cloning the repo, run `bundle install` to get the development dependencies. Use `bin/console` to spin up an IRB instance with captive loaded.
85
+
86
+ ## Contributing
87
+
88
+ Bug reports and pull requests are welcome on GitHub at https://github.com/mserran2/captive.
89
+
90
+
91
+ ## License
92
+
93
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
94
+
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'captive'
6
+ require 'irb'
7
+ IRB.start
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'captive/version'
4
+ require 'captive/error'
5
+ require 'captive/util'
6
+ require 'captive/base'
7
+ require 'captive/cue'
8
+ require 'captive/formats/vtt'
9
+ require 'captive/formats/srt'
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Base
5
+ module ClassMethods
6
+ def from_file(filename:)
7
+ file = File.new(filename, 'r:bom|utf-8')
8
+ blob = file.read
9
+ from_blob(blob: blob)
10
+ ensure
11
+ file&.close
12
+ end
13
+
14
+ def from_blob(blob:)
15
+ new(cue_list: parse(blob: blob))
16
+ end
17
+ end
18
+
19
+ def self.included(base)
20
+ base.extend(ClassMethods)
21
+ base.include(Util)
22
+ end
23
+
24
+ attr_accessor(:cue_list)
25
+
26
+ def initialize(cue_list: nil)
27
+ @cue_list = cue_list || []
28
+ end
29
+
30
+ def save_as(filename:)
31
+ File.open(filename, 'w') do |file|
32
+ file.write(to_s)
33
+ end
34
+ end
35
+
36
+ def as_json(**args)
37
+ results = {
38
+ 'version' => VERSION,
39
+ 'cues' => @cue_list.map(&:as_json),
40
+ }
41
+ if results.respond_to?(:as_json)
42
+ results.as_json(**args)
43
+ else
44
+ results
45
+ end
46
+ end
47
+
48
+ def method_missing(method_name, *_args)
49
+ super unless (match = /^as_([a-z]+)$/.match(method_name))
50
+
51
+ if valid_format?(match.captures.first.upcase)
52
+ define_format_conversion(method_name, match.captures.first.upcase)
53
+ send(method_name)
54
+ elsif valid_format?(match.captures.first.capitalize)
55
+ define_format_conversion(method_name, match.captures.first.capitalize)
56
+ send(method_name)
57
+ else
58
+ super
59
+ end
60
+ end
61
+
62
+ def respond_to_missing?(method_name, _)
63
+ super unless (match = /^as_([a-z]+)$/.match(method_name))
64
+
65
+ return true if valid_format?(match.captures.first.upcase) || valid_format?(match.captures.first.capitalize)
66
+
67
+ super
68
+ end
69
+
70
+ private
71
+
72
+ def base_klass
73
+ base = self.class.to_s.split('::')
74
+ Kernel.const_get(base.first)
75
+ end
76
+
77
+ def valid_format?(format)
78
+ base_klass.const_defined?(format) && base_klass.const_get(format).include?(Base)
79
+ end
80
+
81
+ def define_format_conversion(method_name, format)
82
+ self.class.define_method(method_name) do
83
+ if self.class.to_s.split('::').last == format
84
+ self
85
+ else
86
+ base_klass.const_get(format).new(cue_list: cue_list)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ class Cue
5
+ include Util
6
+
7
+ # Text Properties supported
8
+ ALIGNMENT = 'alignment'
9
+ COLOR = 'color'
10
+ POSITION = 'position'
11
+
12
+ # List of Text Properties
13
+ TEXT_PROPERTIES = [ALIGNMENT, COLOR, POSITION].freeze
14
+
15
+ attr_accessor :number, :text, :properties
16
+ attr_reader :start_time, :end_time
17
+
18
+ # Creates a new Cue class denoting a subtitle.
19
+ def initialize(text: nil, start_time: nil, end_time: nil, cue_number: nil, properties: {})
20
+ self.text = text
21
+ self.start_time = start_time
22
+ self.end_time = end_time
23
+ self.number = cue_number
24
+ self.properties = properties || {}
25
+ end
26
+
27
+ def start_time=(time)
28
+ set_time(:start_time, time)
29
+ end
30
+
31
+ def end_time=(time)
32
+ set_time(:end_time, time)
33
+ end
34
+
35
+ def set_times(start_time:, end_time:)
36
+ self.start_time = start_time
37
+ self.end_time = end_time
38
+ end
39
+
40
+ # Getter and Setter methods for Text Properties
41
+ TEXT_PROPERTIES.each do |setting|
42
+ define_method :"#{setting}" do
43
+ return properties[setting] if properties[setting].present?
44
+
45
+ return nil
46
+ end
47
+
48
+ define_method :"#{setting}=" do |value|
49
+ properties[setting] = value
50
+ end
51
+ end
52
+
53
+ def duration
54
+ end_time - start_time
55
+ end
56
+
57
+ # Adds text. If text is already present, new-line is added before text.
58
+ def add_text(text)
59
+ if self.text.nil?
60
+ self.text = text
61
+ else
62
+ self.text += "\n" + text
63
+ end
64
+ end
65
+
66
+ def <=>(other)
67
+ start_time <=> other.start_time
68
+ end
69
+
70
+ def as_json(**args)
71
+ if respond_to?(:instance_values) && instance_values.respond_to?(:as_json)
72
+ instance_values.as_json(**args)
73
+ else
74
+ instance_variables.each_with_object({}) { |key, hash| hash[key[1..-1]] = instance_variable_get(key) }
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def set_time(field, time)
81
+ return if time.nil?
82
+
83
+ if time.is_a?(Integer)
84
+ instance_variable_set("@#{field}", time)
85
+ elsif TIMECODE_REGEX.match(time)
86
+ instance_variable_set("@#{field}", timecode_to_milliseconds(time))
87
+ else
88
+ raise InvalidInput, "Input for #{field} should be an integer denoting milliseconds or a valid timecode."
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ # Base error class.
5
+ class CaptiveError < StandardError
6
+ end
7
+
8
+ # Error denoting a malformed string during subtitle parsing.
9
+ class MalformedString < CaptiveError
10
+ end
11
+
12
+ # Error denoting a malformed subtitle input format.
13
+ class InvalidSubtitle < CaptiveError
14
+ end
15
+
16
+ # Error denoting incorrect input to a method.
17
+ class InvalidInput < CaptiveError
18
+ end
19
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ class SRT
5
+ include Base
6
+
7
+ def self.parse(blob:)
8
+ cue_list = []
9
+ lines = blob.split("\n")
10
+ count = 0
11
+ state = :new_cue
12
+ cue = nil
13
+
14
+ lines.each_with_index do |line, _index|
15
+ line.strip!
16
+ case state
17
+ when :new_cue
18
+ next if line.empty? ## just another blank line, remain in new_cue state
19
+
20
+ raise InvalidSubtitle, "Invalid Cue Number at line #{count}" if /^\d+$/.match(line).nil?
21
+
22
+ cue = Cue.new(cue_number: line.to_i)
23
+ state = :time
24
+ when :time
25
+ raise InvalidSubtitle, "Invalid Time Format at line #{count}" unless timecode?(line)
26
+
27
+ start_time, end_time = line.split('-->').map(&:strip)
28
+ cue.set_times(
29
+ start_time: format_time(start_time),
30
+ end_time: format_time(end_time)
31
+ )
32
+ state = :text
33
+ when :text
34
+ if line.empty?
35
+ ## end of previous cue
36
+ cue_list << cue
37
+ cue = nil
38
+ state = :new_cue
39
+ else
40
+ cue.add_text(line)
41
+ end
42
+ end
43
+ end
44
+
45
+ # Check to make sure we add the last cue if for some reason the file lacks an empty line at the end
46
+ cue_list << cue unless cue.nil?
47
+
48
+ # Return the cue_list
49
+ cue_list
50
+ end
51
+
52
+ def to_s
53
+ string = String.new
54
+ @cue_list.each do |cue|
55
+ string << cue.number.to_s
56
+ string << "\n"
57
+ string << milliseconds_to_timecode(cue.start_time).gsub!('.', ',')
58
+ string << ' --> '
59
+ string << milliseconds_to_timecode(cue.end_time).gsub!('.', ',')
60
+ string << "\n"
61
+ string << cue.text
62
+ string << "\n\n"
63
+ end
64
+ string
65
+ end
66
+
67
+ def self.format_time(text)
68
+ text.strip.gsub(/,/, '.')
69
+ end
70
+
71
+ def self.timecode?(text)
72
+ !!text.match(/^\d{2,}:\d{2}:\d{2},\d{3}.*\d{2,}:\d{2}:\d{2},\d{3}$/)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ class VTT
5
+ include Base
6
+
7
+ # Standard VTT Header
8
+ VTT_HEADER = 'WEBVTT'
9
+
10
+ # VTT METADATA Regex
11
+ VTT_METADATA = /^NOTE|^STYLE/.freeze
12
+
13
+ # Parse VTT blob and return array of cues
14
+ def self.parse(blob:)
15
+ cue_list = []
16
+ lines = blob.split("\n")
17
+ cue_count = 1
18
+ state = :new_cue
19
+ cue = nil
20
+ raise InvalidSubtitle, 'Invalid VTT Signature' unless validate_header(lines.shift)
21
+
22
+ lines.each_with_index do |line, index|
23
+ line.strip!
24
+
25
+ case state
26
+ when :new_cue
27
+ next if line.empty?
28
+
29
+ if metadata?(line)
30
+ state = :metadata
31
+ next
32
+ end
33
+
34
+ # If its not metadata, and its not an empty line, it should be a timestamp
35
+ raise InvalidSubtitle, "Invalid Time Format at line #{index}" unless time?(line)
36
+
37
+ elements = line.split
38
+ start_time = elements[0]
39
+ end_time = elements[2]
40
+ cue = Cue.new(cue_number: cue_count, start_time: start_time, end_time: end_time)
41
+ cue_count += 1
42
+ state = :text
43
+ when :text
44
+ if line.empty?
45
+ ## end of previous cue
46
+ cue_list << cue
47
+ cue = nil
48
+ state = :new_cue
49
+ else
50
+ cue.add_text(line)
51
+ end
52
+ when :metadata
53
+ next unless line.empty?
54
+
55
+ # Line is empty which means metadata block is over
56
+ state = :new_cue
57
+ end
58
+ end
59
+
60
+ # Check to make sure we add the last cue if for some reason the file lacks an empty line at the end
61
+ cue_list << cue unless cue.nil?
62
+
63
+ # Return the cue_list
64
+ cue_list
65
+ end
66
+
67
+ # Dump contents to String
68
+ def to_s
69
+ string = VTT_HEADER.dup
70
+ string << "\n\n"
71
+ @cue_list.each do |cue|
72
+ string << milliseconds_to_timecode(cue.start_time)
73
+ string << ' --> '
74
+ string << milliseconds_to_timecode(cue.end_time)
75
+ string << "\n"
76
+ string << cue.text
77
+ string << "\n\n"
78
+ end
79
+ string
80
+ end
81
+
82
+ # VTT Header tag matcher
83
+ def self.validate_header(line)
84
+ !!line.strip.match(/^#{VTT_HEADER}/)
85
+ end
86
+
87
+ # VTT Metadata tag matcher
88
+ def self.metadata?(text)
89
+ !!text.match(VTT_METADATA)
90
+ end
91
+
92
+ # VTT Timecode matcher
93
+ def self.time?(text)
94
+ !!text.match(/^(\d{2}:)?\d{2}:\d{2}.\d{3}.*(\d{2}:)?\d{2}:\d{2}.\d{3}/)
95
+ end
96
+
97
+ def self.integer?(val)
98
+ val.to_i.to_s == val
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Util
5
+ TIMECODE_REGEX = /^(\d{2,}:)?[0-5]\d:[0-5]\d(\.\d{1,3})?$/.freeze
6
+
7
+ # Converts timecode in HH:MM:SS.MSEC (or) MM:SS.MSEC to milliseconds.
8
+ def timecode_to_milliseconds(timecode)
9
+ raise InvalidInput, 'Input should be a valid Timecode' unless TIMECODE_REGEX.match(timecode)
10
+
11
+ timecode_split = timecode.split('.')
12
+ time_split = timecode_split[0].split(':')
13
+
14
+ # To handle MM:SS.MSEC format
15
+ time_split.unshift('00') if time_split.length == 2
16
+
17
+ # Get HH:MM:SS in seconds
18
+ seconds = time_split[-1].to_i
19
+ seconds += minutes_to_seconds(time_split[-2].to_i)
20
+ seconds += hours_to_seconds(time_split[-3].to_i)
21
+
22
+ milliseconds = seconds_to_milliseconds(seconds)
23
+
24
+ # Millisecond component exists. Pad it to make sure its a full 3 digits
25
+ milliseconds += timecode_split[1].ljust(3, '0').to_i if timecode_split[1]
26
+
27
+ milliseconds
28
+ end
29
+
30
+ def minutes_to_seconds(minutes)
31
+ minutes * 60
32
+ end
33
+
34
+ def hours_to_seconds(hours)
35
+ minutes_to_seconds(hours * 60)
36
+ end
37
+
38
+ def seconds_to_milliseconds(seconds)
39
+ seconds * 1000
40
+ end
41
+
42
+ # Converts milliseconds to timecode format and returns HH+:MM:SS.MSEC where hours are two or more characters.
43
+ def milliseconds_to_timecode(milliseconds)
44
+ ms_in_a_second = 1000
45
+ ms_in_a_minute = ms_in_a_second * 60
46
+ ms_in_an_hour = ms_in_a_minute * 60
47
+
48
+ hours, remaider = milliseconds.divmod(ms_in_an_hour)
49
+ minutes, remaider = remaider.divmod(ms_in_a_minute)
50
+ seconds, milliseconds = remaider.divmod(ms_in_a_second)
51
+
52
+ format('%<h>02d:%<m>02d:%<s>02d.%<ms>03d', { h: hours, m: minutes, s: seconds, ms: milliseconds })
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ VERSION = '1.0.0'
5
+ end
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: captive
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - mserran2
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-07-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '12.3'
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 12.3.3
37
+ type: :development
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - "~>"
42
+ - !ruby/object:Gem::Version
43
+ version: '12.3'
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 12.3.3
47
+ - !ruby/object:Gem::Dependency
48
+ name: rspec
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.9'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.9'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rubocop
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.87'
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: 0.87.1
71
+ type: :development
72
+ prerelease: false
73
+ version_requirements: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - "~>"
76
+ - !ruby/object:Gem::Version
77
+ version: '0.87'
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: 0.87.1
81
+ description: Captive allows for parsing and converting of subtitles across different
82
+ formats in addition to JSON
83
+ email:
84
+ - mark@markserrano.me
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - LICENSE.txt
90
+ - README.md
91
+ - bin/console
92
+ - lib/captive.rb
93
+ - lib/captive/base.rb
94
+ - lib/captive/cue.rb
95
+ - lib/captive/error.rb
96
+ - lib/captive/formats/srt.rb
97
+ - lib/captive/formats/vtt.rb
98
+ - lib/captive/util.rb
99
+ - lib/captive/version.rb
100
+ homepage: https://github.com/mserran2/captive
101
+ licenses:
102
+ - MIT
103
+ metadata: {}
104
+ post_install_message:
105
+ rdoc_options: []
106
+ require_paths:
107
+ - lib
108
+ required_ruby_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ required_rubygems_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ requirements: []
119
+ rubyforge_project:
120
+ rubygems_version: 2.7.6
121
+ signing_key:
122
+ specification_version: 4
123
+ summary: Ruby Subtitle Manager, Editor and Converter
124
+ test_files: []