cjk_index 0.2.0 → 1.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.
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../cjk_index"
6
+
7
+ module CJKIndex
8
+ # The command-line tool (exe/cjk_index) and the logic that the Rake task
9
+ # shares with it. Needs only Ruby's standard library; "csv" is loaded the
10
+ # first time a CSV file is read (see Records.csv).
11
+ #
12
+ # cjk_index build items.csv --ref objectid --fields title,creator --boost title=3 -o index.json
13
+ # cjk_index search index.json 鳥瞰図 --filter type=地図
14
+ # cjk_index runtime -o cjk-index.js
15
+ module CLI
16
+ # A mistake in the arguments or the input; run prints it and returns 2.
17
+ class Error < StandardError; end
18
+
19
+ USAGE = <<~TEXT
20
+ Usage: cjk_index COMMAND [options]
21
+
22
+ Commands:
23
+ build read records (CSV, JSON, JSON Lines) and write an index
24
+ search query an index file
25
+ runtime write the browser script
26
+
27
+ Run "cjk_index COMMAND --help" for the options of a command.
28
+ Options: -h, --help -v, --version
29
+ TEXT
30
+
31
+ FORMATS = %w[csv json jsonl].freeze
32
+
33
+ module_function
34
+
35
+ # Runs the tool and returns the exit status: 0 done, 1 failed (input could
36
+ # not be read), 2 wrong usage. Output goes to the given streams.
37
+ def run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
38
+ argv = argv.dup
39
+ command = argv.shift
40
+ case command
41
+ when nil
42
+ stderr.puts USAGE
43
+ return 2
44
+ when "-h", "--help", "help" then stdout.puts USAGE
45
+ when "-v", "--version", "version" then stdout.puts VERSION
46
+ when "build" then build_command(argv, stdin, stdout)
47
+ when "search" then search_command(argv, stdout)
48
+ when "runtime" then runtime_command(argv, stdout)
49
+ else raise Error, "unknown command #{command.inspect}\n\n#{USAGE}"
50
+ end
51
+ 0
52
+ rescue Error, OptionParser::ParseError, ArgumentError => e
53
+ stderr.puts "cjk_index: #{e.message.sub(/\Acjk_index: /, '')}"
54
+ 2
55
+ rescue SystemCallError, JSON::ParserError, IOError => e
56
+ stderr.puts "cjk_index: #{e.message}"
57
+ 1
58
+ end
59
+
60
+ # ---- build -------------------------------------------------------------
61
+
62
+ def build_command(argv, stdin, stdout)
63
+ opts = { fields: nil, boosts: {}, filters: {}, variants: Normalizer::DEFAULT_TABLES }
64
+ output = runtime = nil
65
+ parser = OptionParser.new do |o|
66
+ o.banner = "Usage: cjk_index build [FILE|-] [options]\n" \
67
+ "Records come from FILE or standard input: CSV, a JSON array, or JSON Lines."
68
+ o.on("--format FORMAT", FORMATS, "csv, json or jsonl (default: from the file name, or sniffed on stdin)") { |v| opts[:format] = v }
69
+ o.on("--ref COLUMN", "field that identifies a record (default: first field)") { |v| opts[:ref] = v }
70
+ o.on("--fields LIST", Array, "fields to index (default: all but the ref)") { |v| opts[:fields] = v }
71
+ o.on("--boost FIELD=N", "weight of a field, repeatable (default 1)") { |v| name, n = split_pair(v, "--boost"); opts[:boosts][name] = Float(n) }
72
+ o.on("--filter FIELD[=KIND[:SEP]]", "filter on a field: KIND value (default) or year; SEP splits several values in one cell") do |v|
73
+ name, spec = parse_filter(v)
74
+ opts[:filters][name] = spec
75
+ end
76
+ o.on("--variants LIST", Array, "variant tables: ja (default), zh-safe, zh, ko, or table files") { |v| opts[:variants] = v }
77
+ o.on("-o", "--output PATH", "index file (default: standard output)") { |v| output = v }
78
+ o.on("--runtime PATH", "also write the browser script for these variants") { |v| runtime = v }
79
+ end
80
+ parser.parse!(argv)
81
+ raise Error, "too many arguments: #{argv.drop(1).join(' ')}" if argv.length > 1
82
+
83
+ source = argv.first
84
+ source = nil if source == "-"
85
+ format = opts.delete(:format) || format_of(source)
86
+ builder = build_index(**opts, records: read_records(source, format, stdin))
87
+ write_output(output, stdout, builder.to_json)
88
+ write_output(runtime, nil, Runtime.source(normalizer: builder.normalizer)) if runtime
89
+ end
90
+
91
+ # Builds an index from records (an Array of Hashes with string keys).
92
+ # Shared by the command and CJKIndex::RakeTask.
93
+ def build_index(records:, ref: nil, fields: nil, boosts: {}, filters: {}, variants: Normalizer::DEFAULT_TABLES)
94
+ raise Error, "no records" if records.empty?
95
+
96
+ keys = records.first.keys.map(&:to_s)
97
+ ref = (ref || keys.first).to_s
98
+ raise Error, "no field #{ref.inspect} in the records (fields: #{keys.join(', ')})" unless keys.include?(ref)
99
+
100
+ fields = Array(fields || (keys - [ref])).map(&:to_s)
101
+ raise Error, "no fields to index" if fields.empty?
102
+
103
+ missing = (fields + filters.keys.map(&:to_s) + boosts.keys.map(&:to_s)) - keys
104
+ raise Error, "no such field in the records: #{missing.uniq.join(', ')} (fields: #{keys.join(', ')})" unless missing.empty?
105
+
106
+ builder = Builder.new(fields: fields, boosts: boosts, filters: filters,
107
+ normalizer: Normalizer.new(tables: Array(variants).map(&:to_s)))
108
+ records.each do |record|
109
+ value = record[ref] || record[ref.to_sym]
110
+ builder.add(value, record) unless value.to_s.empty?
111
+ end
112
+ builder
113
+ end
114
+
115
+ # ---- reading records ---------------------------------------------------
116
+
117
+ def format_of(path)
118
+ case File.extname(path.to_s).downcase
119
+ when ".csv" then "csv"
120
+ when ".json" then "json"
121
+ when ".jsonl", ".ndjson" then "jsonl"
122
+ end
123
+ end
124
+
125
+ def read_records(path, format, stdin = $stdin)
126
+ text = path ? File.read(path, encoding: "UTF-8") : stdin.read.to_s.dup.force_encoding("UTF-8")
127
+ text = text.delete_prefix("")
128
+ format ||= text.lstrip.start_with?("[") ? "json" : "jsonl"
129
+ case format
130
+ when "csv" then csv(text)
131
+ when "json"
132
+ data = JSON.parse(text)
133
+ raise Error, "the JSON must be an array of objects" unless data.is_a?(Array) && data.all?(Hash)
134
+
135
+ data
136
+ else
137
+ text.each_line.reject { |l| l.strip.empty? }.map.with_index(1) do |line, n|
138
+ JSON.parse(line).tap { |r| raise Error, "line #{n} is not a JSON object" unless r.is_a?(Hash) }
139
+ end
140
+ end
141
+ end
142
+
143
+ # CSV is a bundled gem, not a default one, since Ruby 3.4: under Bundler
144
+ # it can only be loaded when the Gemfile lists it. The gem does not depend
145
+ # on it, so it is loaded here and a missing one is reported.
146
+ def csv(text)
147
+ begin
148
+ require "csv"
149
+ rescue LoadError
150
+ raise Error, "reading CSV needs the csv gem (a bundled gem since Ruby 3.4); " \
151
+ "run `gem install csv`, add `gem \"csv\"` to your Gemfile, or give JSON / JSON Lines instead"
152
+ end
153
+ CSV.parse(text, headers: true).map(&:to_h)
154
+ rescue CSV::MalformedCSVError => e
155
+ raise Error, "invalid CSV: #{e.message}"
156
+ end
157
+
158
+ # ---- search ------------------------------------------------------------
159
+
160
+ def search_command(argv, stdout)
161
+ filter = {}
162
+ limit = 10
163
+ json = false
164
+ parser = OptionParser.new do |o|
165
+ o.banner = "Usage: cjk_index search INDEX.json QUERY [options]"
166
+ o.on("--filter FIELD=VALUE", "keep matching records; repeat for several values or fields; a year filter takes 1900 or 1890..1910") do |v|
167
+ name, value = split_pair(v, "--filter")
168
+ (filter[name] ||= []) << value
169
+ end
170
+ o.on("--limit N", Integer, "results to show (default 10, 0 for all)") { |v| limit = v }
171
+ o.on("--json", "print JSON: [{ref, score, fields}]") { json = true }
172
+ end
173
+ parser.parse!(argv)
174
+ path, *words = argv
175
+ raise Error, "usage: cjk_index search INDEX.json QUERY" if path.nil? || (words.empty? && filter.empty?)
176
+
177
+ searcher = Searcher.load(path)
178
+ filter = filter.transform_values { |values| values.map { |v| filter_value(v) } }
179
+ filter = filter.transform_values { |values| values.length == 1 ? values.first : values }
180
+ results = searcher.search(words.join(" "), filter: filter.empty? ? nil : filter)
181
+ shown = limit.positive? ? results.first(limit) : results
182
+ if json
183
+ stdout.puts JSON.generate(shown)
184
+ else
185
+ shown.each { |r| stdout.puts format("%s\t%.3f\t%s", r[:ref], r[:score], r[:fields].join(",")) }
186
+ stdout.puts "(#{results.length} result#{'s' unless results.length == 1})" if shown.length < results.length
187
+ end
188
+ rescue Errno::ENOENT
189
+ raise Error, "no such file: #{path}"
190
+ end
191
+
192
+ # ---- runtime -----------------------------------------------------------
193
+
194
+ def runtime_command(argv, stdout)
195
+ variants = Normalizer::DEFAULT_TABLES
196
+ output = nil
197
+ OptionParser.new do |o|
198
+ o.banner = "Usage: cjk_index runtime [options]"
199
+ o.on("--variants LIST", Array, "variant tables: ja (default), zh-safe, zh, ko, or table files; must match the index") { |v| variants = v }
200
+ o.on("-o", "--output PATH", "script file (default: standard output)") { |v| output = v }
201
+ end.parse!(argv)
202
+ raise Error, "too many arguments: #{argv.join(' ')}" unless argv.empty?
203
+
204
+ write_output(output, stdout, Runtime.source(normalizer: Normalizer.new(tables: variants)))
205
+ end
206
+
207
+ # ---- helpers -----------------------------------------------------------
208
+
209
+ def split_pair(text, option)
210
+ name, value = text.split("=", 2)
211
+ raise Error, "#{option} takes NAME=VALUE, got #{text.inspect}" if name.to_s.empty? || value.nil?
212
+
213
+ [name, value]
214
+ end
215
+
216
+ # "type", "date=year", "subject=value:;"
217
+ def parse_filter(text)
218
+ name, spec = text.split("=", 2)
219
+ kind, separator = (spec || "value").split(":", 2)
220
+ raise Error, "--filter #{text.inspect}: kind must be value or year" unless Builder::FILTER_KINDS.include?(kind)
221
+
222
+ [name, separator ? { "kind" => kind, "separator" => separator } : kind]
223
+ end
224
+
225
+ def filter_value(text)
226
+ return text unless (m = text.match(/\A(\d*)\.\.(\d*)\z/)) && !(m[1].empty? && m[2].empty?)
227
+
228
+ { "min" => (m[1].empty? ? nil : m[1].to_i), "max" => (m[2].empty? ? nil : m[2].to_i) }.compact
229
+ end
230
+
231
+ def write_output(path, stdout, content)
232
+ if path
233
+ File.write(path, content)
234
+ else
235
+ stdout.write(content)
236
+ stdout.write("\n") unless content.end_with?("\n")
237
+ end
238
+ end
239
+ end
240
+ end
@@ -27,6 +27,10 @@ module CJKIndex
27
27
  #
28
28
  # Locks use flock, which is reliable on a local disk; do not share the file
29
29
  # over NFS.
30
+ #
31
+ # The index is always one file (format 4, as Builder#write). A split index
32
+ # (Builder#write_split) is not supported here: it is meant for static
33
+ # sites, written once per build.
30
34
  class IndexFile
31
35
  # Raised by update and write when another writer holds the lock too long.
32
36
  class LockTimeout < StandardError; end
@@ -9,7 +9,7 @@ module CJKIndex
9
9
  #
10
10
  # cjk_index:
11
11
  # output: assets/cjk-index # where files are written (default)
12
- # variants: [ja] # variant tables: ja (default), zh (Traditional -> Simplified)
12
+ # variants: [ja] # variant tables: ja (default), zh-safe, zh, ko, or a file of your own
13
13
  # indexes:
14
14
  # - name: posts # -> assets/cjk-index/posts.json
15
15
  # collection: posts # a Jekyll collection, or...
@@ -25,8 +25,15 @@ module CJKIndex
25
25
  # date: year
26
26
  # - name: search
27
27
  # preset: collectionbuilder # reads CollectionBuilder's own config
28
+ # - name: texts
29
+ # collection: texts
30
+ # fields: [title, content]
31
+ # split: true # -> assets/cjk-index/texts/index.json and shards
32
+ # shard_size: 32000 # bytes per shard (optional)
28
33
  #
29
- # The browser script is written to <output>/cjk-index.js.
34
+ # The browser script is written to <output>/cjk-index.js. A split index is
35
+ # loaded with CJKIndex.load('<output>/<name>/index.json') and searched with
36
+ # searchAsync, which fetches only the shards a query needs (see Builder).
30
37
  module Jekyll
31
38
  DEFAULT_OUTPUT = "assets/cjk-index"
32
39
  RUNTIME_NAME = "cjk-index.js"
@@ -57,13 +64,22 @@ module CJKIndex
57
64
  return unless config.is_a?(Hash)
58
65
 
59
66
  output = config.fetch("output", DEFAULT_OUTPUT).sub(%r{\A/+}, "").sub(%r{/+\z}, "")
60
- normalizer = Normalizer.new(tables: Array(config.fetch("variants", Normalizer::DEFAULT_TABLES)))
67
+ normalizer = Normalizer.new(tables: Array(config.fetch("variants", Normalizer::DEFAULT_TABLES)), base: site.source)
61
68
  site.static_files << GeneratedFile.new(site, output, RUNTIME_NAME, Runtime.source(normalizer: normalizer))
62
69
  Array(config["indexes"]).each do |spec|
63
70
  builder = build(site, spec, normalizer)
64
- name = "#{spec.fetch('name')}.json"
65
- site.static_files << GeneratedFile.new(site, output, name, builder.to_json)
66
- ::Jekyll.logger.info "cjk_index:", "#{output}/#{name} (#{builder.refs.length} documents)"
71
+ if spec["split"]
72
+ dir = "#{output}/#{spec.fetch('name')}"
73
+ files = builder.each_split_file(shard_size: Integer(spec.fetch("shard_size", Builder::SHARD_SIZE)))
74
+ .map { |name, content| GeneratedFile.new(site, dir, name, content) }
75
+ site.static_files.concat(files)
76
+ ::Jekyll.logger.info "cjk_index:", "#{dir}/#{Builder::MANIFEST} (#{builder.refs.length} documents, " \
77
+ "#{files.length - 2} shards)"
78
+ else
79
+ name = "#{spec.fetch('name')}.json"
80
+ site.static_files << GeneratedFile.new(site, output, name, builder.to_json)
81
+ ::Jekyll.logger.info "cjk_index:", "#{output}/#{name} (#{builder.refs.length} documents)"
82
+ end
67
83
  end
68
84
  end
69
85
 
@@ -79,7 +95,7 @@ module CJKIndex
79
95
  docs = site.collections.fetch(spec["collection"]) do
80
96
  raise ::Jekyll::Errors::FatalException, "cjk_index: no collection #{spec['collection']}"
81
97
  end.docs
82
- docs.each { |doc| builder.add(doc.url, document_fields(doc, builder.fields)) }
98
+ docs.each { |doc| builder.add(doc.url, document_fields(doc, builder.fields | builder.filters.keys)) }
83
99
  elsif spec["data"]
84
100
  ref = spec.fetch("ref", "id")
85
101
  Array(site.data[spec["data"]]).each do |row|
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "json"
5
+
3
6
  module CJKIndex
4
7
  # Folds text so that spelling variants a reader treats as "the same" compare
5
8
  # equal: NFKC (full-width -> half-width etc.), lowercase, katakana ->
@@ -9,18 +12,38 @@ module CJKIndex
9
12
  # The same rules run in the browser (see Runtime), so an index built here
10
13
  # and a query typed there are folded identically.
11
14
  #
12
- # Variant tables (data/*.tsv):
13
- # ja Japanese old forms -> modern forms (圖 -> 図). Default.
14
- # zh Traditional -> Simplified Chinese, from OpenCC. Opt in: it also
15
- # merges characters that are distinct in Japanese (後 and 后).
15
+ # Variant tables (data/*.tsv, regenerated by script/variant_tables.rb):
16
+ # ja Japanese old forms and glyph variants -> one form (圖 -> 図),
17
+ # from the Joyo and Jinmeiyo kanji lists and Unihan. Default.
18
+ # zh Traditional -> Simplified Chinese, from OpenCC. Opt in: it also
19
+ # merges characters that are distinct in Japanese (後 and 后).
20
+ # zh-safe The pairs of zh that are one-to-one both ways (圖 -> 图 but
21
+ # not 後 -> 后 or 發/髮 -> 发). Opt in.
22
+ # ko Old Hangul in the Hanyang Private Use Area -> standard jamo
23
+ # sequences (one character -> several). Opt in.
16
24
  # With several tables, characters linked by any table fold to one form,
17
25
  # preferring the target of the table listed first: with [ja, zh],
18
26
  # 圖, 図 and 图 all become 図.
27
+ # Any other entry names a table file of your own, relative to base (the
28
+ # site's folder for Jekyll and Bridgetown, the current folder otherwise).
29
+ # The index records the entry as written, not the resolved path.
30
+ #
31
+ # Table format: UTF-8 lines "variant<TAB>folded", optionally followed by
32
+ # another tab and a comment column; lines starting with "#" are comments.
33
+ # "folded" may be several characters.
34
+ #
35
+ # Your own table is one group per line, tab-separated: the form to fold to,
36
+ # then its variants, one character each (the format of the itaiji lists
37
+ # that collection databases keep). Each cell is folded first (NFKC,
38
+ # lowercase, katakana -> hiragana), so a compatibility ideograph such as
39
+ # U+F900 joins the group of the character NFKC makes of it.
19
40
  class Normalizer
20
41
  DATA_DIR = File.expand_path("../../data", __dir__)
21
42
  TABLES = {
22
43
  "ja" => "ja-kyujitai.tsv",
23
- "zh" => "zh-hant-hans.tsv"
44
+ "zh" => "zh-hant-hans.tsv",
45
+ "zh-safe" => "zh-hant-hans-safe.tsv",
46
+ "ko" => "ko-hanyang-pua.tsv"
24
47
  }.freeze
25
48
  DEFAULT_TABLES = %w[ja].freeze
26
49
 
@@ -34,35 +57,97 @@ module CJKIndex
34
57
  @default ||= new
35
58
  end
36
59
 
37
- def self.read_table(name)
38
- file = TABLES.fetch(name) { raise ArgumentError, "unknown variant table #{name.inspect} (#{TABLES.keys.join(', ')})" }
39
- File.readlines(File.join(DATA_DIR, file), chomp: true, encoding: "UTF-8")
40
- .reject { |line| line.empty? || line.start_with?("#") }
41
- .map { |line| line.split("\t", 2) }
60
+ # Pairs [variant, folded] of a built-in table, or of a table file of your
61
+ # own (a name that is not built in, resolved against base).
62
+ def self.read_table(name, base: Dir.pwd)
63
+ return read_groups(name, File.expand_path(name, base)) unless TABLES.key?(name)
64
+
65
+ lines(File.join(DATA_DIR, TABLES[name])).map { |_, line| line.split("\t")[0, 2] }
66
+ end
67
+
68
+ def self.lines(path) # :nodoc:
69
+ File.readlines(path, chomp: true, encoding: "UTF-8").each_with_index
70
+ .reject { |line, _| line.strip.empty? || line.start_with?("#") }
71
+ .map { |line, i| [i + 1, line.delete_prefix("\uFEFF")] }
72
+ end
73
+
74
+ def self.read_groups(name, path) # :nodoc:
75
+ unless File.file?(path)
76
+ raise ArgumentError, "unknown variant table #{name.inspect}: not one of #{TABLES.keys.join(', ')}, " \
77
+ "and no file at #{path}"
78
+ end
79
+
80
+ plain = new(tables: [])
81
+ lines(path).flat_map do |number, line|
82
+ target, *variants = line.split("\t").map(&:strip).reject(&:empty?).map { |cell| plain.normalize(cell) }
83
+ variants = variants.uniq.reject { |v| v == target } # NFKC may already fold a cell (㍿)
84
+ variants.each do |v|
85
+ next if v.length == 1
86
+
87
+ raise ArgumentError, "#{name}:#{number}: #{v.inspect} is not one character; " \
88
+ "only the first cell (the form to fold to) may be longer"
89
+ end
90
+ variants.map { |v| [v, target] }
91
+ end
42
92
  end
43
93
 
44
- attr_reader :tables, :variants
94
+ attr_reader :tables, :variants, :digest
45
95
 
46
- def initialize(tables: DEFAULT_TABLES)
96
+ KATAKANA = "#{KATAKANA_FIRST.chr(Encoding::UTF_8)}-#{KATAKANA_LAST.chr(Encoding::UTF_8)}"
97
+ HIRAGANA = "#{(KATAKANA_FIRST - KANA_OFFSET).chr(Encoding::UTF_8)}-#{(KATAKANA_LAST - KANA_OFFSET).chr(Encoding::UTF_8)}"
98
+
99
+ def initialize(tables: DEFAULT_TABLES, base: Dir.pwd)
47
100
  @tables = tables.map(&:to_s).uniq.freeze
101
+ @base = base
48
102
  @variants = build_variants.freeze
103
+ keys = @variants.keys.select { |k| k.length == 1 }
104
+ @variant_chars = keys.empty? ? nil : Regexp.new("[#{keys.map { |k| format('\\u{%x}', k.ord) }.join}]")
105
+ # What the tables fold, not their names: an index built with an older
106
+ # "ja" table must not be read with a newer one.
107
+ @digest = Digest::SHA256.hexdigest(JSON.generate(@variants.sort)).slice(0, 16).freeze
108
+ end
109
+
110
+ # Raises unless data (an index) was built with the same tables as this
111
+ # normalizer. An index without a digest predates 1.0: it still loads, but
112
+ # it folded with older tables and should be rebuilt.
113
+ def check!(data)
114
+ if tables != data["variants"]
115
+ raise ArgumentError, "cjk_index: index built with variant tables #{data['variants'].inspect} " \
116
+ "but this normalizer folds with #{tables.inspect}; rebuild both together"
117
+ end
118
+ return if data["variant_digest"].nil? || data["variant_digest"] == digest
119
+
120
+ raise ArgumentError, "cjk_index: index built with other contents of the variant tables " \
121
+ "#{tables.inspect} (an older cjk_index?); rebuild it"
49
122
  end
50
123
 
124
+ # Character by character: katakana to hiragana, then the variant tables,
125
+ # then 々 to the (folded) character before it. Done as passes over the
126
+ # whole string, which gives the same result as one loop over the
127
+ # characters (the loop in runtime.js) and is several times faster in Ruby.
51
128
  def normalize(text)
52
129
  return "" if text.nil?
53
130
 
131
+ s = text.to_s.unicode_normalize(:nfkc).downcase
132
+ s = s.tr(KATAKANA, HIRAGANA)
133
+ # 々 repeats the folded character before it, which may be several
134
+ # characters (ko): fold one character at a time, as runtime.js does
135
+ return iterate(s) if s.include?(ITERATION_MARK)
136
+
137
+ @variant_chars ? s.gsub(@variant_chars, @variants) : s
138
+ end
139
+
140
+ private
141
+
142
+ def iterate(s)
54
143
  prev = nil
55
- text.to_s.unicode_normalize(:nfkc).downcase.each_char.map do |ch|
56
- code = ch.ord
57
- ch = (code - KANA_OFFSET).chr(Encoding::UTF_8) if code.between?(KATAKANA_FIRST, KATAKANA_LAST)
144
+ s.each_char.map do |ch|
58
145
  ch = @variants.fetch(ch, ch)
59
146
  ch = prev if ch == ITERATION_MARK && prev
60
147
  prev = ch
61
148
  end.join
62
149
  end
63
150
 
64
- private
65
-
66
151
  # Union-find over all pairs; each class folds to the target preferred by
67
152
  # the earliest table (lowest code point among equals, for determinism).
68
153
  def build_variants
@@ -74,7 +159,7 @@ module CJKIndex
74
159
  end
75
160
  rank = {}
76
161
  @tables.each_with_index do |name, priority|
77
- self.class.read_table(name).each do |from, to|
162
+ self.class.read_table(name, base: @base).each do |from, to|
78
163
  parent[find.call(from)] = find.call(to)
79
164
  rank[to] = [rank.fetch(to, [priority, to.ord]), [priority, to.ord]].min
80
165
  end