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 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
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # ruby/exe/textprov
4
+ #
5
+ # frozen_string_literal: true
6
+
7
+ require "textprov/cli"
8
+
9
+ exit Textprov::CLI.main(ARGV)
@@ -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