0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

テキスト行のソート・重複削除をブラウザで実装する — Intl.Collatorの自然順ソートと、処理順序が結果を変える話

0
Posted at

3行まとめ

  • テキストを行単位でソート・重複削除するブラウザ完結ツールを作った。ソートは Intl.Collator に丸投げすると、日本語ロケール対応も「file1, file2, file10」の自然順ソートも標準APIだけで済む
  • 重複削除に [...new Set(lines)] を使わないのは、大文字小文字を無視して重複判定しつつ、残す行は元の表記のままにしたいから。キーと保持値を分ける
  • トリム→空行削除→重複削除→ソートの処理順序が結果を変える。順序を決め打ちにして、各段で何行消えたかを数える

メールアドレスのリストから重複を消す、ログの行を並べ替える、ファイル名リストを人間が期待する順に整列する——テキストを行単位で整理する作業は地味に頻出する。エディタのマクロやワンライナーでもできるが、毎回書くのは面倒だし、日本語混じりのソートや「数字を数値として扱う並び替え」はワンライナーだと意外と手こずる。

ぱんだツールズのテキスト行ソート・重複削除ツールは、貼り付けたテキストを昇順・降順・自然順に並べ替え、重複行・空行の削除や前後トリムをまとめてかけられるツール。テキストはブラウザ内で処理され、サーバーに送信されない。ソースコードや個人情報を含むテキストでも安心して使える。

この記事では、ソートを Intl.Collator に任せる利点、重複削除で Set を素朴に使わない理由、そして「複数の処理をどの順で適用するか」で結果が変わる話を、実装ベースで解説する。

ソートは Intl.Collator に丸投げする

行の並び替えは自前の比較関数を書かず、Intl.Collator に任せる。これ1つで日本語ロケール対応と自然順ソートの両方が手に入る。

if (options.sortDirection !== 'none') {
  const collator = new Intl.Collator('ja', {
    numeric: options.sortDirection === 'natural',
    sensitivity: options.caseInsensitive ? 'base' : 'variant',
  })

  lines.sort((a, b) => {
    const result = collator.compare(a, b)
    return options.sortDirection === 'desc' ? -result : result
  })
}

素朴に lines.sort() すると、比較は文字列のコードポイント順(UTF-16)になる。これだと日本語の並びが直感と合わなかったり、大文字と小文字が別グループに固まったりする。Intl.Collator('ja', …) はロケールを考慮した照合をするので、漢字・ひらがな・カタカナ混じりでも「らしい」順序になる。降順は比較結果の符号を反転するだけ。

自然順ソートnumeric: true を渡すだけで実現できる。これがないと file1, file10, file2 のように文字列比較で 102 より前に来てしまうが、numeric: true にすると文字列中の連続した数字を数値とみなして比較し、file1, file2, file10 になる。バージョン番号やファイル名のリストで効く。自前でやると「数字の並びを検出して数値化して…」と面倒なところを、Collator のオプション1つに寄せられる。

sensitivity で大文字小文字を吸収する — dedup キーとの二重の効き方

「大文字小文字を無視」オプションは、ソートの比較と重複判定の両方に効く。ソート側では Collator の sensitivity を切り替える。

  • sensitivity: 'variant'(既定):大文字小文字・アクセントをすべて区別する
  • sensitivity: 'base'aAeé を同一視する(基底文字だけで比較)

つまり「大文字小文字を無視」がONのときは 'base' にして、Appleapple が隣り合う(同順とみなされる)ようにする。この1オプションが後述の重複判定側でも別ロジックとして効いてくるので、UIのチェックボックス1個が実装では2箇所に波及している。

重複削除に [...new Set()] を使わない理由

重複行の削除は、一見 [...new Set(lines)] で済みそうに見える。だが実装はこうなっている。

if (options.removeDuplicates) {
  const seen = new Set<string>()
  const unique: string[] = []
  for (const line of lines) {
    const key = options.caseInsensitive ? line.toLowerCase() : line
    if (!seen.has(key)) {
      seen.add(key)
      unique.push(line)          // 保持するのは元の行(key ではない)
    } else {
      removedDuplicateCount++
    }
  }
  lines = unique
}

Set に入れる判定キーと、結果に残す保持値を分けているのがポイント。

  • 「大文字小文字を無視」がONのとき、重複判定は toLowerCase() したキーで行う(Appleapple を重複とみなす)
  • しかし配列に残すのは元の行そのままApple を小文字化した apple に潰さない)

[...new Set(lines)] だと判定と保持が同じ値になるので、「大文字を無視して重複判定しつつ、生き残る行は元の表記を保つ」ができない。だからキーと値を分けた手書きループにしている。おまけに、このループは最初に出現した行を残し、出現順を保つので、後段でソートしない場合(「ソートなし」)は元の並びを維持したまま2回目以降の重複だけが消える。ログやリストの整理でこの安定性は効く。

Sethas / add は平均 O(1) なので、数万行でも重複判定は高速。削除件数(removedDuplicateCount)はこのループのついでに数えて、処理後に「重複削除: N行」と表示する。

処理の順序が結果を変える

このツールはトリム・空行削除・重複削除・ソートを自由に組み合わせられる。ここで地味に重要なのが、どの順で適用するか。実装はこの順序に固定してある。

① トリム(各行の前後空白を除去)
② 空行削除
③ 重複削除
④ ソート

順序に意味があるのは、前段の結果が後段の入力になるから。

  • トリムを重複削除より先にやると、"apple""apple "(末尾に空白)が同じ行として重複判定される。逆順だと空白の有無で別物扱いになり、重複が残る
  • トリムを空行削除より先にやると、空白だけの行(" ")がトリムで空文字になり、空行削除の対象になる。逆順だと空白だけの行は「空行」と認識されず残る
  • 重複削除をソートより先にやることで、前述の「出現順を保った安定した重複削除」が成立する。ソートを先にすると元の並びの情報が失われる

ちなみに空行削除がOFFでも、重複削除がONなら複数の空行は1つに集約される(空文字 "" というキーが2回目以降は重複扱いになるため)。この手の「オプションどうしの相互作用」は、順序を決め打ちにして各段を独立した変換として積むと、挙動が読みやすくなる。

処理全体は入力文字列を split('\n') で行配列にして、各段を順に適用し、最後に join('\n') で戻すだけの素直なパイプライン。純粋関数 processText(input, options) にまとまっているので、UIと切り離してテストしやすい。行分割は \n 基準なので、CRLF のテキストは各行末に \r が残りうる。気になる場合はトリムをONにするか、事前に改行コードを揃えておくといい。

function processText(input: string, options: ProcessOptions): ProcessResult {
  let lines = input.split('\n')
  const originalLineCount = lines.length
  // ① trim → ② 空行 → ③ 重複 → ④ ソート の順に lines を変換
  // ...
  return { text: lines.join('\n'), originalLineCount, resultLineCount: lines.length, /* 各削除件数 */ }
}

まとめ

  • 行のソートは Intl.Collator('ja', …) に丸投げすると、日本語ロケール対応も自然順ソート(numeric: true)も標準APIだけで済む。降順は比較結果の符号反転
  • 「大文字小文字を無視」は Collator の sensitivity: 'base' と、重複判定キーの toLowerCase()2箇所に効く
  • 重複削除は [...new Set()] でなく「判定キーと保持値を分けた手書きループ」にすると、大小文字を無視して判定しつつ元の表記・出現順を保てる
  • トリム→空行→重複→ソートの処理順序が結果を左右する。順序を固定して各段を独立した変換として積むと、オプションの相互作用が読みやすい

リストの重複掃除やログの整列にどうぞ。テキストはブラウザの外に出ない。

ぱんだツールズ では他にも 文字数カウント・全角半角変換・テキスト差分・CSV変換など、テキストとファイルの実務処理に効くブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?