Metaphone.java

  1. /*
  2.  * Licensed to the Apache Software Foundation (ASF) under one or more
  3.  * contributor license agreements.  See the NOTICE file distributed with
  4.  * this work for additional information regarding copyright ownership.
  5.  * The ASF licenses this file to You under the Apache License, Version 2.0
  6.  * (the "License"); you may not use this file except in compliance with
  7.  * the License.  You may obtain a copy of the License at
  8.  *
  9.  *      https://www.apache.org/licenses/LICENSE-2.0
  10.  *
  11.  * Unless required by applicable law or agreed to in writing, software
  12.  * distributed under the License is distributed on an "AS IS" BASIS,
  13.  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14.  * See the License for the specific language governing permissions and
  15.  * limitations under the License.
  16.  */

  17. package org.apache.commons.codec.language;

  18. import org.apache.commons.codec.EncoderException;
  19. import org.apache.commons.codec.StringEncoder;

  20. /**
  21.  * Encodes a string into a Metaphone value.
  22.  * <p>
  23.  * Initial Java implementation by <CITE>William B. Brogden. December, 1997</CITE>.
  24.  * Permission given by <CITE>wbrogden</CITE> for code to be used anywhere.
  25.  * </p>
  26.  * <p>
  27.  * <CITE>Hanging on the Metaphone</CITE> by <CITE>Lawrence Philips</CITE> in <CITE>Computer Language of Dec. 1990,
  28.  * p 39.</CITE>
  29.  * </p>
  30.  * <p>
  31.  * Note, that this does not match the algorithm that ships with PHP, or the algorithm found in the Perl implementations:
  32.  * </p>
  33.  * <ul>
  34.  * <li><a href="https://search.cpan.org/~mschwern/Text-Metaphone-1.96/Metaphone.pm">Text:Metaphone-1.96</a>
  35.  *  (broken link 4/30/2013) </li>
  36.  * <li><a href="https://metacpan.org/source/MSCHWERN/Text-Metaphone-1.96//Metaphone.pm">Text:Metaphone-1.96</a>
  37.  *  (link checked 4/30/2013) </li>
  38.  * </ul>
  39.  * <p>
  40.  * They have had undocumented changes from the originally published algorithm.
  41.  * For more information, see <a href="https://issues.apache.org/jira/browse/CODEC-57">CODEC-57</a>.
  42.  * </p>
  43.  * <p>
  44.  * This class is conditionally thread-safe.
  45.  * The instance field for maximum code length is mutable {@link #setMaxCodeLen(int)}
  46.  * but is not volatile, and accesses are not synchronized.
  47.  * If an instance of the class is shared between threads, the caller needs to ensure that suitable synchronization
  48.  * is used to ensure safe publication of the value between threads, and must not invoke {@link #setMaxCodeLen(int)}
  49.  * after initial setup.
  50.  * </p>
  51.  */
  52. public class Metaphone implements StringEncoder {

  53.     /**
  54.      * Five values in the English language
  55.      */
  56.     private static final String VOWELS = "AEIOU";

  57.     /**
  58.      * Variable used in Metaphone algorithm
  59.      */
  60.     private static final String FRONTV = "EIY";

  61.     /**
  62.      * Variable used in Metaphone algorithm
  63.      */
  64.     private static final String VARSON = "CSPTG";

  65.     /**
  66.      * The max code length for metaphone is 4
  67.      */
  68.     private int maxCodeLen = 4;

  69.     /**
  70.      * Constructs a new instance.
  71.      */
  72.     public Metaphone() {
  73.         // empty
  74.     }

  75.     /**
  76.      * Encodes an Object using the metaphone algorithm.  This method
  77.      * is provided in order to satisfy the requirements of the
  78.      * Encoder interface, and will throw an EncoderException if the
  79.      * supplied object is not of type {@link String}.
  80.      *
  81.      * @param obj Object to encode
  82.      * @return An object (or type {@link String}) containing the
  83.      *         metaphone code which corresponds to the String supplied.
  84.      * @throws EncoderException if the parameter supplied is not
  85.      *                          of type {@link String}
  86.      */
  87.     @Override
  88.     public Object encode(final Object obj) throws EncoderException {
  89.         if (!(obj instanceof String)) {
  90.             throw new EncoderException("Parameter supplied to Metaphone encode is not of type java.lang.String");
  91.         }
  92.         return metaphone((String) obj);
  93.     }

  94.     /**
  95.      * Encodes a String using the Metaphone algorithm.
  96.      *
  97.      * @param str String object to encode
  98.      * @return The metaphone code corresponding to the String supplied
  99.      */
  100.     @Override
  101.     public String encode(final String str) {
  102.         return metaphone(str);
  103.     }

  104.     /**
  105.      * Gets the maxCodeLen.
  106.      *
  107.      * @return the maxCodeLen.
  108.      */
  109.     public int getMaxCodeLen() {
  110.         return this.maxCodeLen;
  111.     }

  112.     private boolean isLastChar(final int wdsz, final int n) {
  113.         return n + 1 == wdsz;
  114.     }

  115.     /**
  116.      * Tests is the metaphones of two strings are identical.
  117.      *
  118.      * @param str1 First of two strings to compare
  119.      * @param str2 Second of two strings to compare
  120.      * @return {@code true} if the metaphones of these strings are identical,
  121.      *        {@code false} otherwise.
  122.      */
  123.     public boolean isMetaphoneEqual(final String str1, final String str2) {
  124.         return metaphone(str1).equals(metaphone(str2));
  125.     }

  126.     private boolean isNextChar(final StringBuilder string, final int index, final char c) {
  127.         boolean matches = false;
  128.         if (index >= 0 && index < string.length() - 1) {
  129.             matches = string.charAt(index + 1) == c;
  130.         }
  131.         return matches;
  132.     }

  133.     private boolean isPreviousChar(final StringBuilder string, final int index, final char c) {
  134.         boolean matches = false;
  135.         if (index > 0 && index < string.length()) {
  136.             matches = string.charAt(index - 1) == c;
  137.         }
  138.         return matches;
  139.     }

  140.     private boolean isVowel(final StringBuilder string, final int index) {
  141.         return VOWELS.indexOf(string.charAt(index)) >= 0;
  142.     }

  143.     /**
  144.      * Find the metaphone value of a String. This is similar to the
  145.      * soundex algorithm, but better at finding similar sounding words.
  146.      * All input is converted to upper case.
  147.      * Limitations: Input format is expected to be a single ASCII word
  148.      * with only characters in the A - Z range, no punctuation or numbers.
  149.      *
  150.      * @param txt String to find the metaphone code for
  151.      * @return A metaphone code corresponding to the String supplied
  152.      */
  153.     public String metaphone(final String txt) {
  154.         boolean hard = false;
  155.         final int txtLength;
  156.         if (txt == null || (txtLength = txt.length()) == 0) {
  157.             return "";
  158.         }
  159.         // single character is itself
  160.         if (txtLength == 1) {
  161.             return txt.toUpperCase(java.util.Locale.ENGLISH);
  162.         }

  163.         final char[] inwd = txt.toUpperCase(java.util.Locale.ENGLISH).toCharArray();

  164.         final StringBuilder local = new StringBuilder(40); // manipulate
  165.         final StringBuilder code = new StringBuilder(10); // output
  166.         // handle initial 2 characters exceptions
  167.         switch (inwd[0]) {
  168.         case 'K':
  169.         case 'G':
  170.         case 'P': /* looking for KN, etc */
  171.             if (inwd[1] == 'N') {
  172.                 local.append(inwd, 1, inwd.length - 1);
  173.             } else {
  174.                 local.append(inwd);
  175.             }
  176.             break;
  177.         case 'A': /* looking for AE */
  178.             if (inwd[1] == 'E') {
  179.                 local.append(inwd, 1, inwd.length - 1);
  180.             } else {
  181.                 local.append(inwd);
  182.             }
  183.             break;
  184.         case 'W': /* looking for WR or WH */
  185.             if (inwd[1] == 'R') { // WR -> R
  186.                 local.append(inwd, 1, inwd.length - 1);
  187.                 break;
  188.             }
  189.             if (inwd[1] == 'H') {
  190.                 local.append(inwd, 1, inwd.length - 1);
  191.                 local.setCharAt(0, 'W'); // WH -> W
  192.             } else {
  193.                 local.append(inwd);
  194.             }
  195.             break;
  196.         case 'X': /* initial X becomes S */
  197.             inwd[0] = 'S';
  198.             local.append(inwd);
  199.             break;
  200.         default:
  201.             local.append(inwd);
  202.         } // now local has working string with initials fixed

  203.         final int wdsz = local.length();
  204.         int n = 0;

  205.         while (code.length() < getMaxCodeLen() && n < wdsz) { // max code size of 4 works well
  206.             final char symb = local.charAt(n);
  207.             // remove duplicate letters except C
  208.             if (symb != 'C' && isPreviousChar(local, n, symb)) {
  209.             } else { // not dup
  210.                 switch (symb) {
  211.                 case 'A':
  212.                 case 'E':
  213.                 case 'I':
  214.                 case 'O':
  215.                 case 'U':
  216.                     if (n == 0) {
  217.                         code.append(symb);
  218.                     }
  219.                     break; // only use vowel if leading char
  220.                 case 'B':
  221.                     if (isPreviousChar(local, n, 'M') && isLastChar(wdsz, n)) { // B is silent if word ends in MB
  222.                         break;
  223.                     }
  224.                     code.append(symb);
  225.                     break;
  226.                 case 'C': // lots of C special cases
  227.                     /* discard if SCI, SCE or SCY */
  228.                     if (isPreviousChar(local, n, 'S') && !isLastChar(wdsz, n) && FRONTV.indexOf(local.charAt(n + 1)) >= 0) {
  229.                         break;
  230.                     }
  231.                     if (regionMatch(local, n, "CIA")) { // "CIA" -> X
  232.                         code.append('X');
  233.                         break;
  234.                     }
  235.                     if (!isLastChar(wdsz, n) && FRONTV.indexOf(local.charAt(n + 1)) >= 0) {
  236.                         code.append('S');
  237.                         break; // CI,CE,CY -> S
  238.                     }
  239.                     if (isPreviousChar(local, n, 'S') && isNextChar(local, n, 'H')) { // SCH->sk
  240.                         code.append('K');
  241.                         break;
  242.                     }
  243.                     if (!isNextChar(local, n, 'H') || (n == 0 && wdsz >= 3 && isVowel(local, 2))) { // CH consonant -> K consonant
  244.                         code.append('K');
  245.                     } else {
  246.                         code.append('X'); // CHvowel -> X
  247.                     }
  248.                     break;
  249.                 case 'D':
  250.                     if (!isLastChar(wdsz, n + 1) && isNextChar(local, n, 'G') && FRONTV.indexOf(local.charAt(n + 2)) >= 0) { // DGE DGI DGY -> J
  251.                         code.append('J');
  252.                         n += 2;
  253.                     } else {
  254.                         code.append('T');
  255.                     }
  256.                     break;
  257.                 case 'G': // GH silent at end or before consonant
  258.                     if (isLastChar(wdsz, n + 1) && isNextChar(local, n, 'H')) {
  259.                         break;
  260.                     }
  261.                     if (!isLastChar(wdsz, n + 1) && isNextChar(local, n, 'H') && !isVowel(local, n + 2)) {
  262.                         break;
  263.                     }
  264.                     if (n > 0 && (regionMatch(local, n, "GN") || regionMatch(local, n, "GNED"))) {
  265.                         break; // silent G
  266.                     }
  267.                     // NOTE: Given that duplicated chars are removed, I don't see how this can ever be true
  268.                     hard = isPreviousChar(local, n, 'G');
  269.                     if (!isLastChar(wdsz, n) && FRONTV.indexOf(local.charAt(n + 1)) >= 0 && !hard) {
  270.                         code.append('J');
  271.                     } else {
  272.                         code.append('K');
  273.                     }
  274.                     break;
  275.                 case 'H':
  276.                     if (isLastChar(wdsz, n)) {
  277.                         break; // terminal H
  278.                     }
  279.                     if (n > 0 && VARSON.indexOf(local.charAt(n - 1)) >= 0) {
  280.                         break;
  281.                     }
  282.                     if (isVowel(local, n + 1)) {
  283.                         code.append('H'); // Hvowel
  284.                     }
  285.                     break;
  286.                 case 'F':
  287.                 case 'J':
  288.                 case 'L':
  289.                 case 'M':
  290.                 case 'N':
  291.                 case 'R':
  292.                     code.append(symb);
  293.                     break;
  294.                 case 'K':
  295.                     if (n > 0) { // not initial
  296.                         if (!isPreviousChar(local, n, 'C')) {
  297.                             code.append(symb);
  298.                         }
  299.                     } else {
  300.                         code.append(symb); // initial K
  301.                     }
  302.                     break;
  303.                 case 'P':
  304.                     if (isNextChar(local, n, 'H')) {
  305.                         // PH -> F
  306.                         code.append('F');
  307.                     } else {
  308.                         code.append(symb);
  309.                     }
  310.                     break;
  311.                 case 'Q':
  312.                     code.append('K');
  313.                     break;
  314.                 case 'S':
  315.                     if (regionMatch(local, n, "SH") || regionMatch(local, n, "SIO") || regionMatch(local, n, "SIA")) {
  316.                         code.append('X');
  317.                     } else {
  318.                         code.append('S');
  319.                     }
  320.                     break;
  321.                 case 'T':
  322.                     if (regionMatch(local, n, "TIA") || regionMatch(local, n, "TIO")) {
  323.                         code.append('X');
  324.                         break;
  325.                     }
  326.                     if (regionMatch(local, n, "TCH")) {
  327.                         // Silent if in "TCH"
  328.                         break;
  329.                     }
  330.                     // substitute numeral 0 for TH (resembles theta after all)
  331.                     if (regionMatch(local, n, "TH")) {
  332.                         code.append('0');
  333.                     } else {
  334.                         code.append('T');
  335.                     }
  336.                     break;
  337.                 case 'V':
  338.                     code.append('F');
  339.                     break;
  340.                 case 'W':
  341.                 case 'Y': // silent if not followed by vowel
  342.                     if (!isLastChar(wdsz, n) && isVowel(local, n + 1)) {
  343.                         code.append(symb);
  344.                     }
  345.                     break;
  346.                 case 'X':
  347.                     code.append('K');
  348.                     code.append('S');
  349.                     break;
  350.                 case 'Z':
  351.                     code.append('S');
  352.                     break;
  353.                 default:
  354.                     // do nothing
  355.                     break;
  356.                 } // end switch
  357.             } // end else from symb != 'C'
  358.             n++;
  359.             if (code.length() > getMaxCodeLen()) {
  360.                 code.setLength(getMaxCodeLen());
  361.             }
  362.         }
  363.         return code.toString();
  364.     }

  365.     private boolean regionMatch(final StringBuilder string, final int index, final String test) {
  366.         boolean matches = false;
  367.         if (index >= 0 && index + test.length() - 1 < string.length()) {
  368.             final String substring = string.substring(index, index + test.length());
  369.             matches = substring.equals(test);
  370.         }
  371.         return matches;
  372.     }

  373.     /**
  374.      * Sets the maxCodeLen.
  375.      *
  376.      * @param maxCodeLen The maxCodeLen to set.
  377.      */
  378.     public void setMaxCodeLen(final int maxCodeLen) {
  379.         this.maxCodeLen = maxCodeLen;
  380.     }

  381. }