menkar 0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +75 -0
- data/docs/adr/000-template.md +17 -0
- data/docs/adr/001-strict-bounded-detection.md +25 -0
- data/docs/adr/README.md +3 -0
- data/lib/menkar/detector.rb +154 -0
- data/lib/menkar/encoding_scorer.rb +91 -0
- data/lib/menkar/transcoder.rb +74 -0
- data/lib/menkar/version.rb +5 -0
- data/lib/menkar.rb +71 -0
- data/sig/menkar.rbs +39 -0
- metadata +55 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 19b9fc9d24c830c4eec2a522a1bec3b0b41b5a6756a52d6e2dbd945456e929f1
|
|
4
|
+
data.tar.gz: 285609052ef5eb84c5dee828fa4d21097279f433417bc0ca47ba2bc4e2368762
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7502f91d1b5f17218797d38514a4e49f2615b7d74baad30823e75fb41d60d3cd8ffe790435c9d474fd2f789d71e0ad5952853b264a5da42846ff042c7af80e95
|
|
7
|
+
data.tar.gz: 913bd5a9b617d91c712a71f714041e3dca5330e07372ca6199b7acca6ada96973e3514ea33ef4cfe35a1997c1e7c3fe27fc4f2f42ef02bad530bf7af2c967ec6
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Menkar
|
|
2
|
+
|
|
3
|
+
Menkar is a pure Ruby library for identifying text bytes before they enter a
|
|
4
|
+
UTF-8 editor core. It detects BOMs, binary data, encodings, newline forms, and
|
|
5
|
+
indentation, then performs strict and reversible transcoding.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "menkar"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "menkar"
|
|
17
|
+
|
|
18
|
+
bytes = File.binread("legacy.txt")
|
|
19
|
+
detection = Menkar.detect(bytes, hint: "Windows-31J")
|
|
20
|
+
raise "binary file" if detection.binary
|
|
21
|
+
|
|
22
|
+
text = Menkar.decode(bytes, detection) # valid UTF-8; original newlines remain
|
|
23
|
+
updated = text.sub("旧", "新")
|
|
24
|
+
|
|
25
|
+
unless Menkar.roundtrip?(updated, detection)
|
|
26
|
+
warn "The edited text cannot be saved in #{detection.encoding.name}"
|
|
27
|
+
end
|
|
28
|
+
File.binwrite("legacy.txt", Menkar.encode(updated, detection))
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`encode` restores the detected BOM and otherwise preserves the string's
|
|
32
|
+
newlines byte for byte. Newline conversion is explicit:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
unix_text, original = Menkar.normalize_newlines(text, to: :lf)
|
|
36
|
+
# original is :lf, :crlf, :cr, :mixed, or :none
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`detect_file` reads at most `sample + 1` bytes; `detect` examines only `sample`
|
|
40
|
+
bytes. The default is 64 KiB and the accepted maximum is 16 MiB. Supported
|
|
41
|
+
legacy encodings are Windows-31J (Shift_JIS), EUC-JP, ISO-2022-JP,
|
|
42
|
+
Windows-1252, GBK, Big5, and EUC-KR. A hint is an encoding name, `Encoding`, or
|
|
43
|
+
`{encoding: ...}` and adjusts a statistical score; a BOM always wins.
|
|
44
|
+
|
|
45
|
+
Detection is evidence, not a save policy. Callers decide whether to reload or
|
|
46
|
+
convert, warn for mixed newlines, and require confirmation when `roundtrip?`
|
|
47
|
+
is false. Decoding and encoding never replace invalid or undefined characters.
|
|
48
|
+
|
|
49
|
+
## Corpus and accuracy
|
|
50
|
+
|
|
51
|
+
The test corpus contains short excerpts from public-domain works: the Iroha,
|
|
52
|
+
Ogura Hyakunin Isshu, *I Am a Cat*, *The Pillow Book*, *The Tale of the Heike*,
|
|
53
|
+
*The Narrow Road to the Deep North*, and Basho's haiku (Japanese); the
|
|
54
|
+
*Analects* and *Tao Te Ching* (Chinese); *Hunminjeongeum* and traditional Korean
|
|
55
|
+
proverbs; and Shakespeare's *Hamlet*. `script/build_corpus` reproducibly
|
|
56
|
+
transcodes those excerpts into the fixture encodings. The source works are in
|
|
57
|
+
the public domain; the generated fixtures are distributed under the
|
|
58
|
+
repository's MIT license.
|
|
59
|
+
|
|
60
|
+
The suite requires at least 98% correct classification for Japanese fixtures
|
|
61
|
+
and 90% for the remaining legacy encodings.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
bundle install
|
|
67
|
+
bundle exec rake test
|
|
68
|
+
bundle exec rbs -I sig validate
|
|
69
|
+
BUDGET=1 bundle exec rake bench
|
|
70
|
+
gem build --strict menkar.gemspec
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
Menkar is available under the MIT License.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# ADR NNN: Implementation decision title
|
|
2
|
+
|
|
3
|
+
- Status: Proposed
|
|
4
|
+
- Date: YYYY-MM-DD
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Describe the concrete implementation question and its compatibility, data,
|
|
9
|
+
runtime, or component constraints.
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
Describe the durable boundary or architecture choice.
|
|
14
|
+
|
|
15
|
+
## Consequences
|
|
16
|
+
|
|
17
|
+
Describe the important positive and negative trade-offs and when to revisit it.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# ADR 001: Keep detection bounded and transcoding strict
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-09-15
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Editors need a useful encoding guess for large files, but a guess must not
|
|
9
|
+
silently corrupt source bytes. File reading, overwrite confirmation, and save
|
|
10
|
+
policy belong to the editor rather than this encoding library.
|
|
11
|
+
|
|
12
|
+
## Decision
|
|
13
|
+
|
|
14
|
+
Detection examines a bounded prefix and reports confidence plus structural
|
|
15
|
+
facts. BOM evidence is decisive; otherwise standard Ruby encoders validate
|
|
16
|
+
candidates before small language-specific scores rank them. Decode and encode
|
|
17
|
+
use strict conversion, preserve detected BOMs and newlines, and expose an exact
|
|
18
|
+
round-trip check. The caller owns every overwrite or conversion decision.
|
|
19
|
+
|
|
20
|
+
## Consequences
|
|
21
|
+
|
|
22
|
+
Very short or statistically unusual input can remain ambiguous, while hints
|
|
23
|
+
can improve its ranking. Unsupported or damaged text fails explicitly instead
|
|
24
|
+
of inserting replacement characters. Adding an encoding requires corpus
|
|
25
|
+
coverage rather than changing a caller's save path.
|
data/docs/adr/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menkar
|
|
4
|
+
DEFAULT_SAMPLE = 64 * 1024
|
|
5
|
+
MAX_SAMPLE = 16 * 1024 * 1024
|
|
6
|
+
|
|
7
|
+
BOMS = [
|
|
8
|
+
["\xFF\xFE\x00\x00".b.freeze, Encoding::UTF_32LE],
|
|
9
|
+
["\x00\x00\xFE\xFF".b.freeze, Encoding::UTF_32BE],
|
|
10
|
+
["\xEF\xBB\xBF".b.freeze, Encoding::UTF_8],
|
|
11
|
+
["\xFF\xFE".b.freeze, Encoding::UTF_16LE],
|
|
12
|
+
["\xFE\xFF".b.freeze, Encoding::UTF_16BE]
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
SUPPORTED_ENCODINGS = ([Encoding::UTF_8, Encoding::UTF_16LE, Encoding::UTF_16BE,
|
|
16
|
+
Encoding::UTF_32LE, Encoding::UTF_32BE] + EncodingScorer.encodings).freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def detect(bytes, hint: nil, sample: DEFAULT_SAMPLE)
|
|
21
|
+
validate_bytes!(bytes)
|
|
22
|
+
limit = validate_sample!(sample)
|
|
23
|
+
hint_encoding = normalize_hint(hint)
|
|
24
|
+
chunk = (bytes.byteslice(0, limit) || "").b
|
|
25
|
+
truncated = bytes.bytesize > chunk.bytesize
|
|
26
|
+
bom, bom_encoding = BOMS.find { |mark, _encoding| chunk.start_with?(mark) }
|
|
27
|
+
|
|
28
|
+
if bom_encoding
|
|
29
|
+
text = EncodingScorer.decode(chunk.byteslice(bom.bytesize..) || "".b, bom_encoding, truncated: truncated)
|
|
30
|
+
return detection(bom_encoding, 1.0, bom, text, binary: false)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if chunk.include?("\0")
|
|
34
|
+
encoding, text = nul_encoding(chunk, truncated: truncated)
|
|
35
|
+
return detection(encoding, 0.95, "".b.freeze, text, binary: false) if encoding
|
|
36
|
+
|
|
37
|
+
return detection(nil, 1.0, "".b.freeze, nil, binary: true)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
return detection(nil, 1.0, "".b.freeze, nil, binary: true) if binary_sample?(chunk)
|
|
41
|
+
|
|
42
|
+
if chunk.match?(/\e\$(?:@|B)|\e\([BJ]/n)
|
|
43
|
+
text = EncodingScorer.decode(chunk, Encoding::ISO_2022_JP, truncated: truncated)
|
|
44
|
+
return detection(Encoding::ISO_2022_JP, 0.99, "".b.freeze, text, binary: false) if text
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
utf8 = EncodingScorer.decode(chunk, Encoding::UTF_8, truncated: truncated)
|
|
48
|
+
if utf8
|
|
49
|
+
if utf8.ascii_only? && hint_encoding && hint_encoding != Encoding::UTF_8
|
|
50
|
+
return detection(hint_encoding, 0.6, "".b.freeze, utf8, binary: false)
|
|
51
|
+
end
|
|
52
|
+
return detection(Encoding::UTF_8, utf8.ascii_only? ? 0.5 : 0.9, "".b.freeze, utf8, binary: false)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
encoding, confidence, text = EncodingScorer.legacy(chunk, hint_encoding, truncated: truncated)
|
|
56
|
+
return detection(nil, 1.0, "".b.freeze, nil, binary: true) unless encoding
|
|
57
|
+
|
|
58
|
+
detection(encoding, confidence, "".b.freeze, text, binary: false)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def detect_file(path, sample: DEFAULT_SAMPLE)
|
|
62
|
+
limit = validate_sample!(sample)
|
|
63
|
+
unless path.is_a?(String) && path.valid_encoding? && !path.include?("\0")
|
|
64
|
+
raise Error, "path must be a valid String without NUL bytes"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
bytes = File.open(path, "rb") { |file| file.read(limit + 1) || "".b }
|
|
68
|
+
detect(bytes, sample: limit)
|
|
69
|
+
rescue SystemCallError => error
|
|
70
|
+
raise Error, "cannot read #{path}: #{error.message}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def binary?(bytes)
|
|
74
|
+
detect(bytes).binary
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def detection(encoding, confidence, bom, text, binary:)
|
|
78
|
+
Detection.new(encoding, confidence, bom, text && newline_kind(text), text && indent(text), binary)
|
|
79
|
+
end
|
|
80
|
+
private_class_method :detection
|
|
81
|
+
|
|
82
|
+
def indent(text)
|
|
83
|
+
prefixes = text.each_line.filter_map { |line| line[/\A[ \t]+(?=\S)/] }
|
|
84
|
+
return nil if prefixes.empty?
|
|
85
|
+
|
|
86
|
+
tabs = prefixes.count { |prefix| prefix.start_with?("\t") }
|
|
87
|
+
spaces = prefixes.filter_map { |prefix| prefix[/\A +/]&.length }
|
|
88
|
+
return Indent.new(:tab, nil) if tabs > spaces.length
|
|
89
|
+
return nil if spaces.empty?
|
|
90
|
+
|
|
91
|
+
widths = [2, 3, 4, 8]
|
|
92
|
+
width = widths.max_by { |candidate| [spaces.count { |value| (value % candidate).zero? }.fdiv(spaces.length), candidate] }
|
|
93
|
+
width = 1 if spaces.count { |value| (value % width).zero? }.fdiv(spaces.length) < 0.6
|
|
94
|
+
Indent.new(:space, width)
|
|
95
|
+
end
|
|
96
|
+
private_class_method :indent
|
|
97
|
+
|
|
98
|
+
def nul_encoding(bytes, truncated:)
|
|
99
|
+
options = []
|
|
100
|
+
if bytes.bytesize >= 4
|
|
101
|
+
columns = 4.times.map { |offset| bytes.bytes.each_with_index.count { |byte, index| (index % 4) == offset && byte.zero? } }
|
|
102
|
+
groups = bytes.bytesize / 4
|
|
103
|
+
options << Encoding::UTF_32LE if columns[1..].all? { |count| count.fdiv(groups) > 0.6 }
|
|
104
|
+
options << Encoding::UTF_32BE if columns[0, 3].all? { |count| count.fdiv(groups) > 0.6 }
|
|
105
|
+
end
|
|
106
|
+
if bytes.bytesize >= 2
|
|
107
|
+
pairs = bytes.bytesize / 2
|
|
108
|
+
even = bytes.bytes.each_with_index.count { |byte, index| index.even? && byte.zero? }.fdiv(pairs)
|
|
109
|
+
odd = bytes.bytes.each_with_index.count { |byte, index| index.odd? && byte.zero? }.fdiv(pairs)
|
|
110
|
+
options << Encoding::UTF_16LE if odd > 0.3 && even < 0.2
|
|
111
|
+
options << Encoding::UTF_16BE if even > 0.3 && odd < 0.2
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
options.uniq.each do |encoding|
|
|
115
|
+
text = EncodingScorer.decode(bytes, encoding, truncated: truncated)
|
|
116
|
+
return [encoding, text] if text && EncodingScorer.textual?(text)
|
|
117
|
+
end
|
|
118
|
+
[nil, nil]
|
|
119
|
+
end
|
|
120
|
+
private_class_method :nul_encoding
|
|
121
|
+
|
|
122
|
+
def binary_sample?(bytes)
|
|
123
|
+
return false if bytes.empty?
|
|
124
|
+
|
|
125
|
+
controls = bytes.each_byte.count { |byte| byte < 32 && ![9, 10, 12, 13, 27].include?(byte) }
|
|
126
|
+
controls.fdiv(bytes.bytesize) > 0.3
|
|
127
|
+
end
|
|
128
|
+
private_class_method :binary_sample?
|
|
129
|
+
|
|
130
|
+
def normalize_hint(hint)
|
|
131
|
+
return nil if hint.nil?
|
|
132
|
+
hint = hint[:encoding] || hint["encoding"] if hint.is_a?(Hash)
|
|
133
|
+
encoding = hint.is_a?(Encoding) ? hint : Encoding.find(hint.to_s)
|
|
134
|
+
encoding = Encoding::Windows_31J if encoding == Encoding::Shift_JIS
|
|
135
|
+
raise Error, "unsupported encoding hint: #{hint}" unless SUPPORTED_ENCODINGS.include?(encoding)
|
|
136
|
+
|
|
137
|
+
encoding
|
|
138
|
+
rescue ArgumentError
|
|
139
|
+
raise Error, "unknown encoding hint: #{hint}"
|
|
140
|
+
end
|
|
141
|
+
private_class_method :normalize_hint
|
|
142
|
+
|
|
143
|
+
def validate_bytes!(bytes)
|
|
144
|
+
raise Error, "bytes must be a String" unless bytes.is_a?(String)
|
|
145
|
+
end
|
|
146
|
+
private_class_method :validate_bytes!
|
|
147
|
+
|
|
148
|
+
def validate_sample!(sample)
|
|
149
|
+
raise Error, "sample must be an Integer between 4 and #{MAX_SAMPLE}" unless sample.is_a?(Integer) && sample.between?(4, MAX_SAMPLE)
|
|
150
|
+
|
|
151
|
+
sample
|
|
152
|
+
end
|
|
153
|
+
private_class_method :validate_sample!
|
|
154
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menkar
|
|
4
|
+
module EncodingScorer
|
|
5
|
+
CANDIDATES = [
|
|
6
|
+
[Encoding::Windows_31J, :japanese],
|
|
7
|
+
[Encoding::EUC_JP, :japanese],
|
|
8
|
+
[Encoding::ISO_2022_JP, :japanese],
|
|
9
|
+
[Encoding::GBK, :simplified_chinese],
|
|
10
|
+
[Encoding::Big5, :traditional_chinese],
|
|
11
|
+
[Encoding::EUC_KR, :korean],
|
|
12
|
+
[Encoding::Windows_1252, :western]
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
COMMON = {
|
|
16
|
+
japanese: "のにをはがとでてし日本人一日年大中本時行見言生子上来国私",
|
|
17
|
+
simplified_chinese: "的一是不了在人有我他这中大来上国个们为时会后发里",
|
|
18
|
+
traditional_chinese: "的一是不了在人有我他這中大來上國個們為時會後發裡",
|
|
19
|
+
korean: "이다는을를에의가이은한하로있것수나그되사",
|
|
20
|
+
western: "etaoinshrdlucmETAOINSHRDLUCM"
|
|
21
|
+
}.transform_values(&:freeze).freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def encodings = CANDIDATES.map(&:first)
|
|
26
|
+
|
|
27
|
+
def legacy(bytes, hint, truncated:)
|
|
28
|
+
scored = []
|
|
29
|
+
CANDIDATES.each do |encoding, language|
|
|
30
|
+
text = decode(bytes, encoding, truncated: truncated)
|
|
31
|
+
next unless text && textual?(text)
|
|
32
|
+
|
|
33
|
+
score = language_score(text, language)
|
|
34
|
+
score += 0.35 if encoding == hint
|
|
35
|
+
return [encoding, confidence(score), text] if language == :japanese && score >= 1.8
|
|
36
|
+
|
|
37
|
+
scored << [score, encoding, text]
|
|
38
|
+
end
|
|
39
|
+
return [nil, nil, nil] if scored.empty?
|
|
40
|
+
|
|
41
|
+
score, encoding, text = scored.max_by(&:first)
|
|
42
|
+
[encoding, confidence(score), text]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def decode(bytes, encoding, truncated:)
|
|
46
|
+
cuts = truncated ? 0..[4, bytes.bytesize].min : 0..0
|
|
47
|
+
cuts.each do |cut|
|
|
48
|
+
source = bytes.byteslice(0, bytes.bytesize - cut).dup.force_encoding(encoding)
|
|
49
|
+
next unless source.valid_encoding?
|
|
50
|
+
|
|
51
|
+
return source.encode(Encoding::UTF_8)
|
|
52
|
+
rescue EncodingError
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def textual?(text)
|
|
59
|
+
return true if text.empty?
|
|
60
|
+
return false if text.include?("\0")
|
|
61
|
+
|
|
62
|
+
text.scan(/[\x00-\x08\x0B\x0E-\x1F]/).length.fdiv(text.length) < 0.05
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def confidence(score)
|
|
66
|
+
[[0.5 + (score * 0.18), 0.99].min, 0.5].max
|
|
67
|
+
end
|
|
68
|
+
private_class_method :confidence
|
|
69
|
+
|
|
70
|
+
def language_score(text, language)
|
|
71
|
+
total = text.count(language == :western ? "^ \t\r\n\f\v" : "^\x00-\x7F")
|
|
72
|
+
return 0.0 if total.zero?
|
|
73
|
+
|
|
74
|
+
ratio = ->(characters) { text.count(characters).fdiv(total) }
|
|
75
|
+
common = text.count(COMMON.fetch(language)).fdiv(total)
|
|
76
|
+
|
|
77
|
+
case language
|
|
78
|
+
when :japanese
|
|
79
|
+
0.2 + ratio.call("\u3040-\u30FF") * 3.1 + ratio.call("\uFF61-\uFF9F") * 0.6 +
|
|
80
|
+
ratio.call("\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u{20000}-\u{2FA1F}") * 0.35 + common * 0.8
|
|
81
|
+
when :simplified_chinese, :traditional_chinese
|
|
82
|
+
0.2 + ratio.call("\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u{20000}-\u{2FA1F}") * 1.2 + common * 1.1
|
|
83
|
+
when :korean
|
|
84
|
+
0.2 + ratio.call("\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uAC00-\uD7AF\uD7B0-\uD7FF") * 1.5 + common
|
|
85
|
+
else
|
|
86
|
+
0.2 + ratio.call("A-Za-z\u00C0-\u024F\u1E00-\u1EFF") * 1.2 + common * 0.6
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
private_class_method :language_score
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menkar
|
|
4
|
+
NEWLINES = {lf: "\n", crlf: "\r\n", cr: "\r"}.freeze
|
|
5
|
+
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def decode(bytes, detection)
|
|
9
|
+
validate_detection!(detection)
|
|
10
|
+
raise Error, "cannot decode binary input" if detection.binary
|
|
11
|
+
raise Error, "bytes must be a String" unless bytes.is_a?(String)
|
|
12
|
+
|
|
13
|
+
source = bytes.b
|
|
14
|
+
unless detection.bom.empty?
|
|
15
|
+
raise Error, "input does not start with the detected BOM" unless source.start_with?(detection.bom)
|
|
16
|
+
|
|
17
|
+
source = source.byteslice(detection.bom.bytesize..) || "".b
|
|
18
|
+
end
|
|
19
|
+
source.force_encoding(detection.encoding)
|
|
20
|
+
raise Error, "invalid #{detection.encoding.name} input" unless source.valid_encoding?
|
|
21
|
+
|
|
22
|
+
source.encode(Encoding::UTF_8)
|
|
23
|
+
rescue EncodingError => error
|
|
24
|
+
raise Error, "cannot decode #{detection.encoding.name}: #{error.message}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def encode(string, detection)
|
|
28
|
+
validate_detection!(detection)
|
|
29
|
+
raise Error, "cannot encode binary input" if detection.binary
|
|
30
|
+
raise Error, "string must be valid text" unless string.is_a?(String) && string.valid_encoding?
|
|
31
|
+
|
|
32
|
+
utf8 = string.encode(Encoding::UTF_8)
|
|
33
|
+
detection.bom + utf8.encode(detection.encoding).b
|
|
34
|
+
rescue EncodingError => error
|
|
35
|
+
raise Error, "cannot encode #{detection.encoding.name}: #{error.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def roundtrip?(value, detection)
|
|
39
|
+
return false unless value.is_a?(String)
|
|
40
|
+
|
|
41
|
+
if value.encoding == Encoding::UTF_8 && value.valid_encoding?
|
|
42
|
+
decode(encode(value, detection), detection) == value
|
|
43
|
+
else
|
|
44
|
+
encode(decode(value, detection), detection) == value.b
|
|
45
|
+
end
|
|
46
|
+
rescue Error
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def normalize_newlines(string, to: :lf)
|
|
51
|
+
raise Error, "string must be valid text" unless string.is_a?(String) && string.valid_encoding?
|
|
52
|
+
replacement = NEWLINES.fetch(to) { raise Error, "newline must be :lf, :crlf, or :cr" }
|
|
53
|
+
utf8 = string.encode(Encoding::UTF_8)
|
|
54
|
+
original = newline_kind(utf8)
|
|
55
|
+
[utf8.gsub(/\r\n|\r|\n/, replacement), original]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_detection!(detection)
|
|
59
|
+
unless detection.is_a?(Detection) && (detection.encoding.nil? || SUPPORTED_ENCODINGS.include?(detection.encoding)) &&
|
|
60
|
+
detection.confidence.is_a?(Float) && detection.confidence.finite? && detection.confidence.between?(0.0, 1.0) &&
|
|
61
|
+
detection.bom.is_a?(String) && detection.bom.encoding == Encoding::BINARY &&
|
|
62
|
+
[:lf, :crlf, :cr, :mixed, :none, nil].include?(detection.newline) &&
|
|
63
|
+
(detection.indent.nil? || detection.indent.is_a?(Indent)) &&
|
|
64
|
+
detection.binary == !!detection.binary
|
|
65
|
+
raise Error, "invalid detection"
|
|
66
|
+
end
|
|
67
|
+
raise Error, "text detection requires an encoding" if !detection.binary && detection.encoding.nil?
|
|
68
|
+
raise Error, "binary detection cannot specify text metadata" if detection.binary && (detection.encoding || !detection.bom.empty?)
|
|
69
|
+
if !detection.bom.empty? && !BOMS.include?([detection.bom, detection.encoding])
|
|
70
|
+
raise Error, "BOM does not match the detected encoding"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
private_class_method :validate_detection!
|
|
74
|
+
end
|
data/lib/menkar.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "menkar/version"
|
|
4
|
+
|
|
5
|
+
module Menkar
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
module Value
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def define(*members)
|
|
12
|
+
return Data.define(*members) if defined?(Data)
|
|
13
|
+
|
|
14
|
+
Struct.new(*members) do
|
|
15
|
+
members.each { |member| undef_method("#{member}=") }
|
|
16
|
+
|
|
17
|
+
def initialize(*values, **keywords)
|
|
18
|
+
if keywords.empty?
|
|
19
|
+
raise ArgumentError, "wrong number of arguments" unless values.length == self.class.members.length
|
|
20
|
+
|
|
21
|
+
super(*values)
|
|
22
|
+
else
|
|
23
|
+
raise ArgumentError, "cannot mix positional and keyword arguments" unless values.empty?
|
|
24
|
+
|
|
25
|
+
missing = self.class.members - keywords.keys
|
|
26
|
+
unknown = keywords.keys - self.class.members
|
|
27
|
+
raise ArgumentError, "missing keyword: #{missing.first.inspect}" unless missing.empty?
|
|
28
|
+
raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
|
|
29
|
+
|
|
30
|
+
super(*self.class.members.map { |member| keywords.fetch(member) })
|
|
31
|
+
end
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def with(**changes)
|
|
36
|
+
return self if changes.empty?
|
|
37
|
+
|
|
38
|
+
unknown = changes.keys - self.class.members
|
|
39
|
+
raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
|
|
40
|
+
|
|
41
|
+
self.class.new(**to_h.merge(changes))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Detection = Value.define(:encoding, :confidence, :bom, :newline, :indent, :binary)
|
|
48
|
+
Indent = Value.define(:style, :width)
|
|
49
|
+
private_constant :Value
|
|
50
|
+
|
|
51
|
+
def self.newline_kind(text)
|
|
52
|
+
crlf = text.scan(/\r\n/).length
|
|
53
|
+
rest = text.gsub(/\r\n/, "")
|
|
54
|
+
kinds = []
|
|
55
|
+
kinds << :crlf if crlf.positive?
|
|
56
|
+
kinds << :lf if rest.include?("\n")
|
|
57
|
+
kinds << :cr if rest.include?("\r")
|
|
58
|
+
return :none if kinds.empty?
|
|
59
|
+
|
|
60
|
+
kinds.one? ? kinds.first : :mixed
|
|
61
|
+
end
|
|
62
|
+
private_class_method :newline_kind
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
require_relative "menkar/encoding_scorer"
|
|
66
|
+
require_relative "menkar/detector"
|
|
67
|
+
require_relative "menkar/transcoder"
|
|
68
|
+
|
|
69
|
+
module Menkar
|
|
70
|
+
private_constant :BOMS, :SUPPORTED_ENCODINGS, :NEWLINES, :EncodingScorer
|
|
71
|
+
end
|
data/sig/menkar.rbs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Menkar
|
|
2
|
+
VERSION: String
|
|
3
|
+
DEFAULT_SAMPLE: Integer
|
|
4
|
+
MAX_SAMPLE: Integer
|
|
5
|
+
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Indent
|
|
10
|
+
attr_reader style: :tab | :space
|
|
11
|
+
attr_reader width: Integer?
|
|
12
|
+
def self.new: (:tab | :space style, Integer? width) -> Indent
|
|
13
|
+
| (style: :tab | :space, width: Integer?) -> Indent
|
|
14
|
+
def with: (?style: :tab | :space, ?width: Integer?) -> Indent
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Detection
|
|
18
|
+
attr_reader encoding: Encoding?
|
|
19
|
+
attr_reader confidence: Float
|
|
20
|
+
attr_reader bom: String
|
|
21
|
+
attr_reader newline: :lf | :crlf | :cr | :mixed | :none | nil
|
|
22
|
+
attr_reader indent: Indent?
|
|
23
|
+
attr_reader binary: bool
|
|
24
|
+
def self.new: (Encoding? encoding, Float confidence, String bom,
|
|
25
|
+
(:lf | :crlf | :cr | :mixed | :none | nil) newline, Indent? indent, bool binary) -> Detection
|
|
26
|
+
| (encoding: Encoding?, confidence: Float, bom: String,
|
|
27
|
+
newline: (:lf | :crlf | :cr | :mixed | :none | nil), indent: Indent?, binary: bool) -> Detection
|
|
28
|
+
def with: (?encoding: Encoding?, ?confidence: Float, ?bom: String,
|
|
29
|
+
?newline: (:lf | :crlf | :cr | :mixed | :none | nil), ?indent: Indent?, ?binary: bool) -> Detection
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.detect: (String bytes, ?hint: untyped, ?sample: Integer) -> Detection
|
|
33
|
+
def self.detect_file: (String path, ?sample: Integer) -> Detection
|
|
34
|
+
def self.binary?: (String bytes) -> bool
|
|
35
|
+
def self.decode: (String bytes, Detection detection) -> String
|
|
36
|
+
def self.encode: (String string, Detection detection) -> String
|
|
37
|
+
def self.roundtrip?: (String value, Detection detection) -> bool
|
|
38
|
+
def self.normalize_newlines: (String string, ?to: :lf | :crlf | :cr) -> [String, Symbol]
|
|
39
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: menkar
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
email:
|
|
13
|
+
- t.yudai92@gmail.com
|
|
14
|
+
executables: []
|
|
15
|
+
extensions: []
|
|
16
|
+
extra_rdoc_files: []
|
|
17
|
+
files:
|
|
18
|
+
- CHANGELOG.md
|
|
19
|
+
- LICENSE.txt
|
|
20
|
+
- README.md
|
|
21
|
+
- docs/adr/000-template.md
|
|
22
|
+
- docs/adr/001-strict-bounded-detection.md
|
|
23
|
+
- docs/adr/README.md
|
|
24
|
+
- lib/menkar.rb
|
|
25
|
+
- lib/menkar/detector.rb
|
|
26
|
+
- lib/menkar/encoding_scorer.rb
|
|
27
|
+
- lib/menkar/transcoder.rb
|
|
28
|
+
- lib/menkar/version.rb
|
|
29
|
+
- sig/menkar.rbs
|
|
30
|
+
homepage: https://github.com/noxdea/menkar
|
|
31
|
+
licenses:
|
|
32
|
+
- MIT
|
|
33
|
+
metadata:
|
|
34
|
+
source_code_uri: https://github.com/noxdea/menkar
|
|
35
|
+
changelog_uri: https://github.com/noxdea/menkar/blob/main/CHANGELOG.md
|
|
36
|
+
allowed_push_host: https://rubygems.org
|
|
37
|
+
rubygems_mfa_required: 'true'
|
|
38
|
+
rdoc_options: []
|
|
39
|
+
require_paths:
|
|
40
|
+
- lib
|
|
41
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3.1'
|
|
46
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '0'
|
|
51
|
+
requirements: []
|
|
52
|
+
rubygems_version: 4.0.16
|
|
53
|
+
specification_version: 4
|
|
54
|
+
summary: Pure Ruby text encoding detection and normalization
|
|
55
|
+
test_files: []
|