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.  *      http://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.  * <CITE>Hanging on the Metaphone</CITE> by <CITE>Lawrence Philips</CITE> in <CITE>Computer Language of Dec. 1990,
  27.  * p 39.</CITE>
  28.  * <p>
  29.  * Note, that this does not match the algorithm that ships with PHP, or the algorithm found in the Perl implementations:
  30.  * </p>
  31.  * <ul>
  32.  * <li><a href="http://search.cpan.org/~mschwern/Text-Metaphone-1.96/Metaphone.pm">Text:Metaphone-1.96</a>
  33.  *  (broken link 4/30/2013) </li>
  34.  * <li><a href="https://metacpan.org/source/MSCHWERN/Text-Metaphone-1.96//Metaphone.pm">Text:Metaphone-1.96</a>
  35.  *  (link checked 4/30/2013) </li>
  36.  * </ul>
  37.  * <p>
  38.  * They have had undocumented changes from the originally published algorithm.
  39.  * For more information, see <a href="https://issues.apache.org/jira/browse/CODEC-57">CODEC-57</a>.
  40.  * <p>
  41.  * This class is conditionally thread-safe.
  42.  * The instance field {@link #maxCodeLen} is mutable {@link #setMaxCodeLen(int)}
  43.  * but is not volatile, and accesses are not synchronized.
  44.  * If an instance of the class is shared between threads, the caller needs to ensure that suitable synchronization
  45.  * is used to ensure safe publication of the value between threads, and must not invoke {@link #setMaxCodeLen(int)}
  46.  * after initial setup.
  47.  *
  48.  * @version $Id: Metaphone.java 1619948 2014-08-22 22:53:55Z ggregory $
  49.  */
  50. public class Metaphone implements StringEncoder {

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

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

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

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

  67.     /**
  68.      * Creates an instance of the Metaphone encoder
  69.      */
  70.     public Metaphone() {
  71.         super();
  72.     }

  73.     /**
  74.      * Find the metaphone value of a String. This is similar to the
  75.      * soundex algorithm, but better at finding similar sounding words.
  76.      * All input is converted to upper case.
  77.      * Limitations: Input format is expected to be a single ASCII word
  78.      * with only characters in the A - Z range, no punctuation or numbers.
  79.      *
  80.      * @param txt String to find the metaphone code for
  81.      * @return A metaphone code corresponding to the String supplied
  82.      */
  83.     public String metaphone(final String txt) {
  84.         boolean hard = false;
  85.         int txtLength;
  86.         if (txt == null || (txtLength = txt.length()) == 0) {
  87.             return "";
  88.         }
  89.         // single character is itself
  90.         if (txtLength == 1) {
  91.             return txt.toUpperCase(java.util.Locale.ENGLISH);
  92.         }

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

  94.         final StringBuilder local = new StringBuilder(40); // manipulate
  95.         final StringBuilder code = new StringBuilder(10); //   output
  96.         // handle initial 2 characters exceptions
  97.         switch(inwd[0]) {
  98.         case 'K':
  99.         case 'G':
  100.         case 'P': /* looking for KN, etc*/
  101.             if (inwd[1] == 'N') {
  102.                 local.append(inwd, 1, inwd.length - 1);
  103.             } else {
  104.                 local.append(inwd);
  105.             }
  106.             break;
  107.         case 'A': /* looking for AE */
  108.             if (inwd[1] == 'E') {
  109.                 local.append(inwd, 1, inwd.length - 1);
  110.             } else {
  111.                 local.append(inwd);
  112.             }
  113.             break;
  114.         case 'W': /* looking for WR or WH */
  115.             if (inwd[1] == 'R') {   // WR -> R
  116.                 local.append(inwd, 1, inwd.length - 1);
  117.                 break;
  118.             }
  119.             if (inwd[1] == 'H') {
  120.                 local.append(inwd, 1, inwd.length - 1);
  121.                 local.setCharAt(0, 'W'); // WH -> W
  122.             } else {
  123.                 local.append(inwd);
  124.             }
  125.             break;
  126.         case 'X': /* initial X becomes S */
  127.             inwd[0] = 'S';
  128.             local.append(inwd);
  129.             break;
  130.         default:
  131.             local.append(inwd);
  132.         } // now local has working string with initials fixed

  133.         final int wdsz = local.length();
  134.         int n = 0;

  135.         while (code.length() < this.getMaxCodeLen() &&
  136.                n < wdsz ) { // max code size of 4 works well
  137.             final char symb = local.charAt(n);
  138.             // remove duplicate letters except C
  139.             if (symb != 'C' && isPreviousChar( local, n, symb ) ) {
  140.                 n++;
  141.             } else { // not dup
  142.                 switch(symb) {
  143.                 case 'A':
  144.                 case 'E':
  145.                 case 'I':
  146.                 case 'O':
  147.                 case 'U':
  148.                     if (n == 0) {
  149.                         code.append(symb);
  150.                     }
  151.                     break; // only use vowel if leading char
  152.                 case 'B':
  153.                     if ( isPreviousChar(local, n, 'M') &&
  154.                          isLastChar(wdsz, n) ) { // B is silent if word ends in MB
  155.                         break;
  156.                     }
  157.                     code.append(symb);
  158.                     break;
  159.                 case 'C': // lots of C special cases
  160.                     /* discard if SCI, SCE or SCY */
  161.                     if ( isPreviousChar(local, n, 'S') &&
  162.                          !isLastChar(wdsz, n) &&
  163.                          FRONTV.indexOf(local.charAt(n + 1)) >= 0 ) {
  164.                         break;
  165.                     }
  166.                     if (regionMatch(local, n, "CIA")) { // "CIA" -> X
  167.                         code.append('X');
  168.                         break;
  169.                     }
  170.                     if (!isLastChar(wdsz, n) &&
  171.                         FRONTV.indexOf(local.charAt(n + 1)) >= 0) {
  172.                         code.append('S');
  173.                         break; // CI,CE,CY -> S
  174.                     }
  175.                     if (isPreviousChar(local, n, 'S') &&
  176.                         isNextChar(local, n, 'H') ) { // SCH->sk
  177.                         code.append('K');
  178.                         break;
  179.                     }
  180.                     if (isNextChar(local, n, 'H')) { // detect CH
  181.                         if (n == 0 &&
  182.                             wdsz >= 3 &&
  183.                             isVowel(local,2) ) { // CH consonant -> K consonant
  184.                             code.append('K');
  185.                         } else {
  186.                             code.append('X'); // CHvowel -> X
  187.                         }
  188.                     } else {
  189.                         code.append('K');
  190.                     }
  191.                     break;
  192.                 case 'D':
  193.                     if (!isLastChar(wdsz, n + 1) &&
  194.                         isNextChar(local, n, 'G') &&
  195.                         FRONTV.indexOf(local.charAt(n + 2)) >= 0) { // DGE DGI DGY -> J
  196.                         code.append('J'); n += 2;
  197.                     } else {
  198.                         code.append('T');
  199.                     }
  200.                     break;
  201.                 case 'G': // GH silent at end or before consonant
  202.                     if (isLastChar(wdsz, n + 1) &&
  203.                         isNextChar(local, n, 'H')) {
  204.                         break;
  205.                     }
  206.                     if (!isLastChar(wdsz, n + 1) &&
  207.                         isNextChar(local,n,'H') &&
  208.                         !isVowel(local,n+2)) {
  209.                         break;
  210.                     }
  211.                     if (n > 0 &&
  212.                         ( regionMatch(local, n, "GN") ||
  213.                           regionMatch(local, n, "GNED") ) ) {
  214.                         break; // silent G
  215.                     }
  216.                     if (isPreviousChar(local, n, 'G')) {
  217.                         // NOTE: Given that duplicated chars are removed, I don't see how this can ever be true
  218.                         hard = true;
  219.                     } else {
  220.                         hard = false;
  221.                     }
  222.                     if (!isLastChar(wdsz, n) &&
  223.                         FRONTV.indexOf(local.charAt(n + 1)) >= 0 &&
  224.                         !hard) {
  225.                         code.append('J');
  226.                     } else {
  227.                         code.append('K');
  228.                     }
  229.                     break;
  230.                 case 'H':
  231.                     if (isLastChar(wdsz, n)) {
  232.                         break; // terminal H
  233.                     }
  234.                     if (n > 0 &&
  235.                         VARSON.indexOf(local.charAt(n - 1)) >= 0) {
  236.                         break;
  237.                     }
  238.                     if (isVowel(local,n+1)) {
  239.                         code.append('H'); // Hvowel
  240.                     }
  241.                     break;
  242.                 case 'F':
  243.                 case 'J':
  244.                 case 'L':
  245.                 case 'M':
  246.                 case 'N':
  247.                 case 'R':
  248.                     code.append(symb);
  249.                     break;
  250.                 case 'K':
  251.                     if (n > 0) { // not initial
  252.                         if (!isPreviousChar(local, n, 'C')) {
  253.                             code.append(symb);
  254.                         }
  255.                     } else {
  256.                         code.append(symb); // initial K
  257.                     }
  258.                     break;
  259.                 case 'P':
  260.                     if (isNextChar(local,n,'H')) {
  261.                         // PH -> F
  262.                         code.append('F');
  263.                     } else {
  264.                         code.append(symb);
  265.                     }
  266.                     break;
  267.                 case 'Q':
  268.                     code.append('K');
  269.                     break;
  270.                 case 'S':
  271.                     if (regionMatch(local,n,"SH") ||
  272.                         regionMatch(local,n,"SIO") ||
  273.                         regionMatch(local,n,"SIA")) {
  274.                         code.append('X');
  275.                     } else {
  276.                         code.append('S');
  277.                     }
  278.                     break;
  279.                 case 'T':
  280.                     if (regionMatch(local,n,"TIA") ||
  281.                         regionMatch(local,n,"TIO")) {
  282.                         code.append('X');
  283.                         break;
  284.                     }
  285.                     if (regionMatch(local,n,"TCH")) {
  286.                         // Silent if in "TCH"
  287.                         break;
  288.                     }
  289.                     // substitute numeral 0 for TH (resembles theta after all)
  290.                     if (regionMatch(local,n,"TH")) {
  291.                         code.append('0');
  292.                     } else {
  293.                         code.append('T');
  294.                     }
  295.                     break;
  296.                 case 'V':
  297.                     code.append('F'); break;
  298.                 case 'W':
  299.                 case 'Y': // silent if not followed by vowel
  300.                     if (!isLastChar(wdsz,n) &&
  301.                         isVowel(local,n+1)) {
  302.                         code.append(symb);
  303.                     }
  304.                     break;
  305.                 case 'X':
  306.                     code.append('K');
  307.                     code.append('S');
  308.                     break;
  309.                 case 'Z':
  310.                     code.append('S');
  311.                     break;
  312.                 default:
  313.                     // do nothing
  314.                     break;
  315.                 } // end switch
  316.                 n++;
  317.             } // end else from symb != 'C'
  318.             if (code.length() > this.getMaxCodeLen()) {
  319.                 code.setLength(this.getMaxCodeLen());
  320.             }
  321.         }
  322.         return code.toString();
  323.     }

  324.     private boolean isVowel(final StringBuilder string, final int index) {
  325.         return VOWELS.indexOf(string.charAt(index)) >= 0;
  326.     }

  327.     private boolean isPreviousChar(final StringBuilder string, final int index, final char c) {
  328.         boolean matches = false;
  329.         if( index > 0 &&
  330.             index < string.length() ) {
  331.             matches = string.charAt(index - 1) == c;
  332.         }
  333.         return matches;
  334.     }

  335.     private boolean isNextChar(final StringBuilder string, final int index, final char c) {
  336.         boolean matches = false;
  337.         if( index >= 0 &&
  338.             index < string.length() - 1 ) {
  339.             matches = string.charAt(index + 1) == c;
  340.         }
  341.         return matches;
  342.     }

  343.     private boolean regionMatch(final StringBuilder string, final int index, final String test) {
  344.         boolean matches = false;
  345.         if( index >= 0 &&
  346.             index + test.length() - 1 < string.length() ) {
  347.             final String substring = string.substring( index, index + test.length());
  348.             matches = substring.equals( test );
  349.         }
  350.         return matches;
  351.     }

  352.     private boolean isLastChar(final int wdsz, final int n) {
  353.         return n + 1 == wdsz;
  354.     }


  355.     /**
  356.      * Encodes an Object using the metaphone algorithm.  This method
  357.      * is provided in order to satisfy the requirements of the
  358.      * Encoder interface, and will throw an EncoderException if the
  359.      * supplied object is not of type java.lang.String.
  360.      *
  361.      * @param obj Object to encode
  362.      * @return An object (or type java.lang.String) containing the
  363.      *         metaphone code which corresponds to the String supplied.
  364.      * @throws EncoderException if the parameter supplied is not
  365.      *                          of type java.lang.String
  366.      */
  367.     @Override
  368.     public Object encode(final Object obj) throws EncoderException {
  369.         if (!(obj instanceof String)) {
  370.             throw new EncoderException("Parameter supplied to Metaphone encode is not of type java.lang.String");
  371.         }
  372.         return metaphone((String) obj);
  373.     }

  374.     /**
  375.      * Encodes a String using the Metaphone algorithm.
  376.      *
  377.      * @param str String object to encode
  378.      * @return The metaphone code corresponding to the String supplied
  379.      */
  380.     @Override
  381.     public String encode(final String str) {
  382.         return metaphone(str);
  383.     }

  384.     /**
  385.      * Tests is the metaphones of two strings are identical.
  386.      *
  387.      * @param str1 First of two strings to compare
  388.      * @param str2 Second of two strings to compare
  389.      * @return <code>true</code> if the metaphones of these strings are identical,
  390.      *        <code>false</code> otherwise.
  391.      */
  392.     public boolean isMetaphoneEqual(final String str1, final String str2) {
  393.         return metaphone(str1).equals(metaphone(str2));
  394.     }

  395.     /**
  396.      * Returns the maxCodeLen.
  397.      * @return int
  398.      */
  399.     public int getMaxCodeLen() { return this.maxCodeLen; }

  400.     /**
  401.      * Sets the maxCodeLen.
  402.      * @param maxCodeLen The maxCodeLen to set
  403.      */
  404.     public void setMaxCodeLen(final int maxCodeLen) { this.maxCodeLen = maxCodeLen; }

  405. }