subconv 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.
- checksums.yaml +5 -5
- data/.gitignore +1 -1
- data/.gitlab-ci.yml +33 -0
- data/.rubocop.yml +26 -30
- data/Gemfile +2 -0
- data/Gemfile.lock +73 -0
- data/README.md +5 -8
- data/Rakefile +3 -1
- data/bin/subconv +7 -1
- data/lib/subconv.rb +2 -0
- data/lib/subconv/caption.rb +5 -0
- data/lib/subconv/caption_filter.rb +6 -4
- data/lib/subconv/scc/reader.rb +190 -62
- data/lib/subconv/scc/transformer.rb +64 -33
- data/lib/subconv/utility.rb +4 -1
- data/lib/subconv/version.rb +3 -1
- data/lib/subconv/webvtt/writer.rb +44 -33
- data/spec/.rubocop.yml +8 -0
- data/spec/caption_filter_spec.rb +2 -0
- data/spec/scc/reader_spec.rb +140 -65
- data/spec/scc/transformer_spec.rb +168 -48
- data/spec/spec_helper.rb +8 -2
- data/spec/test_helpers.rb +18 -0
- data/spec/webvtt/writer_spec.rb +16 -14
- data/subconv.gemspec +11 -11
- metadata +38 -35
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 6348b3b43fd4b191b25d166007dd539d18d3e72c13f32cb23e7feb839c06d665
|
4
|
+
data.tar.gz: 8a0b8177d72cf42ff45649e28d27c333ad223072193181bcb023a6349c8e6ee5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d805a1a29e705a9fbc23ec481484600f99ef844fc51422469a1768d255d2894e8c7c06fe9c45079cf97b99a86b5bbb34df0c7c09083ac5cd493d084ebce605a7
|
7
|
+
data.tar.gz: bfbe89e2915ffc77484521d8e291cb8f58685dc8e5bf819c34c62bb8344b03c187dfcedce55363003ab1565d7eb59694f4cee1aee85c2a9e9b9cee84cb1d6ac6
|
data/.gitignore
CHANGED
data/.gitlab-ci.yml
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
before_script:
|
2
|
+
- apt-get update -qq
|
3
|
+
- ruby -v
|
4
|
+
- which ruby
|
5
|
+
- gem install bundler --no-document --version "~>1.0"
|
6
|
+
- bundle install --jobs $(nproc) "${FLAGS[@]}"
|
7
|
+
|
8
|
+
rspec:
|
9
|
+
script:
|
10
|
+
- bundle exec rake spec
|
11
|
+
|
12
|
+
rubocop:
|
13
|
+
script:
|
14
|
+
- bundle exec rake rubocop
|
15
|
+
|
16
|
+
dependency_scanning:
|
17
|
+
before_script: []
|
18
|
+
image: docker:stable
|
19
|
+
variables:
|
20
|
+
DOCKER_DRIVER: overlay2
|
21
|
+
allow_failure: true
|
22
|
+
services:
|
23
|
+
- docker:stable-dind
|
24
|
+
script:
|
25
|
+
- export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
|
26
|
+
- docker run
|
27
|
+
--env DEP_SCAN_DISABLE_REMOTE_CHECKS="${DEP_SCAN_DISABLE_REMOTE_CHECKS:-false}"
|
28
|
+
--volume "$PWD:/code"
|
29
|
+
--volume /var/run/docker.sock:/var/run/docker.sock
|
30
|
+
"registry.gitlab.com/gitlab-org/security-products/dependency-scanning:$SP_VERSION" /code
|
31
|
+
artifacts:
|
32
|
+
reports:
|
33
|
+
dependency_scanning: gl-dependency-scanning-report.json
|
data/.rubocop.yml
CHANGED
@@ -1,20 +1,14 @@
|
|
1
1
|
AllCops:
|
2
|
-
TargetRubyVersion: 2.
|
2
|
+
TargetRubyVersion: 2.4
|
3
3
|
Exclude:
|
4
4
|
- '**/bin/**/*'
|
5
|
-
- '**/db/schema.rb'
|
6
5
|
- '**/config/**/*'
|
7
6
|
- '**/lib/tasks/*.rake'
|
7
|
+
- 'vendor/**/*'
|
8
8
|
|
9
|
-
# Use only ascii symbols in identifiers.
|
10
|
-
AsciiComments:
|
11
|
-
Enabled: false
|
12
|
-
|
13
|
-
# Document classes and non-namespace modules.
|
14
9
|
Documentation:
|
15
10
|
Enabled: false
|
16
11
|
|
17
|
-
# Limit lines to 79 characters.
|
18
12
|
LineLength:
|
19
13
|
Enabled: false
|
20
14
|
|
@@ -24,17 +18,17 @@ BlockDelimiters:
|
|
24
18
|
MethodLength:
|
25
19
|
Max: 30
|
26
20
|
|
27
|
-
|
28
|
-
|
29
|
-
|
21
|
+
UncommunicativeMethodParamName:
|
22
|
+
AllowedNames:
|
23
|
+
- hi
|
24
|
+
- io
|
25
|
+
- lo
|
26
|
+
- x
|
27
|
+
- y
|
30
28
|
|
31
29
|
StringLiterals:
|
32
30
|
EnforcedStyle: single_quotes
|
33
31
|
|
34
|
-
# Avoid the use of attr. Use attr_reader and attr_accessor instead.
|
35
|
-
Attr:
|
36
|
-
Enabled: false
|
37
|
-
|
38
32
|
UnusedBlockArgument:
|
39
33
|
AutoCorrect: false
|
40
34
|
|
@@ -44,31 +38,33 @@ UnusedMethodArgument:
|
|
44
38
|
CyclomaticComplexity:
|
45
39
|
Max: 30
|
46
40
|
|
47
|
-
# https://github.com/bbatsov/ruby-style-guide/issues/395
|
48
|
-
RaiseArgs:
|
49
|
-
Enabled: false
|
50
|
-
|
51
41
|
AbcSize:
|
52
42
|
Max: 20
|
53
43
|
|
54
|
-
|
55
|
-
|
44
|
+
|
45
|
+
# Indentation
|
56
46
|
|
57
47
|
AlignHash:
|
58
48
|
EnforcedHashRocketStyle: table
|
59
49
|
EnforcedColonStyle: table
|
60
50
|
|
61
|
-
|
51
|
+
IndentFirstArrayElement:
|
52
|
+
EnforcedStyle: consistent
|
53
|
+
|
54
|
+
IndentFirstHashElement:
|
62
55
|
EnforcedStyle: consistent
|
63
56
|
|
64
57
|
SignalException:
|
65
58
|
EnforcedStyle: semantic
|
66
59
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
60
|
+
IndentFirstArgument:
|
61
|
+
EnforcedStyle: consistent
|
62
|
+
|
63
|
+
MultilineMethodCallIndentation:
|
64
|
+
EnforcedStyle: indented
|
65
|
+
|
66
|
+
MultilineOperationIndentation:
|
67
|
+
EnforcedStyle: indented
|
68
|
+
|
69
|
+
AlignParameters:
|
70
|
+
EnforcedStyle: with_fixed_indentation
|
data/Gemfile
CHANGED
data/Gemfile.lock
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
subconv (1.0.0)
|
5
|
+
solid-struct (~> 0.1)
|
6
|
+
timecode (~> 2.2)
|
7
|
+
|
8
|
+
GEM
|
9
|
+
remote: https://rubygems.org/
|
10
|
+
specs:
|
11
|
+
ansi (1.5.0)
|
12
|
+
approximately (1.1.0)
|
13
|
+
ast (2.4.0)
|
14
|
+
diff-lcs (1.3)
|
15
|
+
docile (1.3.2)
|
16
|
+
jaro_winkler (1.5.3)
|
17
|
+
json (2.2.0)
|
18
|
+
parallel (1.17.0)
|
19
|
+
parser (2.6.3.0)
|
20
|
+
ast (~> 2.4.0)
|
21
|
+
rainbow (3.0.0)
|
22
|
+
rake (12.3.2)
|
23
|
+
rspec (3.8.0)
|
24
|
+
rspec-core (~> 3.8.0)
|
25
|
+
rspec-expectations (~> 3.8.0)
|
26
|
+
rspec-mocks (~> 3.8.0)
|
27
|
+
rspec-core (3.8.2)
|
28
|
+
rspec-support (~> 3.8.0)
|
29
|
+
rspec-expectations (3.8.4)
|
30
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
31
|
+
rspec-support (~> 3.8.0)
|
32
|
+
rspec-mocks (3.8.1)
|
33
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
34
|
+
rspec-support (~> 3.8.0)
|
35
|
+
rspec-support (3.8.2)
|
36
|
+
rubocop (0.72.0)
|
37
|
+
jaro_winkler (~> 1.5.1)
|
38
|
+
parallel (~> 1.10)
|
39
|
+
parser (>= 2.6)
|
40
|
+
rainbow (>= 2.2.2, < 4.0)
|
41
|
+
ruby-progressbar (~> 1.7)
|
42
|
+
unicode-display_width (>= 1.4.0, < 1.7)
|
43
|
+
ruby-progressbar (1.10.1)
|
44
|
+
simplecov (0.17.0)
|
45
|
+
docile (~> 1.1)
|
46
|
+
json (>= 1.8, < 3)
|
47
|
+
simplecov-html (~> 0.10.0)
|
48
|
+
simplecov-console (0.5.0)
|
49
|
+
ansi
|
50
|
+
simplecov
|
51
|
+
terminal-table
|
52
|
+
simplecov-html (0.10.2)
|
53
|
+
solid-struct (0.1.0)
|
54
|
+
terminal-table (1.8.0)
|
55
|
+
unicode-display_width (~> 1.1, >= 1.1.1)
|
56
|
+
timecode (2.2.2)
|
57
|
+
approximately (~> 1.1)
|
58
|
+
unicode-display_width (1.6.0)
|
59
|
+
|
60
|
+
PLATFORMS
|
61
|
+
ruby
|
62
|
+
|
63
|
+
DEPENDENCIES
|
64
|
+
bundler (~> 1.7)
|
65
|
+
rake (~> 12.3)
|
66
|
+
rspec (~> 3.8.0)
|
67
|
+
rubocop (~> 0.72)
|
68
|
+
simplecov (~> 0.17)
|
69
|
+
simplecov-console (~> 0.5)
|
70
|
+
subconv!
|
71
|
+
|
72
|
+
BUNDLED WITH
|
73
|
+
1.16.1
|
data/README.md
CHANGED
@@ -1,9 +1,3 @@
|
|
1
|
-
[](http://badge.fury.io/rb/subconv)
|
2
|
-
[](https://gemnasium.com/pkerling/subconv)
|
3
|
-
[](https://travis-ci.org/pkerling/subconv)
|
4
|
-
[](https://coveralls.io/github/pkerling/subconv?branch=master)
|
5
|
-
[](http://inch-ci.org/github/pkerling/subconv)
|
6
|
-
|
7
1
|
subconv - Ruby SCC (EIA-608) to WebVTT subtitle converter
|
8
2
|
=========================================================
|
9
3
|
|
@@ -26,6 +20,7 @@ Usage
|
|
26
20
|
-c, --no-color Remove all color information from output
|
27
21
|
-F, --no-flash Remove all flash (blinking) information from output
|
28
22
|
-s, --simple-positions Convert to simple top/bottom center-aligned captions
|
23
|
+
-p, --keep-paint-on Keep paint-on captions, i.e. do not combine them into pop-on cues
|
29
24
|
-h, --help Show this help message and quit.
|
30
25
|
|
31
26
|
The API can also be used programmatically, the `bin/subconv` file is just an
|
@@ -35,12 +30,15 @@ Supported features
|
|
35
30
|
------------------
|
36
31
|
* EIA-608 parsing and conversion to WebVTT
|
37
32
|
* Pop-on captions
|
33
|
+
* Paint-on captions
|
38
34
|
* Full positioning
|
39
|
-
* All special characters defined in the standard
|
35
|
+
* All special characters defined in the FCC standard
|
36
|
+
* All extended (Western European) characters defined in EIA-608-B
|
40
37
|
* All colors
|
41
38
|
* Italics
|
42
39
|
* Underline
|
43
40
|
* Flash
|
41
|
+
* Paint-on to pop-on caption conversion (by combining similar cues)
|
44
42
|
* Optional removal of certain features during the conversion
|
45
43
|
* Color
|
46
44
|
* Flash
|
@@ -55,5 +53,4 @@ Firefox can not apply classes/styles inside cues at all.
|
|
55
53
|
Unsupported features
|
56
54
|
--------------------
|
57
55
|
* Roll-up captions
|
58
|
-
* Paint-on captions
|
59
56
|
* EIA-708
|
data/Rakefile
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'bundler/gem_tasks'
|
2
4
|
require 'rspec/core/rake_task'
|
3
5
|
require 'rubocop/rake_task'
|
@@ -7,4 +9,4 @@ RuboCop::RakeTask.new do |task|
|
|
7
9
|
task.options = ['--fail-level', 'warning']
|
8
10
|
end
|
9
11
|
|
10
|
-
task default: [
|
12
|
+
task default: %i[spec rubocop]
|
data/bin/subconv
CHANGED
@@ -21,6 +21,9 @@ OptionParser.new do |opts|
|
|
21
21
|
opts.on('-s', '--simple-positions', 'Convert to simple top/bottom center-aligned captions') do
|
22
22
|
options[:simple_positions] = true
|
23
23
|
end
|
24
|
+
opts.on('-p', '--keep-paint-on', 'Keep paint-on captions, i.e. do not combine them into pop-on cues') do
|
25
|
+
options[:keep_paint_on] = true
|
26
|
+
end
|
24
27
|
opts.on_tail('-h', '--help', 'Show this help message and quit.') do
|
25
28
|
puts opts.help
|
26
29
|
exit
|
@@ -35,7 +38,10 @@ reader = Subconv::Scc::Reader.new
|
|
35
38
|
File.open(options[:in_file], File::RDONLY) do |file|
|
36
39
|
reader.read(file, options[:fps].to_f)
|
37
40
|
end
|
38
|
-
|
41
|
+
transformer = Subconv::Scc::Transformer.new
|
42
|
+
scc_captions = reader.captions
|
43
|
+
scc_captions = transformer.combine_paint_on_captions(scc_captions) unless options[:keep_paint_on]
|
44
|
+
captions = transformer.transform scc_captions
|
39
45
|
filter_options = {}
|
40
46
|
filter_options[:remove_color] = true if options[:no_color]
|
41
47
|
filter_options[:remove_flash] = true if options[:no_flash]
|
data/lib/subconv.rb
CHANGED
data/lib/subconv/caption.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Subconv
|
2
4
|
# Two-dimensional screen position relative (both x and y position between 0 and 1) to the screen size
|
3
5
|
class Position
|
@@ -17,6 +19,7 @@ module Subconv
|
|
17
19
|
def x=(x)
|
18
20
|
x = x.to_f
|
19
21
|
fail RangeError, 'X position not between 0 and 1' unless x.between?(0.0, 1.0)
|
22
|
+
|
20
23
|
@x = x
|
21
24
|
end
|
22
25
|
|
@@ -24,6 +27,7 @@ module Subconv
|
|
24
27
|
def y=(y)
|
25
28
|
y = y.to_f
|
26
29
|
fail RangeError, 'Y position not between 0 and 1' unless y.between?(0.0, 1.0)
|
30
|
+
|
27
31
|
@y = y
|
28
32
|
end
|
29
33
|
end
|
@@ -58,6 +62,7 @@ module Subconv
|
|
58
62
|
|
59
63
|
def children=(children)
|
60
64
|
fail 'Children must be an array' unless children.class == Array
|
65
|
+
|
61
66
|
@children = children
|
62
67
|
end
|
63
68
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'subconv/caption'
|
2
4
|
|
3
5
|
module Subconv
|
@@ -65,9 +67,7 @@ module Subconv
|
|
65
67
|
# Remove nils resulting from removed captions
|
66
68
|
captions.compact!
|
67
69
|
# Do per-caption processing after merging etc.
|
68
|
-
captions.each
|
69
|
-
process_caption!(caption)
|
70
|
-
end
|
70
|
+
captions.each(&method(:process_caption!))
|
71
71
|
end
|
72
72
|
|
73
73
|
def process_caption!(caption)
|
@@ -88,7 +88,9 @@ module Subconv
|
|
88
88
|
if current_text_node.nil?
|
89
89
|
current_text_node = child
|
90
90
|
else
|
91
|
-
# Add text to previous node
|
91
|
+
# Add text to previous node, copying the text if necessary (literals are frozen by default)
|
92
|
+
current_text_node.text = current_text_node.text.dup if current_text_node.text.frozen?
|
93
|
+
|
92
94
|
current_text_node.text << child.text
|
93
95
|
# Remove this node
|
94
96
|
next
|
data/lib/subconv/scc/reader.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
require 'subconv/utility'
|
3
4
|
|
4
5
|
require 'solid_struct'
|
@@ -6,7 +7,7 @@ require 'timecode'
|
|
6
7
|
|
7
8
|
module Subconv
|
8
9
|
module Scc
|
9
|
-
FILE_MAGIC = 'Scenarist_SCC V1.0'
|
10
|
+
FILE_MAGIC = 'Scenarist_SCC V1.0'
|
10
11
|
|
11
12
|
# Grid size
|
12
13
|
GRID_ROWS = 15
|
@@ -14,8 +15,18 @@ module Subconv
|
|
14
15
|
|
15
16
|
# Grid is just an array with some extra convenience functions and a default size
|
16
17
|
class Grid < Array
|
17
|
-
def initialize
|
18
|
-
|
18
|
+
def initialize(*args)
|
19
|
+
if args.empty?
|
20
|
+
super(GRID_ROWS) { Array.new(GRID_COLUMNS) }
|
21
|
+
elsif args.size == 1
|
22
|
+
fail ArgumentError, 'Attempted to create grid from unsupported type' unless args[0].is_a?(Array)
|
23
|
+
fail ArgumentError, 'Grid has illegal row count' if args[0].size != GRID_ROWS
|
24
|
+
fail ArgumentError, 'Grid has illegal column count' if args[0].any? { |row| row.size != GRID_COLUMNS }
|
25
|
+
|
26
|
+
super(args.first)
|
27
|
+
else
|
28
|
+
fail ArgumentError, 'Illegal number of parameters'
|
29
|
+
end
|
19
30
|
end
|
20
31
|
|
21
32
|
# The grid is empty when there are no characters in it
|
@@ -23,6 +34,11 @@ module Subconv
|
|
23
34
|
flatten.compact.empty?
|
24
35
|
end
|
25
36
|
|
37
|
+
# Make a deep copy
|
38
|
+
def clone
|
39
|
+
Grid.new(map { |row| row.map(&:clone) })
|
40
|
+
end
|
41
|
+
|
26
42
|
# Insert continuous text at a given position
|
27
43
|
# Returns self for chaining
|
28
44
|
def insert_text(row, column, text, style = CharacterStyle.default)
|
@@ -32,6 +48,21 @@ module Subconv
|
|
32
48
|
end
|
33
49
|
self
|
34
50
|
end
|
51
|
+
|
52
|
+
def without_identical_characters(other_grid)
|
53
|
+
result = clone
|
54
|
+
each_with_index do |row, row_i|
|
55
|
+
row.each_with_index do |column, column_i|
|
56
|
+
result[row_i][column_i] = nil if other_grid[row_i][column_i] == column
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
result
|
61
|
+
end
|
62
|
+
|
63
|
+
def to_simple_text
|
64
|
+
map { |row| row.map { |char| char.nil? ? ' ' : char.character }.join }.join("\n")
|
65
|
+
end
|
35
66
|
end
|
36
67
|
|
37
68
|
# Color constants as immutable value objects with some convenience functions (e.g. conversion to string or symbol)
|
@@ -92,6 +123,7 @@ module Subconv
|
|
92
123
|
def self.for_value(value)
|
93
124
|
color = COLORS[value]
|
94
125
|
fail "Color value #{value} is unknown" if color.nil?
|
126
|
+
|
95
127
|
color
|
96
128
|
end
|
97
129
|
|
@@ -111,7 +143,45 @@ module Subconv
|
|
111
143
|
Character = SolidStruct.new(:character, :style)
|
112
144
|
|
113
145
|
# One fully rendered caption displayed at a specific point in time
|
114
|
-
Caption
|
146
|
+
class Caption
|
147
|
+
def initialize(args)
|
148
|
+
# Inject default
|
149
|
+
args = { mode: :pop_on, char_replacement: false }.merge(args)
|
150
|
+
self.timecode = args[:timecode]
|
151
|
+
self.grid = args[:grid]
|
152
|
+
self.mode = args[:mode]
|
153
|
+
self.char_replacement = args[:char_replacement]
|
154
|
+
end
|
155
|
+
|
156
|
+
def ==(other)
|
157
|
+
timecode == other.timecode && grid == other.grid && mode == other.mode && char_replacement? == other.char_replacement?
|
158
|
+
end
|
159
|
+
|
160
|
+
alias eql? ==
|
161
|
+
|
162
|
+
attr_reader :mode
|
163
|
+
attr_writer :char_replacement
|
164
|
+
attr_accessor :timecode, :grid
|
165
|
+
|
166
|
+
def mode=(mode)
|
167
|
+
fail 'Unknown mode' unless %i[pop_on paint_on].include?(mode)
|
168
|
+
|
169
|
+
@mode = mode
|
170
|
+
end
|
171
|
+
|
172
|
+
def pop_on_mode?
|
173
|
+
@mode == :pop_on
|
174
|
+
end
|
175
|
+
|
176
|
+
def paint_on_mode?
|
177
|
+
@mode == :paint_on
|
178
|
+
end
|
179
|
+
|
180
|
+
# Is this caption just replacing an existing character with an extended character?
|
181
|
+
def char_replacement?
|
182
|
+
@char_replacement
|
183
|
+
end
|
184
|
+
end
|
115
185
|
|
116
186
|
# SCC reader
|
117
187
|
# Parse and render an SCC file sequentially into a background and foreground grid
|
@@ -122,23 +192,24 @@ module Subconv
|
|
122
192
|
# The advanced recovery methods mentioned in CEA608 are not implemented since the source is assumed to contain no errors (e.g. DVD source).
|
123
193
|
class Reader
|
124
194
|
# Regular expression for parsing one line of data
|
125
|
-
LINE_REGEXP = /^(?<timecode>[0-9:;]+)\t(?<data>(?:[0-9a-fA-F]{4} ?)+)
|
195
|
+
LINE_REGEXP = /^(?<timecode>[0-9:;]+)\t(?<data>(?:[0-9a-fA-F]{4} ?)+)$/.freeze
|
126
196
|
|
127
197
|
# rubocop:disable MutableConstant
|
128
198
|
|
129
199
|
# Map of standard characters that do not match the standard ASCII codes
|
130
200
|
# to their corresponding unicode characters
|
131
201
|
STANDARD_CHARACTER_MAP = {
|
132
|
-
'
|
133
|
-
'
|
134
|
-
'
|
135
|
-
'
|
136
|
-
'
|
137
|
-
'
|
138
|
-
'
|
139
|
-
'
|
140
|
-
'
|
141
|
-
|
202
|
+
'\'' => '’', # CEA-608-B says to use curly apostrophe
|
203
|
+
'*' => 'á',
|
204
|
+
'\\' => 'é',
|
205
|
+
'^' => 'í',
|
206
|
+
'_' => 'ó',
|
207
|
+
'`' => 'ú',
|
208
|
+
'{' => 'ç',
|
209
|
+
'|' => '÷',
|
210
|
+
'}' => 'Ñ',
|
211
|
+
'~' => 'ñ',
|
212
|
+
"\x7f" => '█'
|
142
213
|
}
|
143
214
|
# rubocop:enable MutableConstant
|
144
215
|
# Simply return the character if no exception matched
|
@@ -148,24 +219,14 @@ module Subconv
|
|
148
219
|
STANDARD_CHARACTER_MAP.freeze
|
149
220
|
|
150
221
|
# Map of special characters to unicode codepoints
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
'
|
157
|
-
|
158
|
-
|
159
|
-
'7' => "\u266a",
|
160
|
-
'8' => "\u00e0",
|
161
|
-
# "\x39" => transparent space is handled specially since it is not a real character
|
162
|
-
':' => "\u00e8",
|
163
|
-
';' => "\u00e2",
|
164
|
-
'<' => "\u00ea",
|
165
|
-
'=' => "\u00ee",
|
166
|
-
'>' => "\u00f4",
|
167
|
-
'?' => "\u00fb"
|
168
|
-
}.freeze
|
222
|
+
# 0x39 transparent space is handled specially since it is not a real character
|
223
|
+
SPECIAL_CHARACTER_MAP = Hash[%w[® ° ½ ¿ ™ ¢ £ ♪ à _ è á ê î ô û].each_with_index.map { |char, i| [0x30 + i, char] }].freeze
|
224
|
+
|
225
|
+
# Extended character maps for Western European languages and box drawing
|
226
|
+
EXTENDED_CHARACTER_MAPS = [
|
227
|
+
Hash[%w[Á É Ó Ú Ü ü ‘ ¡ * ' ─ © ℠ · “ ” À Â Ç È Ê Ë ë Î Ï ï Ô Ù ù Û « »].each_with_index.map { |char, i| [0x20 + i, char] }].freeze,
|
228
|
+
Hash[%w[Ã ã Í Ì ì Ò ò Õ õ { } \\ ^ _ | ~ Ä ä Ö ö ß ¥ ¤ │ Å å Ø ø ┌ ┐ └ ┘].each_with_index.map { |char, i| [0x20 + i, char] }].freeze
|
229
|
+
].freeze
|
169
230
|
|
170
231
|
# Map of preamble address code high bytes to their
|
171
232
|
# corresponding base row numbers (counted from 0)
|
@@ -185,16 +246,17 @@ module Subconv
|
|
185
246
|
class InvalidFormatError < Error; end
|
186
247
|
class ParityError < Error; end
|
187
248
|
|
188
|
-
# Internal state of the parser consisting of current drawing position and
|
249
|
+
# Internal state of the parser consisting of current drawing position, character style and captioning mode
|
189
250
|
class State
|
190
251
|
def initialize(params)
|
191
252
|
self.row = params[:row]
|
192
253
|
self.column = params[:column]
|
193
254
|
@style = params[:style]
|
255
|
+
self.mode = params[:mode]
|
194
256
|
end
|
195
257
|
|
196
258
|
attr_accessor :style
|
197
|
-
attr_reader :row, :column
|
259
|
+
attr_reader :row, :column, :mode, :char_replaced
|
198
260
|
|
199
261
|
# Make sure the maximum row count is not exceeded
|
200
262
|
def row=(row)
|
@@ -206,8 +268,32 @@ module Subconv
|
|
206
268
|
@column = Utility.clamp(column, 0, GRID_COLUMNS - 1)
|
207
269
|
end
|
208
270
|
|
271
|
+
def mode=(mode)
|
272
|
+
fail 'Unknown mode' unless %i[pop_on paint_on].include?(mode)
|
273
|
+
|
274
|
+
@mode = mode
|
275
|
+
end
|
276
|
+
|
277
|
+
def pop_on_mode?
|
278
|
+
@mode == :pop_on
|
279
|
+
end
|
280
|
+
|
281
|
+
def paint_on_mode?
|
282
|
+
@mode == :paint_on
|
283
|
+
end
|
284
|
+
|
285
|
+
def char_replaced=(replaced)
|
286
|
+
fail ArgumentError, 'Invalid value for char_replaced' unless [true, false].include?(replaced)
|
287
|
+
|
288
|
+
@char_replaced = replaced
|
289
|
+
end
|
290
|
+
|
291
|
+
def start_new_frame
|
292
|
+
self.char_replaced = false
|
293
|
+
end
|
294
|
+
|
209
295
|
def self.default
|
210
|
-
State.new(row: 0, column: 0, style: CharacterStyle.default)
|
296
|
+
State.new(row: 0, column: 0, style: CharacterStyle.default, mode: :pop_on)
|
211
297
|
end
|
212
298
|
end
|
213
299
|
|
@@ -225,6 +311,8 @@ module Subconv
|
|
225
311
|
@now = Timecode.new(0, fps)
|
226
312
|
@data_channel = 0
|
227
313
|
|
314
|
+
update_active_grid
|
315
|
+
|
228
316
|
magic = io.readline.chomp!
|
229
317
|
fail InvalidFormatError, 'File does not start with "' + Scc::FILE_MAGIC + '"' unless Scc::FILE_MAGIC == magic
|
230
318
|
|
@@ -235,6 +323,7 @@ module Subconv
|
|
235
323
|
|
236
324
|
line_data = LINE_REGEXP.match(line)
|
237
325
|
fail InvalidFormatError, "Invalid line \"#{line}\"" if line_data.nil?
|
326
|
+
|
238
327
|
# Parse timecode
|
239
328
|
old_time = @now
|
240
329
|
timecode = Timecode.new(line_data[:timecode], fps)
|
@@ -254,10 +343,13 @@ module Subconv
|
|
254
343
|
|
255
344
|
data.split(' ').each do |word_string|
|
256
345
|
begin
|
346
|
+
@state.start_new_frame
|
347
|
+
|
257
348
|
# Decode hexadecimal word into two-byte string
|
258
349
|
word = [word_string].pack('H*')
|
259
350
|
# Check parity
|
260
351
|
fail ParityError, "At least one byte in word #{word_string} has even parity, odd required" unless !check_parity || (correct_parity?(word[0]) && correct_parity?(word[1]))
|
352
|
+
|
261
353
|
# Remove parity bit for further processing
|
262
354
|
word = word.bytes.collect { |byte|
|
263
355
|
# Unset 8th bit
|
@@ -298,15 +390,20 @@ module Subconv
|
|
298
390
|
# for further processing.
|
299
391
|
end
|
300
392
|
|
301
|
-
|
393
|
+
# rubocop:disable Style/NumericPredicate
|
394
|
+
|
395
|
+
if hi == 0x11 && (0x30..0x3f).cover?(lo)
|
302
396
|
# Special character
|
303
397
|
handle_special_character(lo)
|
304
|
-
elsif hi
|
305
|
-
#
|
398
|
+
elsif (0x12..0x13).cover?(hi) && (0x20..0x3f).cover?(lo)
|
399
|
+
# Extended character
|
400
|
+
handle_extended_character(hi & 1, lo)
|
401
|
+
elsif (0x10..0x17).cover?(hi) && (0x40..0xff).cover?(lo)
|
402
|
+
# Preamble address code
|
306
403
|
handle_preamble_address_code(hi, lo)
|
307
|
-
elsif
|
404
|
+
elsif [0x14, 0x17].include?(hi) && (0x20..0x2f).cover?(lo)
|
308
405
|
handle_control_code(hi, lo)
|
309
|
-
elsif hi == 0x11 &&
|
406
|
+
elsif hi == 0x11 && (0x20..0x2f).cover?(lo)
|
310
407
|
handle_mid_row_code(hi, lo)
|
311
408
|
elsif hi == 0x00 && lo == 0x00
|
312
409
|
# Ignore filler
|
@@ -314,9 +411,12 @@ module Subconv
|
|
314
411
|
puts "Ignoring unknown command #{hi}/#{lo}"
|
315
412
|
end
|
316
413
|
|
414
|
+
# rubocop:enable Style/NumericPredicate
|
415
|
+
|
317
416
|
last_command = word
|
318
417
|
end
|
319
418
|
|
419
|
+
post_frame if @state.paint_on_mode?
|
320
420
|
ensure
|
321
421
|
# Advance one frame for each word read
|
322
422
|
@now += 1
|
@@ -327,14 +427,14 @@ module Subconv
|
|
327
427
|
# Insert one unicode character into the grid at the current position and with the
|
328
428
|
# current style, then advance the cursor one column
|
329
429
|
def insert_character(char)
|
330
|
-
@
|
430
|
+
@active_grid[@state.row][@state.column] = Character.new(char, @state.style.dup)
|
331
431
|
@state.column += 1
|
332
432
|
end
|
333
433
|
|
334
434
|
# Insert a CEA608 character into the grid at the current position, converting it to its unicode representation
|
335
435
|
def handle_character(byte)
|
336
436
|
# Ignore filler character
|
337
|
-
return if byte
|
437
|
+
return if byte.zero?
|
338
438
|
|
339
439
|
char = STANDARD_CHARACTER_MAP[byte.chr]
|
340
440
|
insert_character(char)
|
@@ -345,17 +445,26 @@ module Subconv
|
|
345
445
|
def handle_special_character(byte)
|
346
446
|
if byte == 0x39
|
347
447
|
# Transparent space: Move cursor after deleting the current column to open up a hole
|
348
|
-
@
|
448
|
+
@active_grid[@state.row][@state.column] = nil
|
349
449
|
@state.column += 1
|
350
450
|
else
|
351
|
-
char = SPECIAL_CHARACTER_MAP
|
451
|
+
char = SPECIAL_CHARACTER_MAP.fetch(byte)
|
352
452
|
insert_character(char)
|
353
453
|
end
|
354
454
|
end
|
355
455
|
|
456
|
+
# Insert an extended character into the grid at the current position, converting it to its Unicode representation
|
457
|
+
def handle_extended_character(map, byte)
|
458
|
+
char = EXTENDED_CHARACTER_MAPS.fetch(map).fetch(byte)
|
459
|
+
# Extended characters include automatic backspace+overwrite
|
460
|
+
@state.column -= 1
|
461
|
+
insert_character(char)
|
462
|
+
@state.char_replaced = true
|
463
|
+
end
|
464
|
+
|
356
465
|
# Set drawing position and style according to the information in a preamble address code
|
357
466
|
def handle_preamble_address_code(hi, lo)
|
358
|
-
@state.row = PREAMBLE_ADDRESS_CODE_ROW_MAP
|
467
|
+
@state.row = PREAMBLE_ADDRESS_CODE_ROW_MAP.fetch(hi)
|
359
468
|
# Low byte bit 5 adds 1 to the row number if set
|
360
469
|
@state.row += 1 if lo & (1 << 5) != 0
|
361
470
|
|
@@ -375,12 +484,16 @@ module Subconv
|
|
375
484
|
@state.style.color = Color::WHITE
|
376
485
|
# One indent equals 4 characters
|
377
486
|
@state.column = color_or_indent * 4
|
378
|
-
elsif color_or_indent == 7
|
379
|
-
# "color" 7 is white with italics
|
380
|
-
@state.style.color = Color::WHITE
|
381
|
-
@state.style.italics = true
|
382
487
|
else
|
383
|
-
|
488
|
+
# Style code always sets first column
|
489
|
+
@state.column = 0
|
490
|
+
if color_or_indent == 7
|
491
|
+
# "color" 7 is white with italics
|
492
|
+
@state.style.color = Color::WHITE
|
493
|
+
@state.style.italics = true
|
494
|
+
else
|
495
|
+
@state.style.color = Color.for_value(color_or_indent)
|
496
|
+
end
|
384
497
|
end
|
385
498
|
end
|
386
499
|
|
@@ -388,38 +501,49 @@ module Subconv
|
|
388
501
|
def handle_control_code(hi, lo)
|
389
502
|
if hi == 0x14 && lo == 0x20
|
390
503
|
# Resume caption loading
|
391
|
-
|
504
|
+
@state.mode = :pop_on
|
505
|
+
update_active_grid
|
392
506
|
elsif hi == 0x14 && lo == 0x21
|
393
507
|
# Backspace
|
394
508
|
unless @state.column.zero? # Ignore in the first column
|
395
509
|
@state.column -= 1
|
396
510
|
# Delete character at cursor after moving one character back
|
397
|
-
@
|
511
|
+
@active_grid[@state.row][@state.column] = nil
|
398
512
|
end
|
399
513
|
elsif hi == 0x14 && lo == 0x24
|
400
514
|
# Delete to end of row
|
401
515
|
(@state.column...GRID_COLUMNS).each do |column|
|
402
|
-
@
|
516
|
+
@active_grid[@state.row][column] = nil
|
403
517
|
end
|
404
518
|
elsif hi == 0x14 && lo == 0x28
|
405
519
|
# Flash on
|
406
520
|
# Flash is a spacing character
|
407
521
|
insert_character(' ')
|
408
522
|
@state.style.flash = true
|
523
|
+
elsif hi == 0x14 && lo == 0x29
|
524
|
+
# Resume direct captioning
|
525
|
+
@state.mode = :paint_on
|
526
|
+
update_active_grid
|
409
527
|
# elsif hi == 0x14 && lo == 0x2b
|
410
|
-
# Resume text display -> not
|
528
|
+
# Resume text display -> command not described in spec
|
411
529
|
# fail "RTD"
|
412
530
|
elsif hi == 0x14 && lo == 0x2c
|
413
531
|
# Erase displayed memory
|
414
532
|
@foreground_grid = Grid.new
|
415
533
|
post_frame
|
534
|
+
update_active_grid
|
416
535
|
elsif hi == 0x14 && lo == 0x2e
|
417
536
|
# Erase non-displayed memory
|
418
537
|
@background_grid = Grid.new
|
538
|
+
update_active_grid
|
419
539
|
elsif hi == 0x14 && lo == 0x2f
|
420
540
|
# End of caption (flip memories)
|
421
541
|
@foreground_grid, @background_grid = @background_grid, @foreground_grid
|
542
|
+
# This also forces pop-on mode
|
543
|
+
@state.mode = :pop_on
|
422
544
|
post_frame
|
545
|
+
|
546
|
+
update_active_grid
|
423
547
|
elsif hi == 0x17 && lo >= 0x21 && lo <= 0x23
|
424
548
|
# Tab offset
|
425
549
|
# Bits 0 and 1 designate how many columns to go
|
@@ -452,13 +576,17 @@ module Subconv
|
|
452
576
|
# Insert the currently displayed foreground grid as caption into the captions array
|
453
577
|
# Must be called whenever the foreground grid is changed as a result of a command
|
454
578
|
def post_frame
|
455
|
-
# Only push a new caption if the grid has changed
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
579
|
+
# Only push a new caption if the grid has changed, but do not push out an empty grid initially
|
580
|
+
return unless @foreground_grid != @last_grid && !(@last_grid.nil? && @foreground_grid.empty?)
|
581
|
+
|
582
|
+
# Save space by not saving the grid if it is completely empty
|
583
|
+
grid = @foreground_grid.empty? ? nil : @foreground_grid
|
584
|
+
@captions.push(Caption.new(timecode: @now, grid: grid.clone, mode: @state.mode, char_replacement: @state.char_replaced))
|
585
|
+
@last_grid = @foreground_grid.clone
|
586
|
+
end
|
587
|
+
|
588
|
+
def update_active_grid
|
589
|
+
@active_grid = @state.paint_on_mode? ? @foreground_grid : @background_grid
|
462
590
|
end
|
463
591
|
|
464
592
|
# Check a byte for odd parity
|