cjk_index 1.0.0 → 1.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c9ffe634562d740738f6d9b9cf97ade5f5ab6d32a80b6bfd349496435d9e88b
4
- data.tar.gz: 6cc0aebdedd14b07cdcf9dda3dcc9b849b80d135149444f057daed4cc620c004
3
+ metadata.gz: 615a0f6d44d6e04e21f66aeb15778e39adf566ec078f11c69ceecf41473e1388
4
+ data.tar.gz: 7a106a769e2cc57e382727c8ada1afbba691fbdb7620c0f487eddbe31610abf8
5
5
  SHA512:
6
- metadata.gz: ee41c34d99efe6e17f8936f3211b62aaab6fdafa54b714f685458c641a56263a46b506de9f703a4cd1c6f470c32e0d336b1914547655a433641ecbf9cdc5fcc0
7
- data.tar.gz: 608805cba3a84e8c1bb8425e09c8ff3bdcb2bd4da6443f6ca63eeb6d2e3952e75b0166db871e853383d3531cb40d7f48052cc70540f2ae7fd8bd5ae372424af7
6
+ metadata.gz: 2783f62f61930fbd740261d552d7b8092b098b1e15a4fe28741b37f766b9670f5e66045c994da3bcff2b16644f28772b681b8f6fd70b28440f8dd58571e0759e
7
+ data.tar.gz: c1faf175e55309bf62568d7215bee743fe7a4f0942d9621e1ad8f846e9082393dd51c38ef2ef141bdada33ce887a253d4ae533e812abe6e07cf59423901e3638
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.1 (2026-10-28)
4
+
5
+ - Variation selectors (U+FE00–U+FE0F and the ideographic U+E0100–U+E01EF)
6
+ are dropped when folding: 「珊」 followed by an ideographic variation
7
+ selector is 「珊」. Before, the selector broke a CJK run in two, so
8
+ 「珊瑚」 did not find 「珊\u{E0100}瑚」, and a table file of your own with
9
+ such a cell was rejected as "not one character". `highlight` and
10
+ `excerpt` keep the selector inside the mark with its character. Rebuild
11
+ indexes whose text has variation selectors.
12
+
13
+ ## 1.1.0 (2026-10-28)
14
+
15
+ - `variants` also takes table files of your own: one group per line,
16
+ tab-separated, the form to fold to first (the format of the itaiji lists
17
+ that collection databases keep). Paths are relative to the site's folder
18
+ (Jekyll, Bridgetown) or the current folder; the index records the path as
19
+ written and a digest of the contents. `Builder.load` and `Builder.read`
20
+ take `normalizer:` to read such an index from another folder.
21
+ - `Searcher.new(builder)` folds with the builder's normalizer.
22
+
3
23
  ## 1.0.0 (2026-10-28)
4
24
 
5
25
  Indexes built with 0.x should be rebuilt: the `ja` table changed, and the
data/README.md CHANGED
@@ -24,8 +24,8 @@ even when it is an exact title. cjk_index instead:
24
24
  stores their positions, so a query matches only where its bigrams are
25
25
  adjacent and in order: an exact substring match, with no dictionary or
26
26
  native extension (the same idea as [kensaku](https://github.com/whispcat/kensaku));
27
- - folds variants on both sides: NFKC, lowercase, katakana → hiragana,
28
- old-form kanji → modern form (「圖」→「図」), 々 expanded (「佐々木」→「佐佐木」),
27
+ - folds variants on both sides: NFKC, lowercase, variation selectors
28
+ dropped, katakana → hiragana, old-form kanji → modern form (「圖」→「図」), 々 expanded (「佐々木」→「佐佐木」),
29
29
  and optionally Traditional ↔ Simplified Chinese (from OpenCC) and old
30
30
  Hangul in the Private Use Area;
31
31
  - ranks with BM25 per field, with field boosts;
@@ -366,6 +366,37 @@ by Unicode normalization. `ko` is for old Hangul stored in the Private Use
366
366
  Area by HWP and the Hanyang fonts; a query typed in standard jamo then finds
367
367
  and highlights it.
368
368
 
369
+ ### A table of your own
370
+
371
+ Any entry of `variants` that is not one of the names above is a table file
372
+ of your own, relative to the site's folder (Jekyll, Bridgetown) or the
373
+ current folder (CLI, Rake, Ruby). One group per line, tab-separated: the
374
+ form to fold to, then its variants, one character each. This is the format
375
+ of the itaiji lists that collection databases often keep already.
376
+
377
+ ```text
378
+ # itaiji.tsv
379
+ 与 與
380
+ 宴 醼 讌
381
+ 嶋 嶌
382
+ ```
383
+
384
+ ```yaml
385
+ cjk_index:
386
+ variants: [ja, _data/itaiji.tsv]
387
+ ```
388
+
389
+ Each cell is folded first (NFKC, lowercase, variation selectors dropped,
390
+ katakana → hiragana), so a cell written as an ideographic variation sequence
391
+ (珊 + U+E0100) is the character itself, and the table listed first still
392
+ picks the form a group folds to. The index records
393
+ the entry as written (`_data/itaiji.tsv`), not the full path, and its digest
394
+ covers the file's contents, so editing the file means rebuilding the index.
395
+ To read such an index from another folder in Ruby, pass the normalizer:
396
+ `Builder.load(json, normalizer: CJKIndex::Normalizer.new(tables: [...], base: dir))`.
397
+ Wide lists merge more than spelling: a list that puts 燕 with 宴 makes
398
+ a search for 燕 (swallow) find 宴 (banquet). Check a list against your own material first.
399
+
369
400
  Browser script size, gzipped: no table 6.3 KB, `ja` 9.0 KB, `ja, zh-safe`
370
401
  24.1 KB, `ja, zh` 25.9 KB, `ja, ko` 36.1 KB.
371
402
 
@@ -11,7 +11,7 @@ module CJKIndex
11
11
  # Bridgetown.configure do |config|
12
12
  # init :cjk_index,
13
13
  # output: "cjk-index", # folder inside the output (default)
14
- # variants: ["ja"], # variant tables: ja (default), zh
14
+ # variants: ["ja"], # variant tables: ja (default), zh-safe, zh, ko, or a file
15
15
  # indexes: [
16
16
  # { name: "posts", # -> output/cjk-index/posts.json
17
17
  # collection: "posts", # a collection (or a list of them), or...
@@ -37,7 +37,7 @@ module CJKIndex
37
37
  def write(site, config)
38
38
  config = stringify(config)
39
39
  output = config.fetch("output", DEFAULT_OUTPUT).to_s.gsub(%r{\A/+|/+\z}, "")
40
- normalizer = Normalizer.new(tables: Array(config.fetch("variants", Normalizer::DEFAULT_TABLES)))
40
+ normalizer = Normalizer.new(tables: Array(config.fetch("variants", Normalizer::DEFAULT_TABLES)), base: site.root_dir)
41
41
  put(site, output, RUNTIME_NAME, Runtime.source(normalizer: normalizer))
42
42
  Array(config["indexes"]).each do |spec|
43
43
  builder = build(site, stringify(spec), normalizer)
@@ -129,7 +129,9 @@ module CJKIndex
129
129
  # Reads an index written by to_json (String) or to_h back, to update it.
130
130
  # Reads formats 2, 3 and 4; a split index is read with read.
131
131
  # A value filter keeps the form of each value that the index stored.
132
- def self.load(index)
132
+ # normalizer: needed when the index uses a table file of your own and
133
+ # the current folder is not the one it was built from.
134
+ def self.load(index, normalizer: nil)
133
135
  data = index.is_a?(String) ? JSON.parse(index) : index
134
136
  unless data.is_a?(Hash) && FORMATS.include?(data["format"])
135
137
  raise ArgumentError, "cjk_index: unsupported index format #{data.is_a?(Hash) ? data['format'].inspect : data.class}"
@@ -137,7 +139,7 @@ module CJKIndex
137
139
  raise ArgumentError, "cjk_index: a split index is read with Builder.read(directory)" unless data["tokens"]
138
140
 
139
141
  filters = (data["filters"] || {}).transform_values { |f| f.slice("kind", "separator") }
140
- normalizer = Normalizer.new(tables: data["variants"])
142
+ normalizer ||= Normalizer.new(tables: data["variants"])
141
143
  normalizer.check!(data)
142
144
  builder = new(fields: data["fields"], boosts: data["fields"].zip(data["boosts"]).to_h,
143
145
  normalizer: normalizer, filters: filters)
@@ -147,8 +149,8 @@ module CJKIndex
147
149
 
148
150
  # Reads an index from a file (written by write or to_json) or from the
149
151
  # directory of a split index (written by write_split), to update it.
150
- def self.read(path)
151
- load(Builder.read_data(path))
152
+ def self.read(path, normalizer: nil)
153
+ load(Builder.read_data(path), normalizer: normalizer)
152
154
  end
153
155
 
154
156
  # The index at path as one Hash with "tokens", for a file or a split
data/lib/cjk_index/cli.rb CHANGED
@@ -73,7 +73,7 @@ module CJKIndex
73
73
  name, spec = parse_filter(v)
74
74
  opts[:filters][name] = spec
75
75
  end
76
- o.on("--variants LIST", Array, "variant tables: ja (default), zh-safe, zh, ko") { |v| opts[:variants] = v }
76
+ o.on("--variants LIST", Array, "variant tables: ja (default), zh-safe, zh, ko, or table files") { |v| opts[:variants] = v }
77
77
  o.on("-o", "--output PATH", "index file (default: standard output)") { |v| output = v }
78
78
  o.on("--runtime PATH", "also write the browser script for these variants") { |v| runtime = v }
79
79
  end
@@ -196,7 +196,7 @@ module CJKIndex
196
196
  output = nil
197
197
  OptionParser.new do |o|
198
198
  o.banner = "Usage: cjk_index runtime [options]"
199
- o.on("--variants LIST", Array, "variant tables: ja (default), zh-safe, zh, ko; must match the index") { |v| variants = v }
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
200
  o.on("-o", "--output PATH", "script file (default: standard output)") { |v| output = v }
201
201
  end.parse!(argv)
202
202
  raise Error, "too many arguments: #{argv.join(' ')}" unless argv.empty?
@@ -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...
@@ -64,7 +64,7 @@ module CJKIndex
64
64
  return unless config.is_a?(Hash)
65
65
 
66
66
  output = config.fetch("output", DEFAULT_OUTPUT).sub(%r{\A/+}, "").sub(%r{/+\z}, "")
67
- 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)
68
68
  site.static_files << GeneratedFile.new(site, output, RUNTIME_NAME, Runtime.source(normalizer: normalizer))
69
69
  Array(config["indexes"]).each do |spec|
70
70
  builder = build(site, spec, normalizer)
@@ -5,9 +5,10 @@ require "json"
5
5
 
6
6
  module CJKIndex
7
7
  # Folds text so that spelling variants a reader treats as "the same" compare
8
- # equal: NFKC (full-width -> half-width etc.), lowercase, katakana ->
9
- # hiragana, character variants from the selected tables, and the iteration
10
- # mark 々 expanded to the preceding character.
8
+ # equal: NFKC (full-width -> half-width etc.), lowercase, variation
9
+ # selectors dropped (珊 followed by an ideographic variation selector is 珊),
10
+ # katakana -> hiragana, character variants from the selected tables, and
11
+ # the iteration mark 々 expanded to the preceding character.
11
12
  #
12
13
  # The same rules run in the browser (see Runtime), so an index built here
13
14
  # and a query typed there are folded identically.
@@ -24,10 +25,19 @@ module CJKIndex
24
25
  # With several tables, characters linked by any table fold to one form,
25
26
  # preferring the target of the table listed first: with [ja, zh],
26
27
  # 圖, 図 and 图 all become 図.
28
+ # Any other entry names a table file of your own, relative to base (the
29
+ # site's folder for Jekyll and Bridgetown, the current folder otherwise).
30
+ # The index records the entry as written, not the resolved path.
27
31
  #
28
32
  # Table format: UTF-8 lines "variant<TAB>folded", optionally followed by
29
33
  # another tab and a comment column; lines starting with "#" are comments.
30
34
  # "folded" may be several characters.
35
+ #
36
+ # Your own table is one group per line, tab-separated: the form to fold to,
37
+ # then its variants, one character each (the format of the itaiji lists
38
+ # that collection databases keep). Each cell is folded first (NFKC,
39
+ # lowercase, katakana -> hiragana), so a compatibility ideograph such as
40
+ # U+F900 joins the group of the character NFKC makes of it.
31
41
  class Normalizer
32
42
  DATA_DIR = File.expand_path("../../data", __dir__)
33
43
  TABLES = {
@@ -43,16 +53,48 @@ module CJKIndex
43
53
  KATAKANA_LAST = 0x30F6
44
54
  KANA_OFFSET = 0x60
45
55
  ITERATION_MARK = "々"
56
+ # Variation selectors (VS1-VS16 and the ideographic VS17-VS256) only pick
57
+ # a glyph of the character before them: 珊 with or without U+E0100 is the
58
+ # same character to a reader, so they are dropped, not kept as a run of
59
+ # their own between two CJK characters. In String#delete form.
60
+ VARIATION_SELECTORS = "\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}"
46
61
 
47
62
  def self.default
48
63
  @default ||= new
49
64
  end
50
65
 
51
- def self.read_table(name)
52
- file = TABLES.fetch(name) { raise ArgumentError, "unknown variant table #{name.inspect} (#{TABLES.keys.join(', ')})" }
53
- File.readlines(File.join(DATA_DIR, file), chomp: true, encoding: "UTF-8")
54
- .reject { |line| line.empty? || line.start_with?("#") }
55
- .map { |line| line.split("\t")[0, 2] }
66
+ # Pairs [variant, folded] of a built-in table, or of a table file of your
67
+ # own (a name that is not built in, resolved against base).
68
+ def self.read_table(name, base: Dir.pwd)
69
+ return read_groups(name, File.expand_path(name, base)) unless TABLES.key?(name)
70
+
71
+ lines(File.join(DATA_DIR, TABLES[name])).map { |_, line| line.split("\t")[0, 2] }
72
+ end
73
+
74
+ def self.lines(path) # :nodoc:
75
+ File.readlines(path, chomp: true, encoding: "UTF-8").each_with_index
76
+ .reject { |line, _| line.strip.empty? || line.start_with?("#") }
77
+ .map { |line, i| [i + 1, line.delete_prefix("\uFEFF")] }
78
+ end
79
+
80
+ def self.read_groups(name, path) # :nodoc:
81
+ unless File.file?(path)
82
+ raise ArgumentError, "unknown variant table #{name.inspect}: not one of #{TABLES.keys.join(', ')}, " \
83
+ "and no file at #{path}"
84
+ end
85
+
86
+ plain = new(tables: [])
87
+ lines(path).flat_map do |number, line|
88
+ target, *variants = line.split("\t").map(&:strip).reject(&:empty?).map { |cell| plain.normalize(cell) }
89
+ variants = variants.uniq.reject { |v| v == target } # NFKC may already fold a cell (㍿)
90
+ variants.each do |v|
91
+ next if v.length == 1
92
+
93
+ raise ArgumentError, "#{name}:#{number}: #{v.inspect} is not one character; " \
94
+ "only the first cell (the form to fold to) may be longer"
95
+ end
96
+ variants.map { |v| [v, target] }
97
+ end
56
98
  end
57
99
 
58
100
  attr_reader :tables, :variants, :digest
@@ -60,8 +102,9 @@ module CJKIndex
60
102
  KATAKANA = "#{KATAKANA_FIRST.chr(Encoding::UTF_8)}-#{KATAKANA_LAST.chr(Encoding::UTF_8)}"
61
103
  HIRAGANA = "#{(KATAKANA_FIRST - KANA_OFFSET).chr(Encoding::UTF_8)}-#{(KATAKANA_LAST - KANA_OFFSET).chr(Encoding::UTF_8)}"
62
104
 
63
- def initialize(tables: DEFAULT_TABLES)
105
+ def initialize(tables: DEFAULT_TABLES, base: Dir.pwd)
64
106
  @tables = tables.map(&:to_s).uniq.freeze
107
+ @base = base
65
108
  @variants = build_variants.freeze
66
109
  keys = @variants.keys.select { |k| k.length == 1 }
67
110
  @variant_chars = keys.empty? ? nil : Regexp.new("[#{keys.map { |k| format('\\u{%x}', k.ord) }.join}]")
@@ -84,14 +127,16 @@ module CJKIndex
84
127
  "#{tables.inspect} (an older cjk_index?); rebuild it"
85
128
  end
86
129
 
87
- # Character by character: katakana to hiragana, then the variant tables,
88
- # then 々 to the (folded) character before it. Done as passes over the
130
+ # Character by character: variation selectors dropped, katakana to
131
+ # hiragana, then the variant tables, then 々 to the (folded) character
132
+ # before it. Done as passes over the
89
133
  # whole string, which gives the same result as one loop over the
90
134
  # characters (the loop in runtime.js) and is several times faster in Ruby.
91
135
  def normalize(text)
92
136
  return "" if text.nil?
93
137
 
94
138
  s = text.to_s.unicode_normalize(:nfkc).downcase
139
+ s = s.delete(VARIATION_SELECTORS)
95
140
  s = s.tr(KATAKANA, HIRAGANA)
96
141
  # 々 repeats the folded character before it, which may be several
97
142
  # characters (ko): fold one character at a time, as runtime.js does
@@ -122,7 +167,7 @@ module CJKIndex
122
167
  end
123
168
  rank = {}
124
169
  @tables.each_with_index do |name, priority|
125
- self.class.read_table(name).each do |from, to|
170
+ self.class.read_table(name, base: @base).each do |from, to|
126
171
  parent[find.call(from)] = find.call(to)
127
172
  rank[to] = [rank.fetch(to, [priority, to.ord]), [priority, to.ord]].min
128
173
  end
@@ -59,6 +59,10 @@
59
59
  // Score multiplier for matches found by prefix or typo expansion.
60
60
  var EXPANDED = 0.6;
61
61
 
62
+ // Variation selectors (VS1-VS16, VS17-VS256): dropped, as
63
+ // CJKIndex::Normalizer::VARIATION_SELECTORS in Ruby.
64
+ var SELECTOR = /^[\ufe00-\ufe0f\u{e0100}-\u{e01ef}]$/u;
65
+
62
66
  // Folds one character; prev is the previous folded character (for 々).
63
67
  function foldChar(ch, prev) {
64
68
  var c = ch.codePointAt(0);
@@ -73,6 +77,7 @@
73
77
  var out = '';
74
78
  var prev = null;
75
79
  for (var ch of String(text).normalize('NFKC').toLowerCase()) {
80
+ if (SELECTOR.test(ch)) continue;
76
81
  prev = foldChar(ch, prev);
77
82
  out += prev;
78
83
  }
@@ -534,11 +539,16 @@
534
539
  // Each folded code point remembers which original characters it came
535
540
  // from. A character may fold to several (ko: one Private Use Area
536
541
  // character -> a jamo sequence), and a cluster may compose into fewer.
542
+ // A character that folds to nothing (a variation selector) belongs to
543
+ // the one before it: a match on 珊 marks its selector too.
537
544
  function emit(str, from, to) {
545
+ var before = norm.length;
538
546
  for (var c of str.normalize('NFKC').toLowerCase()) {
547
+ if (SELECTOR.test(c)) continue;
539
548
  prev = foldChar(c, prev);
540
549
  for (var p of prev) { norm.push(p); start.push(from); end.push(to); }
541
550
  }
551
+ if (norm.length === before && end.length) end[end.length - 1] = to;
542
552
  }
543
553
  for (var i = 0, j; i < chars.length; i = j) {
544
554
  for (j = i + 1; j < chars.length && JOINS_PREVIOUS.test(chars[j]); j++);
@@ -57,7 +57,7 @@ module CJKIndex
57
57
  end
58
58
  end
59
59
 
60
- @normalizer = normalizer || Normalizer.new(tables: data["variants"])
60
+ @normalizer = normalizer || @builder&.normalizer || Normalizer.new(tables: data["variants"])
61
61
  @normalizer.check!(data)
62
62
 
63
63
  if data["shards"] && !@load_shard
@@ -353,19 +353,23 @@ module CJKIndex
353
353
  chars = text.to_s.chars
354
354
  norm = +""
355
355
  origin = []
356
+ ends = []
356
357
  chars.each_with_index do |ch, i|
357
358
  folded = normalizer.normalize(ch)
358
359
  # 々 depends on the previous character, so fold it in context.
359
360
  folded = norm[-1] if folded == Normalizer::ITERATION_MARK && !norm.empty?
361
+ # A character that folds to nothing (a variation selector) belongs to
362
+ # the one before it: a match on 珊 marks its selector too.
363
+ ends[-1] = i + 1 if folded.empty? && !ends.empty?
360
364
  norm << folded
361
- folded.length.times { origin << i }
365
+ folded.length.times { origin << i; ends << i + 1 }
362
366
  end
363
367
  needles = normalizer.normalize(query).split(Tokenizer::SEPARATOR).reject(&:empty?)
364
368
  ranges = needles.flat_map do |needle|
365
369
  found = []
366
370
  from = 0
367
371
  while (at = norm.index(needle, from))
368
- found << [origin[at], origin[at + needle.length - 1] + 1]
372
+ found << [origin[at], ends[at + needle.length - 1]]
369
373
  from = at + needle.length
370
374
  end
371
375
  found
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CJKIndex
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cjk_index
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Satoru Nakamura