textprov 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/LICENSE +21 -0
- data/README.md +75 -0
- data/exe/textprov +9 -0
- data/lib/textprov/cli.rb +179 -0
- data/lib/textprov/core.rb +212 -0
- data/lib/textprov/encode.rb +171 -0
- data/lib/textprov/mapping.json +764 -0
- data/lib/textprov/version.rb +7 -0
- data/lib/textprov.rb +7 -0
- metadata +53 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 12591d9466d6f8f852a3aca23ff985f8183cf6013d95bb2a5f56282641d97a76
|
|
4
|
+
data.tar.gz: 11e17c28beb87e5a9e7ea32ffc6ba21dd80af8ad93a2c334545a2d9e080d824e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 07f475db040b23d302de0bcea2b0ecbd02aa8ba7271bf1a9f0613fb4ebaebdcf58c2328cb96dc16b0d16c8ab8be7eb1ed53b1b28d018202640af4a546d2d634a
|
|
7
|
+
data.tar.gz: b51d9352b3f35ace72bb733afbe51c4321a79febde03dd7fc1c74f6257df1253aed97b3359bfbde5ea774cc47fa94f47338c0c0f988d969c10df1f1fc2ee229b
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TextProv contributors
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# textprov (Ruby)
|
|
2
|
+
|
|
3
|
+
The TextProv reference implementation in Ruby: it puts provenance marks in text
|
|
4
|
+
and reads them back out. Standard library only.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
require "textprov"
|
|
8
|
+
|
|
9
|
+
Textprov.mark("foo", state: "ai") # "f\u{E0101}o\u{E0101}o\u{E0101}"
|
|
10
|
+
Textprov.mark_added("abc", "abXc") # mark only what an edit added
|
|
11
|
+
Textprov.convert(marked, "vs", "pua") # same states, other encoding
|
|
12
|
+
Textprov.strip_marks(marked) # back to the original text
|
|
13
|
+
|
|
14
|
+
Textprov.runs("f\u{E0101}oo")
|
|
15
|
+
# => [["ai", "f\u{E0101}"], [nil, "oo"]]
|
|
16
|
+
|
|
17
|
+
Textprov.to_html("f\u{E0101}oo")
|
|
18
|
+
# => '<span class="prov prov-ai" data-prov="ai">f\u{E0101}</span>oo'
|
|
19
|
+
|
|
20
|
+
Textprov.inspect_text(marked) # a 'key: value' report of the states
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`runs` and `to_html` take `strip:`, `merge_whitespace:` (also `mergeWhitespace:`
|
|
24
|
+
as an alias per SPEC's cross-language rule), and (for `to_html`)
|
|
25
|
+
`class_prefix:`. `mark` and `mark_added` take `state:` and `mode:` (`"vs"` or
|
|
26
|
+
`"pua"`). Every function takes `mapping: Textprov::Mapping.load(path)` to use a
|
|
27
|
+
registry other than the copy vendored in this package.
|
|
28
|
+
|
|
29
|
+
The Ruby method is `Textprov.inspect_text`, not `.inspect`, so it does not
|
|
30
|
+
shadow `Kernel#inspect`.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
gem install textprov
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Ruby 3.2+ is required. No runtime dependencies.
|
|
39
|
+
|
|
40
|
+
## CLI
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
textprov -o marked.txt mark --ai draft.txt
|
|
44
|
+
textprov mark-added old.txt new.txt
|
|
45
|
+
textprov convert --from vs --to pua marked.txt
|
|
46
|
+
textprov strip marked.txt
|
|
47
|
+
textprov render marked.txt
|
|
48
|
+
textprov inspect marked.txt
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A file argument may be `-` for stdin; output goes to stdout unless `-o` is
|
|
52
|
+
given. Text is read and written as UTF-8 verbatim, so CRLF survives.
|
|
53
|
+
|
|
54
|
+
## Contract and mapping versions
|
|
55
|
+
|
|
56
|
+
- Contract version 1 (see [SPEC.md](../SPEC.md)).
|
|
57
|
+
- Mapping version 1 (see [mapping.json](../mapping.json)). `lib/textprov/mapping.json`
|
|
58
|
+
is a vendored copy and the test suite fails if it drifts from the canonical
|
|
59
|
+
file.
|
|
60
|
+
|
|
61
|
+
## Tests
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
bundle install
|
|
65
|
+
bundle exec rake test
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The suite runs every decoder, producer, and convert case in
|
|
69
|
+
[fixtures.json](../fixtures.json), tests the producer properties from
|
|
70
|
+
[SPEC.md](../SPEC.md) directly rather than only through recorded outputs, and
|
|
71
|
+
unit-tests `cluster_end`, `to_html`, `strip_marks`, and the CLI.
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT. See [LICENSE](LICENSE).
|
data/exe/textprov
ADDED
data/lib/textprov/cli.rb
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# ruby/lib/textprov/cli.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require "optparse"
|
|
6
|
+
require_relative "../textprov"
|
|
7
|
+
|
|
8
|
+
module Textprov
|
|
9
|
+
module CLI
|
|
10
|
+
PROG = "textprov"
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def read_input(path)
|
|
15
|
+
if path == "-"
|
|
16
|
+
$stdin.binmode
|
|
17
|
+
$stdin.read.force_encoding("UTF-8")
|
|
18
|
+
else
|
|
19
|
+
File.binread(path).force_encoding("UTF-8")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def write_output(text, path)
|
|
24
|
+
bytes = text.dup.force_encoding("UTF-8").b
|
|
25
|
+
if path
|
|
26
|
+
File.binwrite(path, bytes)
|
|
27
|
+
else
|
|
28
|
+
$stdout.binmode
|
|
29
|
+
$stdout.write(bytes)
|
|
30
|
+
$stdout.flush
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def version_string
|
|
35
|
+
"#{PROG} #{Textprov::VERSION} (contract #{Textprov::CONTRACT_VERSION}, mapping #{Textprov.default_mapping.version})"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def usage
|
|
39
|
+
<<~USAGE
|
|
40
|
+
Usage: #{PROG} [-o FILE] COMMAND [OPTIONS] ARGS...
|
|
41
|
+
|
|
42
|
+
Commands:
|
|
43
|
+
mark [--human|--ai|--mixed] [--mode vs|pua] FILE
|
|
44
|
+
mark-added [--human|--ai|--mixed] [--mode vs|pua] OLD NEW
|
|
45
|
+
convert --from vs|pua --to vs|pua FILE
|
|
46
|
+
strip FILE
|
|
47
|
+
render [--strip] [--no-merge-whitespace] [--class-prefix P] FILE
|
|
48
|
+
inspect FILE
|
|
49
|
+
|
|
50
|
+
FILE may be '-' for stdin. Text is read and written as UTF-8 verbatim.
|
|
51
|
+
--version prints the version and exits.
|
|
52
|
+
USAGE
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse_state(argv, defaults: { state: "ai", mode: "vs" })
|
|
56
|
+
opts = defaults.dup
|
|
57
|
+
rest = []
|
|
58
|
+
i = 0
|
|
59
|
+
while i < argv.length
|
|
60
|
+
case argv[i]
|
|
61
|
+
when "--human", "--ai", "--mixed"
|
|
62
|
+
opts[:state] = argv[i].sub(/^--/, "")
|
|
63
|
+
when "--mode"
|
|
64
|
+
opts[:mode] = argv[i + 1]
|
|
65
|
+
raise ArgumentError, "invalid --mode #{opts[:mode].inspect}" unless %w[vs
|
|
66
|
+
pua].include?(opts[:mode])
|
|
67
|
+
|
|
68
|
+
i += 1
|
|
69
|
+
else
|
|
70
|
+
rest << argv[i]
|
|
71
|
+
end
|
|
72
|
+
i += 1
|
|
73
|
+
end
|
|
74
|
+
[opts, rest]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def main(argv)
|
|
78
|
+
argv = argv.dup
|
|
79
|
+
# Top-level --version / --help handled before subcommand dispatch.
|
|
80
|
+
if argv.include?("--version")
|
|
81
|
+
$stdout.puts version_string
|
|
82
|
+
return 0
|
|
83
|
+
end
|
|
84
|
+
if argv.include?("--help") || argv.include?("-h") || argv.empty?
|
|
85
|
+
$stdout.puts usage
|
|
86
|
+
return argv.empty? ? 1 : 0
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# -o/--output may appear before or after the command.
|
|
90
|
+
output = nil
|
|
91
|
+
filtered = []
|
|
92
|
+
i = 0
|
|
93
|
+
while i < argv.length
|
|
94
|
+
case argv[i]
|
|
95
|
+
when "-o", "--output"
|
|
96
|
+
output = argv[i + 1]
|
|
97
|
+
i += 1
|
|
98
|
+
else
|
|
99
|
+
filtered << argv[i]
|
|
100
|
+
end
|
|
101
|
+
i += 1
|
|
102
|
+
end
|
|
103
|
+
argv = filtered
|
|
104
|
+
|
|
105
|
+
command = argv.shift
|
|
106
|
+
out =
|
|
107
|
+
case command
|
|
108
|
+
when "mark"
|
|
109
|
+
opts, rest = parse_state(argv)
|
|
110
|
+
raise ArgumentError, "mark: FILE required" if rest.empty?
|
|
111
|
+
|
|
112
|
+
Textprov.mark(read_input(rest[0]), state: opts[:state], mode: opts[:mode])
|
|
113
|
+
when "mark-added"
|
|
114
|
+
opts, rest = parse_state(argv)
|
|
115
|
+
raise ArgumentError, "mark-added: OLD and NEW required" if rest.length < 2
|
|
116
|
+
|
|
117
|
+
Textprov.mark_added(read_input(rest[0]), read_input(rest[1]),
|
|
118
|
+
state: opts[:state], mode: opts[:mode])
|
|
119
|
+
when "convert"
|
|
120
|
+
from_mode = nil
|
|
121
|
+
to_mode = nil
|
|
122
|
+
rest = []
|
|
123
|
+
i = 0
|
|
124
|
+
while i < argv.length
|
|
125
|
+
case argv[i]
|
|
126
|
+
when "--from" then from_mode = argv[i + 1]
|
|
127
|
+
i += 1
|
|
128
|
+
when "--to" then to_mode = argv[i + 1]
|
|
129
|
+
i += 1
|
|
130
|
+
else rest << argv[i]
|
|
131
|
+
end
|
|
132
|
+
i += 1
|
|
133
|
+
end
|
|
134
|
+
raise ArgumentError, "convert: --from and --to required" unless from_mode && to_mode
|
|
135
|
+
raise ArgumentError, "convert: FILE required" if rest.empty?
|
|
136
|
+
|
|
137
|
+
Textprov.convert(read_input(rest[0]), from_mode, to_mode)
|
|
138
|
+
when "strip"
|
|
139
|
+
raise ArgumentError, "strip: FILE required" if argv.empty?
|
|
140
|
+
|
|
141
|
+
Textprov.strip_marks(read_input(argv[0]))
|
|
142
|
+
when "render"
|
|
143
|
+
strip = false
|
|
144
|
+
merge_ws = true
|
|
145
|
+
class_prefix = "prov"
|
|
146
|
+
rest = []
|
|
147
|
+
i = 0
|
|
148
|
+
while i < argv.length
|
|
149
|
+
case argv[i]
|
|
150
|
+
when "--strip" then strip = true
|
|
151
|
+
when "--no-merge-whitespace" then merge_ws = false
|
|
152
|
+
when "--class-prefix" then class_prefix = argv[i + 1]
|
|
153
|
+
i += 1
|
|
154
|
+
else rest << argv[i]
|
|
155
|
+
end
|
|
156
|
+
i += 1
|
|
157
|
+
end
|
|
158
|
+
raise ArgumentError, "render: FILE required" if rest.empty?
|
|
159
|
+
|
|
160
|
+
Textprov.to_html(read_input(rest[0]),
|
|
161
|
+
strip: strip, merge_whitespace: merge_ws, class_prefix: class_prefix)
|
|
162
|
+
when "inspect"
|
|
163
|
+
raise ArgumentError, "inspect: FILE required" if argv.empty?
|
|
164
|
+
|
|
165
|
+
Textprov.inspect_text(read_input(argv[0]))
|
|
166
|
+
else
|
|
167
|
+
warn "unknown command: #{command}"
|
|
168
|
+
warn usage
|
|
169
|
+
return 2
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
write_output(out, output)
|
|
173
|
+
0
|
|
174
|
+
rescue ArgumentError => e
|
|
175
|
+
warn "#{PROG}: #{e.message}"
|
|
176
|
+
2
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# ruby/lib/textprov/core.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require "cgi"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Textprov
|
|
9
|
+
CONTRACT_VERSION = 1
|
|
10
|
+
GENERATED_STATES = %w[human ai mixed].freeze
|
|
11
|
+
PROPOSED_STATES = %w[edited unknown].freeze
|
|
12
|
+
|
|
13
|
+
MAPPING_PATH = File.expand_path("mapping.json", __dir__)
|
|
14
|
+
|
|
15
|
+
ZWJ = 0x200D
|
|
16
|
+
VS15 = 0xFE0E
|
|
17
|
+
VS16 = 0xFE0F
|
|
18
|
+
SKIN_TONES = (0x1F3FB..0x1F3FF)
|
|
19
|
+
REGIONAL = (0x1F1E6..0x1F1FF)
|
|
20
|
+
|
|
21
|
+
# Categories Mn/Mc/Me: extracted from Unicode data. Ruby's regexp \p{Mn} etc.
|
|
22
|
+
# covers this; using a regex avoids shipping our own category table.
|
|
23
|
+
COMBINING_RE = /[\p{Mn}\p{Mc}\p{Me}]/
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def is_combining(char)
|
|
28
|
+
!!(char =~ COMBINING_RE)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A cluster is a base character plus everything that visually belongs to it:
|
|
32
|
+
# combining marks, VS15/VS16, skin-tone modifiers, ZWJ joins, and the second
|
|
33
|
+
# half of a regional-indicator pair. A provenance selector always ends the
|
|
34
|
+
# cluster.
|
|
35
|
+
def cluster_end(chars, start, sel_cps)
|
|
36
|
+
index = start + 1
|
|
37
|
+
if REGIONAL.include?(chars[start].ord) &&
|
|
38
|
+
index < chars.length &&
|
|
39
|
+
REGIONAL.include?(chars[index].ord)
|
|
40
|
+
index += 1
|
|
41
|
+
end
|
|
42
|
+
while index < chars.length
|
|
43
|
+
cp = chars[index].ord
|
|
44
|
+
break if sel_cps.include?(cp)
|
|
45
|
+
|
|
46
|
+
if cp == ZWJ
|
|
47
|
+
index += (index + 1 < chars.length ? 2 : 1)
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
if cp == VS15 || cp == VS16 || SKIN_TONES.include?(cp) || is_combining(chars[index])
|
|
51
|
+
index += 1
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
break
|
|
55
|
+
end
|
|
56
|
+
index
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class Mapping
|
|
60
|
+
attr_reader :raw, :version, :selectors, :pua2base, :base2pua
|
|
61
|
+
|
|
62
|
+
def initialize(raw:, version:, selectors:, pua2base:, base2pua:)
|
|
63
|
+
@raw = raw
|
|
64
|
+
@version = version
|
|
65
|
+
@selectors = selectors
|
|
66
|
+
@pua2base = pua2base
|
|
67
|
+
@base2pua = base2pua
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.load(path = MAPPING_PATH)
|
|
71
|
+
raw = JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
72
|
+
selectors = {}
|
|
73
|
+
raw["variation_selectors"].each do |name, value|
|
|
74
|
+
selectors[name] = Integer(value.sub(/^U\+/, ""), 16)
|
|
75
|
+
end
|
|
76
|
+
pua2base = {}
|
|
77
|
+
base2pua = {}
|
|
78
|
+
raw["pua"].each do |pua_string, entry|
|
|
79
|
+
pua = Integer(pua_string.sub(/^U\+/, ""), 16)
|
|
80
|
+
base = Integer(entry["base"].sub(/^U\+/, ""), 16)
|
|
81
|
+
state = entry["provenance"] || "ai"
|
|
82
|
+
pua2base[pua] = [base, state]
|
|
83
|
+
base2pua[base] = pua if state == "ai"
|
|
84
|
+
end
|
|
85
|
+
new(raw: raw, version: raw["version"], selectors: selectors,
|
|
86
|
+
pua2base: pua2base, base2pua: base2pua)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
@default_mapping = nil
|
|
91
|
+
|
|
92
|
+
def default_mapping
|
|
93
|
+
@default_mapping ||= Mapping.load
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class << self
|
|
97
|
+
attr_writer :default_mapping
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# `text` is a String; we work over its per-codepoint characters. Ruby's
|
|
101
|
+
# String#chars yields one Unicode scalar per element, which is what the
|
|
102
|
+
# Python port iterates.
|
|
103
|
+
def _strip(text, selectors, pua2base)
|
|
104
|
+
sel_cps = selectors.values.to_set
|
|
105
|
+
out = String.new(encoding: "UTF-8")
|
|
106
|
+
text.each_char do |char|
|
|
107
|
+
cp = char.ord
|
|
108
|
+
next if sel_cps.include?(cp)
|
|
109
|
+
|
|
110
|
+
entry = pua2base[cp]
|
|
111
|
+
out << (entry ? entry[0].chr(Encoding::UTF_8) : char)
|
|
112
|
+
end
|
|
113
|
+
out
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def _runs(text, selectors, pua2base, strip: false, merge_whitespace: true)
|
|
117
|
+
sel_cps = selectors.values.to_set
|
|
118
|
+
sel2name = selectors.each_with_object({}) { |(name, cp), h| h[cp] = name }
|
|
119
|
+
chars = text.chars
|
|
120
|
+
items = []
|
|
121
|
+
index = 0
|
|
122
|
+
while index < chars.length
|
|
123
|
+
char = chars[index]
|
|
124
|
+
cp = char.ord
|
|
125
|
+
if pua2base.key?(cp)
|
|
126
|
+
base_cp, state = pua2base[cp]
|
|
127
|
+
out = strip ? base_cp.chr(Encoding::UTF_8) : base_cp.chr(Encoding::UTF_8) + selectors[state].chr(Encoding::UTF_8)
|
|
128
|
+
index += 1
|
|
129
|
+
elsif char.match?(/\s/)
|
|
130
|
+
state = "ws"
|
|
131
|
+
out = char
|
|
132
|
+
index += 1
|
|
133
|
+
else
|
|
134
|
+
endi = cluster_end(chars, index, sel_cps)
|
|
135
|
+
cluster = chars[index...endi].join
|
|
136
|
+
state = nil
|
|
137
|
+
out = cluster
|
|
138
|
+
index = endi
|
|
139
|
+
if index < chars.length && sel_cps.include?(chars[index].ord)
|
|
140
|
+
state = sel2name[chars[index].ord]
|
|
141
|
+
out += chars[index] unless strip
|
|
142
|
+
index += 1
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
if !items.empty? && items.last[0] == state
|
|
146
|
+
items.last[1] << out
|
|
147
|
+
else
|
|
148
|
+
items << [state, out.dup]
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
merged = []
|
|
153
|
+
items.each_with_index do |(state, out), position|
|
|
154
|
+
following = position + 1 < items.length ? items[position + 1][0] : nil
|
|
155
|
+
if merge_whitespace && state == "ws" && !merged.empty? && merged.last[0] && merged.last[0] == following
|
|
156
|
+
merged.last[1] << out
|
|
157
|
+
next
|
|
158
|
+
end
|
|
159
|
+
state = nil if state == "ws"
|
|
160
|
+
if !merged.empty? && merged.last[0] == state
|
|
161
|
+
merged.last[1] << out
|
|
162
|
+
else
|
|
163
|
+
merged << [state, out.dup]
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
merged.map { |state, out| [state, out] }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def _to_html(text, selectors, pua2base, strip: false, merge_whitespace: true,
|
|
170
|
+
class_prefix: "prov")
|
|
171
|
+
parts = []
|
|
172
|
+
_runs(text, selectors, pua2base, strip: strip,
|
|
173
|
+
merge_whitespace: merge_whitespace).each do |state, out|
|
|
174
|
+
escaped = CGI.escapeHTML(out)
|
|
175
|
+
if state
|
|
176
|
+
parts << %(<span class="#{class_prefix} #{class_prefix}-#{state}" data-prov="#{state}">#{escaped}</span>)
|
|
177
|
+
else
|
|
178
|
+
parts << escaped
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
parts.join
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Public API accepts either merge_whitespace (contract spelling) or
|
|
185
|
+
# mergeWhitespace (camelCase alias per SPEC's cross-language rule).
|
|
186
|
+
def runs(text, mapping: nil, strip: false, merge_whitespace: nil, mergeWhitespace: nil)
|
|
187
|
+
mw = if merge_whitespace.nil?
|
|
188
|
+
mergeWhitespace.nil? || mergeWhitespace
|
|
189
|
+
else
|
|
190
|
+
merge_whitespace
|
|
191
|
+
end
|
|
192
|
+
m = mapping || default_mapping
|
|
193
|
+
_runs(text, m.selectors, m.pua2base, strip: strip, merge_whitespace: mw)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def to_html(text, mapping: nil, strip: false, merge_whitespace: nil, mergeWhitespace: nil,
|
|
197
|
+
class_prefix: "prov")
|
|
198
|
+
mw = if merge_whitespace.nil?
|
|
199
|
+
mergeWhitespace.nil? || mergeWhitespace
|
|
200
|
+
else
|
|
201
|
+
merge_whitespace
|
|
202
|
+
end
|
|
203
|
+
m = mapping || default_mapping
|
|
204
|
+
_to_html(text, m.selectors, m.pua2base, strip: strip, merge_whitespace: mw,
|
|
205
|
+
class_prefix: class_prefix)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def strip_marks(text, mapping: nil)
|
|
209
|
+
m = mapping || default_mapping
|
|
210
|
+
_strip(text, m.selectors, m.pua2base)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# ruby/lib/textprov/encode.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative "core"
|
|
6
|
+
|
|
7
|
+
module Textprov
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def _mark(text, state, mode, selectors, pua2base, base2pua)
|
|
11
|
+
sel_cps = selectors.values.to_set
|
|
12
|
+
selector = selectors[state].chr(Encoding::UTF_8)
|
|
13
|
+
chars = text.chars
|
|
14
|
+
out = String.new(encoding: "UTF-8")
|
|
15
|
+
index = 0
|
|
16
|
+
while index < chars.length
|
|
17
|
+
char = chars[index]
|
|
18
|
+
cp = char.ord
|
|
19
|
+
if sel_cps.include?(cp) || pua2base.key?(cp) || char.match?(/\s/)
|
|
20
|
+
out << char
|
|
21
|
+
index += 1
|
|
22
|
+
next
|
|
23
|
+
end
|
|
24
|
+
start = index
|
|
25
|
+
index = cluster_end(chars, index, sel_cps)
|
|
26
|
+
cluster = chars[start...index]
|
|
27
|
+
if index < chars.length && sel_cps.include?(chars[index].ord)
|
|
28
|
+
cluster.each { |c| out << c }
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
if mode == "pua" && state == "ai" && cluster.length == 1 && base2pua.key?(cp)
|
|
32
|
+
out << base2pua[cp].chr(Encoding::UTF_8)
|
|
33
|
+
else
|
|
34
|
+
cluster.each { |c| out << c }
|
|
35
|
+
out << selector
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
out
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# `old_text` and `new_text` are code-point-wise compared via chars, matching
|
|
42
|
+
# the Python impl (Python indexes by code point).
|
|
43
|
+
def _mark_added(old_text, new_text, state, mode, selectors, pua2base, base2pua)
|
|
44
|
+
return _mark(new_text, state, mode, selectors, pua2base, base2pua) if old_text.empty?
|
|
45
|
+
|
|
46
|
+
old_chars = old_text.chars
|
|
47
|
+
new_chars = new_text.chars
|
|
48
|
+
pre = 0
|
|
49
|
+
while pre < [old_chars.length, new_chars.length].min && old_chars[pre] == new_chars[pre]
|
|
50
|
+
pre += 1
|
|
51
|
+
end
|
|
52
|
+
suf = 0
|
|
53
|
+
while suf < [old_chars.length,
|
|
54
|
+
new_chars.length].min - pre && old_chars[-1 - suf] == new_chars[-1 - suf]
|
|
55
|
+
suf += 1
|
|
56
|
+
end
|
|
57
|
+
endi = new_chars.length - suf
|
|
58
|
+
middle = new_chars[pre...endi].join
|
|
59
|
+
middle_marked = _mark(middle, state, mode, selectors, pua2base, base2pua)
|
|
60
|
+
new_chars[0...pre].join + middle_marked + new_chars[endi...new_chars.length].join
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def _convert(text, from_mode, to_mode, selectors, pua2base, base2pua)
|
|
64
|
+
return text if from_mode == to_mode
|
|
65
|
+
|
|
66
|
+
vs_ai = selectors["ai"]
|
|
67
|
+
chars = text.chars
|
|
68
|
+
out = String.new(encoding: "UTF-8")
|
|
69
|
+
if from_mode == "vs"
|
|
70
|
+
index = 0
|
|
71
|
+
while index < chars.length
|
|
72
|
+
char = chars[index]
|
|
73
|
+
index += 1
|
|
74
|
+
if index < chars.length && chars[index].ord == vs_ai && base2pua.key?(char.ord)
|
|
75
|
+
out << base2pua[char.ord].chr(Encoding::UTF_8)
|
|
76
|
+
index += 1
|
|
77
|
+
else
|
|
78
|
+
out << char
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
return out
|
|
82
|
+
end
|
|
83
|
+
chars.each do |char|
|
|
84
|
+
entry = pua2base[char.ord]
|
|
85
|
+
if entry && entry[1] == "ai"
|
|
86
|
+
out << entry[0].chr(Encoding::UTF_8)
|
|
87
|
+
out << vs_ai.chr(Encoding::UTF_8)
|
|
88
|
+
else
|
|
89
|
+
out << char
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
out
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def _inspect(text, selectors, pua2base)
|
|
96
|
+
sel2name = selectors.each_with_object({}) { |(name, cp), h| h[cp] = name }
|
|
97
|
+
counts = { "unmarked" => 0, "human" => 0, "ai_vs" => 0, "ai_pua" => 0,
|
|
98
|
+
"unknown" => 0, "edited" => 0, "mixed" => 0, "whitespace" => 0 }
|
|
99
|
+
unrecognised_selectors = []
|
|
100
|
+
unrecognised_pua = []
|
|
101
|
+
sel_cp_set = selectors.values.to_set
|
|
102
|
+
|
|
103
|
+
chars = text.chars
|
|
104
|
+
index = 0
|
|
105
|
+
while index < chars.length
|
|
106
|
+
char = chars[index]
|
|
107
|
+
cp = char.ord
|
|
108
|
+
index += 1
|
|
109
|
+
if sel2name.key?(cp)
|
|
110
|
+
unrecognised_selectors << cp
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
if cp.between?(0xE0100, 0xE01EF)
|
|
114
|
+
unrecognised_selectors << cp
|
|
115
|
+
next
|
|
116
|
+
end
|
|
117
|
+
if pua2base.key?(cp)
|
|
118
|
+
counts["ai_pua"] += 1
|
|
119
|
+
next
|
|
120
|
+
end
|
|
121
|
+
if cp.between?(0x100000, 0x10FFFD)
|
|
122
|
+
unrecognised_pua << cp
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
if char.match?(/\s/)
|
|
126
|
+
counts["whitespace"] += 1
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
index = cluster_end(chars, index - 1, sel_cp_set)
|
|
130
|
+
if index < chars.length && sel_cp_set.include?(chars[index].ord)
|
|
131
|
+
name = sel2name[chars[index].ord]
|
|
132
|
+
index += 1
|
|
133
|
+
key = name == "ai" ? "ai_vs" : name
|
|
134
|
+
counts[key] += 1
|
|
135
|
+
else
|
|
136
|
+
counts["unmarked"] += 1
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
lines = ["characters: #{chars.length}"]
|
|
141
|
+
%w[unmarked human ai_vs ai_pua unknown edited mixed whitespace].each do |key|
|
|
142
|
+
lines << "#{key}: #{counts[key]}"
|
|
143
|
+
end
|
|
144
|
+
sels = unrecognised_selectors.uniq.sort.map { |cp| format("U+%04X", cp) }.join(" ")
|
|
145
|
+
puas = unrecognised_pua.uniq.sort.map { |cp| format("U+%04X", cp) }.join(" ")
|
|
146
|
+
lines << "unrecognised_selectors: #{sels.empty? ? "-" : sels}"
|
|
147
|
+
lines << "unrecognised_pua: #{puas.empty? ? "-" : puas}"
|
|
148
|
+
"#{lines.join("\n")}\n"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def mark(text, state: "ai", mode: "vs", mapping: nil)
|
|
152
|
+
m = mapping || default_mapping
|
|
153
|
+
_mark(text, state, mode, m.selectors, m.pua2base, m.base2pua)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def mark_added(old_text, new_text, state: "ai", mode: "vs", mapping: nil)
|
|
157
|
+
m = mapping || default_mapping
|
|
158
|
+
_mark_added(old_text, new_text, state, mode, m.selectors, m.pua2base, m.base2pua)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def convert(text, from_mode, to_mode, mapping: nil)
|
|
162
|
+
m = mapping || default_mapping
|
|
163
|
+
_convert(text, from_mode, to_mode, m.selectors, m.pua2base, m.base2pua)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Named inspect_text to avoid shadowing Kernel#inspect / Object#inspect.
|
|
167
|
+
def inspect_text(text, mapping: nil)
|
|
168
|
+
m = mapping || default_mapping
|
|
169
|
+
_inspect(text, m.selectors, m.pua2base)
|
|
170
|
+
end
|
|
171
|
+
end
|