gsm_encoder 0.1.6 → 0.1.8

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 (4) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +40 -0
  3. data/lib/gsm_encoder.rb +161 -65
  4. metadata +3 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: cc5e53ca18eacc9115c7395a5f6c833f22571d19
4
- data.tar.gz: eab20ccf00096deef3577367472e87ae88dfa6c0
2
+ SHA256:
3
+ metadata.gz: 328cd3d4611243a39035910afc138fda9bdcc23620edc2c6fa83eb4bf9bfa4dc
4
+ data.tar.gz: 450843ca2309b3070ed92835b5e81620c7bf38467c8e435017e5692c3e8cce7c
5
5
  SHA512:
6
- metadata.gz: afd5459dc5be3e784c6b691d1ab6e6600cc0f333965cd1ad87b068a28787b98c92abc75534078f60e79e37506c0d8d2cbe5afe940032115949733593e71ba814
7
- data.tar.gz: 088b1bbe74116558fd69645a61f8a4bc8df9f7f501f893071f733165002c6774ef6f6a30502ed728b4b9e9a1b3604623577c4abec30080b70bf331b3fc443c9f
6
+ metadata.gz: 98bd3e1a50f2e05b957cff8c041666e4647d8e9c0ef28ad795ef610fa5423e9978d9015b318a9b116e6dd4b65deea03b0ed8298c7c04eba50452ec59ce43aece
7
+ data.tar.gz: e2b7a8daacf2c746946fc772b0eb22aa344de19e811322c637c24d810cb50c455db399b2b1dc10c55371c533d2f46726700aac148701d55faa0d08eb76421eea
data/README.md CHANGED
@@ -29,6 +29,35 @@ __NOTE: ruby >= 1.9.2 is required__
29
29
  # replaces unsupported chars with a provided value ' '
30
30
  GSMEncoder.encode('`', ' ') # => ' '
31
31
 
32
+ ## Extension tables
33
+
34
+ Characters like `{ } [ ] ~ \ | € ^` are sent as an escape plus a code from an
35
+ extension table. Two tables are built in:
36
+
37
+ - `:spanish` (the default) - the Spanish national language single shift table.
38
+ It adds `ç Á Í Ó Ú á í ó ú` on top of the standard table.
39
+ - `:standard` - the GSM 03.38 default extension table.
40
+
41
+ The Spanish characters are only valid GSM 7-bit when the message header selects
42
+ the Spanish shift table. Most SMS providers (Twilio, Telnyx, Plivo, ...) don't,
43
+ so a message containing them is sent as UCS-2 instead. That cuts a segment from
44
+ 160 characters to 70. Use `:standard` if you rely on `can_encode?` or
45
+ `encode` to keep messages in GSM 7-bit:
46
+
47
+ # for every call
48
+ GSMEncoder.extension_table = :standard
49
+
50
+ # or for one call
51
+ GSMEncoder.can_encode?('á', extension_table: :standard) # => false
52
+ GSMEncoder.encode('á', extension_table: :standard) # => '?'
53
+ GSMEncoder.decode(bytes, extension_table: :standard)
54
+
55
+ You can also register your own table, an Array indexed by GSM code with `nil`
56
+ for unused codes:
57
+
58
+ GSMEncoder.register_extension_table(:mine, table)
59
+ GSMEncoder.extension_table = :mine
60
+
32
61
  ## Updates
33
62
 
34
63
  ### 0.1.1
@@ -51,3 +80,14 @@ Code cleanup and speedup
51
80
  ### 0.1.5
52
81
 
53
82
  Even faster. About 4 times. Many thanks to @dlarrabee
83
+
84
+ ### 0.1.7
85
+
86
+ Even faster! Many thanks to @ThomasSevestre
87
+
88
+ ### 0.1.8
89
+
90
+ - Configurable extension table (`:standard` or `:spanish`), defaults to `:spanish`
91
+ - Adds form feed to the extension tables
92
+ - `decode` returns the replacement for unknown codes instead of raising `TypeError`
93
+
data/lib/gsm_encoder.rb CHANGED
@@ -1,24 +1,34 @@
1
- #encoding: utf-8
1
+ # frozen_string_literal: true
2
2
  # Stealing from Twitter's Java implementation
3
3
  # https://github.com/twitter/cloudhopper-commons-charset/blob/master/src/main/java/com/cloudhopper/commons/charset/GSMCharset.java
4
4
 
5
5
  #
6
6
  # This class encodes and decodes Ruby Strings to and from the SMS default
7
- # alphabet. It also supports the default extension table. The default alphabet
8
- # and it's extension table is defined in GSM 03.38.
7
+ # alphabet. It also supports an extension table. The default alphabet and
8
+ # its extension tables are defined in GSM 03.38 (3GPP TS 23.038).
9
+ #
10
+ # Which extension table is used can be configured:
11
+ #
12
+ # GSMEncoder.extension_table = :standard # GSM 03.38 default extension table only
13
+ # GSMEncoder.extension_table = :spanish # Spanish single shift table (the default)
14
+ #
15
+ # or per call:
16
+ #
17
+ # GSMEncoder.encode("¿Qué?", extension_table: :standard)
9
18
  module GSMEncoder
10
- DEFAULT_REPLACE_CHAR = "?"
19
+ # GSM to UTF8 tables
20
+ nl = 10.chr
21
+ ff = 12.chr
22
+ cr = 13.chr
23
+ bs = 92.chr
11
24
 
12
- EXTENDED_ESCAPE = 0x1b
13
- NL = 10.chr
14
- CR = 13.chr
15
- BS = 92.chr
25
+ GSM_ESCAPE = 0x1b
16
26
 
17
- CHAR_TABLE = [
27
+ GSM_TABLE = [
18
28
  '@', '£', '$', '¥', 'è', 'é', 'ù', 'ì',
19
- 'ò', 'Ç', NL, 'Ø', 'ø', CR , 'Å', 'å',
29
+ 'ò', 'Ç', nl, 'Ø', 'ø', cr , 'Å', 'å',
20
30
  'Δ', '_', 'Φ', 'Γ', 'Λ', 'Ω', 'Π', 'Ψ',
21
- 'Σ', 'Θ', 'Ξ', " ", 'Æ', 'æ', 'ß', 'É', # 0x1B is actually an escape which we'll encode to a space char
31
+ 'Σ', 'Θ', 'Ξ', nil, 'Æ', 'æ', 'ß', 'É', # 0x1b is the escape
22
32
  " ", '!', '"', '#', '¤', '%', '&', "'",
23
33
  '(', ')', '*', '+', ',', '-', '.', '/',
24
34
  '0', '1', '2', '3', '4', '5', '6', '7',
@@ -31,73 +41,159 @@ module GSMEncoder
31
41
  'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
32
42
  'p', 'q', 'r', 's', 't', 'u', 'v', 'w',
33
43
  'x', 'y', 'z', 'ä', 'ö', 'ñ', 'ü', 'à',
34
- ].join # make it string to speedup lookup
35
-
36
- # Extended character table. Characters in this table are accessed by the
37
- # 'escape' character in the base table. It is important that none of the
38
- # 'inactive' characters ever be matchable with a valid base-table
39
- # character as this breaks the encoding loop.
40
- EXT_CHAR_TABLE = [
41
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 'ç', 0, 0, 0, 0, 0, 0,
42
- 0, 0, 0, 0, '^', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
43
- 0, 0, 0, 0, 0, 0, 0, 0, '{', '}', 0, 0, 0, 0, 0, BS,
44
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, '[', '~', ']', 0,
45
- '|', 'Á', 0, 0, 0, 0, 0, 0, 0, 'Í', 0, 0, 0, 0, 0, 'Ó',
46
- 0, 0, 0, 0, 0, 'Ú', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
47
- 0, 'á', 0, 0, 0, '€', 0, 0, 0, 'í', 0, 0, 0, 0, 0, 'ó',
48
- 0, 0, 0, 0, 0, 'ú', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
49
- ]
50
-
51
- REGEX = /\A[ -_a-~#{Regexp.escape(CHAR_TABLE + EXT_CHAR_TABLE.select {|c| c != 0}.join)}]*\Z/
52
-
53
- # Verifies that this charset can represent every character in the Ruby
54
- # String.
55
- # @param str The String to verfiy
56
- # @return True if the charset can represent every character in the Ruby
57
- # String, otherwise false.
58
- def can_encode?(str)
59
- !str || !!(REGEX =~ str)
60
- end
44
+ ].freeze
61
45
 
62
- def encode(str, replace_char=nil)
63
- return nil if !str
46
+ # GSM 03.38 default extension table. This is the only extension table a
47
+ # handset (or SMS provider) uses unless the message explicitly selects a
48
+ # national language shift table in its header.
49
+ STANDARD_EXT_TABLE = [
50
+ nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, ff, nil, nil, nil, nil, nil,
51
+ nil, nil, nil, nil, '^', nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
52
+ nil, nil, nil, nil, nil, nil, nil, nil, '{', '}', nil, nil, nil, nil, nil, bs,
53
+ nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, '[', '~', ']', nil,
54
+ '|', nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
55
+ nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
56
+ nil, nil, nil, nil, nil, '€', nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
57
+ nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
58
+ ].freeze
64
59
 
65
- replace_char = DEFAULT_REPLACE_CHAR if !replace_char || !can_encode?(replace_char)
60
+ # Spanish national language single shift table (3GPP TS 23.038 A.2.2).
61
+ # Only decoded correctly when the message header selects the Spanish shift.
62
+ SPANISH_EXT_TABLE = [
63
+ nil, nil, nil, nil, nil, nil, nil, nil, nil, 'ç', ff, nil, nil, nil, nil, nil,
64
+ nil, nil, nil, nil, '^', nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
65
+ nil, nil, nil, nil, nil, nil, nil, nil, '{', '}', nil, nil, nil, nil, nil, bs,
66
+ nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, '[', '~', ']', nil,
67
+ '|', 'Á', nil, nil, nil, nil, nil, nil, nil, 'Í', nil, nil, nil, nil, nil, 'Ó',
68
+ nil, nil, nil, nil, nil, 'Ú', nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
69
+ nil, 'á', nil, nil, nil, '€', nil, nil, nil, 'í', nil, nil, nil, nil, nil, 'ó',
70
+ nil, nil, nil, nil, nil, 'ú', nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
71
+ ].freeze
66
72
 
67
- buffer = ''.encode('binary')
73
+ # kept for backwards compatibility, this was the only extension table before
74
+ GSM_EXT_TABLE = SPANISH_EXT_TABLE
75
+
76
+ UTF8_ARRAY_SIZE = 128
77
+
78
+ # UTF8 to GSM lookups for one extension table
79
+ Charset = Struct.new(:ext_table, :utf8_array, :utf8_hash, :regexp)
80
+
81
+ def self.build_charset(ext_table)
82
+ unless ext_table.is_a?(Array) && ext_table.size <= UTF8_ARRAY_SIZE
83
+ raise ArgumentError, "extension table must be an Array of at most #{UTF8_ARRAY_SIZE} entries"
84
+ end
68
85
 
69
- begin
70
- str.each_char do |c|
71
- if index = CHAR_TABLE.rindex(c)
72
- buffer << index
73
- elsif index = EXT_CHAR_TABLE.index(c)
74
- buffer << EXTENDED_ESCAPE
75
- buffer << index
86
+ utf8_array = []
87
+ utf8_hash = {}
88
+ [
89
+ [ GSM_TABLE, nil ],
90
+ [ ext_table, GSM_ESCAPE ]
91
+ ].each do |table, prefix|
92
+ table.each.with_index do |char, index|
93
+ next if char.nil?
94
+ # build GSM value
95
+ if prefix
96
+ value = +''.encode('binary')
97
+ value << prefix
98
+ value << index
76
99
  else
77
- buffer << replace_char
100
+ value = index
101
+ end
102
+ # store GSM value
103
+ key = char.codepoints.first
104
+ if key < UTF8_ARRAY_SIZE
105
+ utf8_array[key] = value
106
+ else
107
+ utf8_hash[key] = value
78
108
  end
79
109
  end
80
- rescue
81
- # TODO: ?
82
110
  end
83
- buffer
111
+ utf8_array.each(&:freeze)
112
+ utf8_hash.each_value(&:freeze)
113
+
114
+ regexp = /\A[#{Regexp.escape((GSM_TABLE + ext_table).compact.join)}]*\Z/
115
+
116
+ Charset.new(ext_table.dup.freeze, utf8_array.freeze, utf8_hash.freeze, regexp).freeze
117
+ end
118
+
119
+ @charsets = {
120
+ standard: build_charset(STANDARD_EXT_TABLE),
121
+ spanish: build_charset(SPANISH_EXT_TABLE)
122
+ }
123
+
124
+ # kept for backwards compatibility
125
+ UTF8_ARRAY = @charsets[:spanish].utf8_array
126
+ UTF8_HASH = @charsets[:spanish].utf8_hash
127
+ REGEXP = @charsets[:spanish].regexp
128
+
129
+ @extension_table = :spanish
130
+
131
+ class << self
132
+ # The extension table used when a call doesn't pass one (:spanish by default)
133
+ attr_reader :extension_table
134
+
135
+ def extension_table=(name)
136
+ charset(name) # validate
137
+ @extension_table = name
138
+ end
139
+
140
+ # Register a custom extension table (an Array indexed by GSM code, nil for unused codes)
141
+ #
142
+ # GSMEncoder.register_extension_table(:mine, table)
143
+ # GSMEncoder.extension_table = :mine
144
+ def register_extension_table(name, table)
145
+ @charsets = @charsets.merge(name.to_sym => build_charset(table)).freeze
146
+ end
147
+
148
+ def extension_tables
149
+ @charsets.keys
150
+ end
151
+
152
+ def charset(name = nil)
153
+ name ||= @extension_table
154
+ @charsets.fetch(name.to_sym) do
155
+ raise ArgumentError, "unknown extension table #{name.inspect}, expected one of #{@charsets.keys.inspect}"
156
+ end
157
+ end
84
158
  end
85
159
 
86
- def decode bstring
87
- return nil if !bstring
160
+ # Verifies that the given string can be encoded in GSM 03.38
161
+ def can_encode?(str, extension_table: nil)
162
+ !str || !!(GSMEncoder.charset(extension_table).regexp =~ str)
163
+ end
88
164
 
89
- buffer = ''.encode('utf-8')
165
+ # Encode given UTF-8 string to GSM 03.38
166
+ def encode(string, replacement = "?", extension_table: nil)
167
+ return nil if string.nil?
168
+ charset = GSMEncoder.charset(extension_table)
169
+ utf8_array = charset.utf8_array
170
+ utf8_hash = charset.utf8_hash
171
+ replacement = replacement == "?" ? 63 : encode(replacement, extension_table: extension_table)
172
+ buffer = String.new(encoding: "binary")
173
+ string.each_codepoint do |codepoint|
174
+ if codepoint < UTF8_ARRAY_SIZE
175
+ buffer << (utf8_array[codepoint] || replacement)
176
+ else
177
+ buffer << (utf8_hash[codepoint] || replacement)
178
+ end
179
+ end
180
+ buffer
181
+ end
90
182
 
91
- table = CHAR_TABLE
92
- bstring.bytes.each do |c|
93
- code = c & 0x000000ff
94
- if code == EXTENDED_ESCAPE
95
- # take next char from extension table
96
- table = EXT_CHAR_TABLE
183
+ # Encode given GSM 03.38 string to UTF-8
184
+ def decode(string, replacement = "?", extension_table: nil)
185
+ return nil if string.nil?
186
+ ext_table = GSMEncoder.charset(extension_table).ext_table
187
+ buffer = +""
188
+ escaped = false
189
+ string.each_byte do |c|
190
+ if c == GSM_ESCAPE
191
+ escaped = true
192
+ elsif escaped
193
+ buffer << (ext_table[c] || replacement)
194
+ escaped = false
97
195
  else
98
- buffer << (code >= table.length ? '?' : table[code])
99
- # go back to the default table
100
- table = CHAR_TABLE
196
+ buffer << (GSM_TABLE[c] || replacement)
101
197
  end
102
198
  end
103
199
  buffer
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gsm_encoder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yury Korolev
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2014-03-18 00:00:00.000000000 Z
12
+ date: 2026-11-12 00:00:00.000000000 Z
13
13
  dependencies: []
14
14
  description: GSMEncoder encodes and decodes Ruby Strings to and from the SMS default
15
15
  alphabet. It also supports the default extension table. The default alphabet and
@@ -41,8 +41,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
41
41
  - !ruby/object:Gem::Version
42
42
  version: 1.3.6
43
43
  requirements: []
44
- rubyforge_project:
45
- rubygems_version: 2.2.0
44
+ rubygems_version: 3.0.3.1
46
45
  signing_key:
47
46
  specification_version: 4
48
47
  summary: ruby GSM 03.38 encoder/decoder