Current section
Files
Jump to
Current section
Files
lib/phonetic/cologne.ex
defmodule Text.Phonetic.Cologne do
@moduledoc """
Cologne phonetics (Kölner Phonetik), the German-language counterpart
to Soundex.
Designed by Hans Joachim Postel in 1969 specifically for German names
and German-language text. Encodes a word as a digit string in which
similarly-pronounced German names share a key. Particularly good with
German spelling variants — `Müller` / `Mueller` / `Muller`,
`Meyer` / `Maier` / `Mayer` / `Meier` all collapse to the same code.
## Encoding
Each letter maps to a digit `0`–`8`:
| Letter | Code |
|----------------|------|
| A, E, I, J, O, U, Y | 0 |
| H | (skipped) |
| B | 1 |
| P (not before H) | 1 |
| P before H | 3 |
| D, T (not before C, S, Z) | 2 |
| D, T before C, S, Z | 8 |
| F, V, W | 3 |
| G, K, Q | 4 |
| C — see context rules below | 4 or 8 |
| X — see context rules below | 48 or 8 |
| L | 5 |
| M, N | 6 |
| R | 7 |
| S, Z | 8 |
Context rules for C and X are slightly elaborate (initial vs medial
position; preceding and following letter); see the implementation
for the full table.
Post-processing:
1. Collapse runs of identical adjacent codes to a single code.
2. Drop every `0` except the one in the first position.
Result: a digit string of variable length.
## Examples
iex> Text.Phonetic.Cologne.encode("Müller")
"657"
iex> Text.Phonetic.Cologne.encode("Mueller")
"657"
iex> Text.Phonetic.Cologne.encode("Meyer")
"67"
iex> Text.Phonetic.Cologne.encode("Mayer")
"67"
iex> Text.Phonetic.Cologne.encode("Maier")
"67"
iex> Text.Phonetic.Cologne.encode("Schmidt")
"862"
iex> Text.Phonetic.Cologne.encode("Wikipedia")
"3412"
## Reference
Postel, H. J. (1969). *Die Kölner Phonetik: Ein Verfahren zur
Identifizierung von Personennamen auf der Grundlage der
Gestaltanalyse*. IBM-Nachrichten 19, 925–931.
"""
alias Text.Clean
@vowels ~c"AEIJOUY"
@csz ~c"CSZ"
@initial_c_to_4 ~c"AHKLOQRUX"
@medial_c_to_4 ~c"AHKOQUX"
@after_for_c4 ~c"SZ"
@ckq ~c"CKQ"
@doc """
Returns the Kölner-Phonetik code for `name`.
### Arguments
* `name` is a string. German umlauts (`Ä Ö Ü ß`) are normalised
before encoding, and other diacritics are folded via
`Text.Clean.unaccent/1`.
### Returns
* A digit string. The first character is always `0`–`8`. Returns
`""` for empty input or input containing no Latin letters.
### Examples
iex> Text.Phonetic.Cologne.encode("Schmitt")
"862"
iex> Text.Phonetic.Cologne.encode("Wikipedia")
"3412"
"""
@spec encode(String.t()) :: String.t()
def encode(name) when is_binary(name) do
case prepare(name) do
[] -> ""
letters -> letters |> walk() |> finalise()
end
end
@doc """
Returns `true` if `name_a` and `name_b` produce the same Kölner
code (and both produce a non-empty code).
### Arguments
* `name_a` is a string.
* `name_b` is a string.
### Returns
* `true` when both inputs produce a non-empty Kölner code and the
codes are equal.
* `false` otherwise.
### Examples
iex> Text.Phonetic.Cologne.match?("Müller", "Mueller")
true
iex> Text.Phonetic.Cologne.match?("Meyer", "Maier")
true
iex> Text.Phonetic.Cologne.match?("Schmidt", "Schneider")
false
"""
@spec match?(String.t(), String.t()) :: boolean()
def match?(name_a, name_b) when is_binary(name_a) and is_binary(name_b) do
code_a = encode(name_a)
code_a != "" and code_a == encode(name_b)
end
## ── normalization ───────────────────────────────────────────────
# German uppercase normalisation:
# * `ß` → `SS`
# * `Ä Ö Ü` → `A O U`
# then strip non-letters.
defp prepare(name) do
name
|> String.replace("ß", "ss")
|> String.replace("ẞ", "SS")
|> Clean.unaccent()
|> String.upcase()
|> :unicode.characters_to_list()
|> Enum.filter(fn c -> c >= ?A and c <= ?Z end)
end
## ── walk: produce the raw digit-or-skip stream ──────────────────
defp walk(letters), do: walk(letters, nil, true, [])
defp walk([], _prev, _initial?, acc), do: Enum.reverse(acc)
defp walk([c | rest], prev, initial?, acc) do
code = code_for(c, prev, List.first(rest), initial?)
case code do
:skip -> walk(rest, c, false, acc)
digits -> walk(rest, c, false, prepend_digits(digits, acc))
end
end
defp prepend_digits([], acc), do: acc
defp prepend_digits([d | rest], acc), do: prepend_digits(rest, [d | acc])
## ── per-letter code lookup ──────────────────────────────────────
# H is dropped entirely.
defp code_for(?H, _prev, _next, _initial?), do: :skip
# Vowels (incl. J, Y) → 0
defp code_for(c, _prev, _next, _initial?) when c in @vowels, do: ~c"0"
defp code_for(?B, _prev, _next, _initial?), do: ~c"1"
defp code_for(?P, _prev, ?H, _initial?), do: ~c"3"
defp code_for(?P, _prev, _next, _initial?), do: ~c"1"
defp code_for(c, _prev, next, _initial?) when c in [?D, ?T] and next in @csz, do: ~c"8"
defp code_for(c, _prev, _next, _initial?) when c in [?D, ?T], do: ~c"2"
defp code_for(c, _prev, _next, _initial?) when c in [?F, ?V, ?W], do: ~c"3"
defp code_for(c, _prev, _next, _initial?) when c in [?G, ?K, ?Q], do: ~c"4"
# C in initial position
defp code_for(?C, _prev, next, true) when next in @initial_c_to_4, do: ~c"4"
defp code_for(?C, _prev, _next, true), do: ~c"8"
# C in medial position
defp code_for(?C, prev, _next, false) when prev in @after_for_c4, do: ~c"8"
defp code_for(?C, prev, next, false) when next in @medial_c_to_4 and prev not in @ckq, do: ~c"4"
defp code_for(?C, _prev, _next, false), do: ~c"8"
# X — emits two digits when not after C, K or Q (encodes as if 'KS')
defp code_for(?X, prev, _next, _initial?) when prev in @ckq, do: ~c"8"
defp code_for(?X, _prev, _next, _initial?), do: ~c"48"
defp code_for(?L, _prev, _next, _initial?), do: ~c"5"
defp code_for(c, _prev, _next, _initial?) when c in [?M, ?N], do: ~c"6"
defp code_for(?R, _prev, _next, _initial?), do: ~c"7"
defp code_for(c, _prev, _next, _initial?) when c in [?S, ?Z], do: ~c"8"
defp code_for(_other, _prev, _next, _initial?), do: :skip
## ── post-processing ────────────────────────────────────────────
defp finalise([]), do: ""
defp finalise([first | _] = codes) do
codes
|> collapse_runs()
|> drop_zeros_except_leading(first)
|> List.to_string()
end
# Collapse runs of identical adjacent codes.
defp collapse_runs(codes), do: collapse_runs(codes, [])
defp collapse_runs([], acc), do: Enum.reverse(acc)
defp collapse_runs([c, c | rest], acc), do: collapse_runs([c | rest], acc)
defp collapse_runs([c | rest], acc), do: collapse_runs(rest, [c | acc])
# Drop every `?0` except the very first code (only if that first code
# is itself a `0`).
defp drop_zeros_except_leading([?0 | rest], ?0) do
[?0 | Enum.reject(rest, &(&1 == ?0))]
end
defp drop_zeros_except_leading(codes, _first) do
Enum.reject(codes, &(&1 == ?0))
end
end