001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017
018package org.apache.commons.codec.language;
019
020import org.apache.commons.codec.EncoderException;
021import org.apache.commons.codec.StringEncoder;
022
023/**
024 * Encodes a string into a Metaphone value.
025 * <p>
026 * Initial Java implementation by <CITE>William B. Brogden. December, 1997</CITE>.
027 * Permission given by <CITE>wbrogden</CITE> for code to be used anywhere.
028 * <p>
029 * <CITE>Hanging on the Metaphone</CITE> by <CITE>Lawrence Philips</CITE> in <CITE>Computer Language of Dec. 1990,
030 * p 39.</CITE>
031 * <p>
032 * Note, that this does not match the algorithm that ships with PHP, or the algorithm found in the Perl implementations:
033 * </p>
034 * <ul>
035 * <li><a href="http://search.cpan.org/~mschwern/Text-Metaphone-1.96/Metaphone.pm">Text:Metaphone-1.96</a>
036 *  (broken link 4/30/2013) </li>
037 * <li><a href="https://metacpan.org/source/MSCHWERN/Text-Metaphone-1.96//Metaphone.pm">Text:Metaphone-1.96</a>
038 *  (link checked 4/30/2013) </li>
039 * </ul>
040 * <p>
041 * They have had undocumented changes from the originally published algorithm.
042 * For more information, see <a href="https://issues.apache.org/jira/browse/CODEC-57">CODEC-57</a>.
043 * <p>
044 * This class is conditionally thread-safe.
045 * The instance field {@link #maxCodeLen} is mutable {@link #setMaxCodeLen(int)}
046 * but is not volatile, and accesses are not synchronized.
047 * If an instance of the class is shared between threads, the caller needs to ensure that suitable synchronization
048 * is used to ensure safe publication of the value between threads, and must not invoke {@link #setMaxCodeLen(int)}
049 * after initial setup.
050 *
051 * @version $Id: Metaphone.java 1619948 2014-08-22 22:53:55Z ggregory $
052 */
053public class Metaphone implements StringEncoder {
054
055    /**
056     * Five values in the English language
057     */
058    private static final String VOWELS = "AEIOU";
059
060    /**
061     * Variable used in Metaphone algorithm
062     */
063    private static final String FRONTV = "EIY";
064
065    /**
066     * Variable used in Metaphone algorithm
067     */
068    private static final String VARSON = "CSPTG";
069
070    /**
071     * The max code length for metaphone is 4
072     */
073    private int maxCodeLen = 4;
074
075    /**
076     * Creates an instance of the Metaphone encoder
077     */
078    public Metaphone() {
079        super();
080    }
081
082    /**
083     * Find the metaphone value of a String. This is similar to the
084     * soundex algorithm, but better at finding similar sounding words.
085     * All input is converted to upper case.
086     * Limitations: Input format is expected to be a single ASCII word
087     * with only characters in the A - Z range, no punctuation or numbers.
088     *
089     * @param txt String to find the metaphone code for
090     * @return A metaphone code corresponding to the String supplied
091     */
092    public String metaphone(final String txt) {
093        boolean hard = false;
094        int txtLength;
095        if (txt == null || (txtLength = txt.length()) == 0) {
096            return "";
097        }
098        // single character is itself
099        if (txtLength == 1) {
100            return txt.toUpperCase(java.util.Locale.ENGLISH);
101        }
102
103        final char[] inwd = txt.toUpperCase(java.util.Locale.ENGLISH).toCharArray();
104
105        final StringBuilder local = new StringBuilder(40); // manipulate
106        final StringBuilder code = new StringBuilder(10); //   output
107        // handle initial 2 characters exceptions
108        switch(inwd[0]) {
109        case 'K':
110        case 'G':
111        case 'P': /* looking for KN, etc*/
112            if (inwd[1] == 'N') {
113                local.append(inwd, 1, inwd.length - 1);
114            } else {
115                local.append(inwd);
116            }
117            break;
118        case 'A': /* looking for AE */
119            if (inwd[1] == 'E') {
120                local.append(inwd, 1, inwd.length - 1);
121            } else {
122                local.append(inwd);
123            }
124            break;
125        case 'W': /* looking for WR or WH */
126            if (inwd[1] == 'R') {   // WR -> R
127                local.append(inwd, 1, inwd.length - 1);
128                break;
129            }
130            if (inwd[1] == 'H') {
131                local.append(inwd, 1, inwd.length - 1);
132                local.setCharAt(0, 'W'); // WH -> W
133            } else {
134                local.append(inwd);
135            }
136            break;
137        case 'X': /* initial X becomes S */
138            inwd[0] = 'S';
139            local.append(inwd);
140            break;
141        default:
142            local.append(inwd);
143        } // now local has working string with initials fixed
144
145        final int wdsz = local.length();
146        int n = 0;
147
148        while (code.length() < this.getMaxCodeLen() &&
149               n < wdsz ) { // max code size of 4 works well
150            final char symb = local.charAt(n);
151            // remove duplicate letters except C
152            if (symb != 'C' && isPreviousChar( local, n, symb ) ) {
153                n++;
154            } else { // not dup
155                switch(symb) {
156                case 'A':
157                case 'E':
158                case 'I':
159                case 'O':
160                case 'U':
161                    if (n == 0) {
162                        code.append(symb);
163                    }
164                    break; // only use vowel if leading char
165                case 'B':
166                    if ( isPreviousChar(local, n, 'M') &&
167                         isLastChar(wdsz, n) ) { // B is silent if word ends in MB
168                        break;
169                    }
170                    code.append(symb);
171                    break;
172                case 'C': // lots of C special cases
173                    /* discard if SCI, SCE or SCY */
174                    if ( isPreviousChar(local, n, 'S') &&
175                         !isLastChar(wdsz, n) &&
176                         FRONTV.indexOf(local.charAt(n + 1)) >= 0 ) {
177                        break;
178                    }
179                    if (regionMatch(local, n, "CIA")) { // "CIA" -> X
180                        code.append('X');
181                        break;
182                    }
183                    if (!isLastChar(wdsz, n) &&
184                        FRONTV.indexOf(local.charAt(n + 1)) >= 0) {
185                        code.append('S');
186                        break; // CI,CE,CY -> S
187                    }
188                    if (isPreviousChar(local, n, 'S') &&
189                        isNextChar(local, n, 'H') ) { // SCH->sk
190                        code.append('K');
191                        break;
192                    }
193                    if (isNextChar(local, n, 'H')) { // detect CH
194                        if (n == 0 &&
195                            wdsz >= 3 &&
196                            isVowel(local,2) ) { // CH consonant -> K consonant
197                            code.append('K');
198                        } else {
199                            code.append('X'); // CHvowel -> X
200                        }
201                    } else {
202                        code.append('K');
203                    }
204                    break;
205                case 'D':
206                    if (!isLastChar(wdsz, n + 1) &&
207                        isNextChar(local, n, 'G') &&
208                        FRONTV.indexOf(local.charAt(n + 2)) >= 0) { // DGE DGI DGY -> J
209                        code.append('J'); n += 2;
210                    } else {
211                        code.append('T');
212                    }
213                    break;
214                case 'G': // GH silent at end or before consonant
215                    if (isLastChar(wdsz, n + 1) &&
216                        isNextChar(local, n, 'H')) {
217                        break;
218                    }
219                    if (!isLastChar(wdsz, n + 1) &&
220                        isNextChar(local,n,'H') &&
221                        !isVowel(local,n+2)) {
222                        break;
223                    }
224                    if (n > 0 &&
225                        ( regionMatch(local, n, "GN") ||
226                          regionMatch(local, n, "GNED") ) ) {
227                        break; // silent G
228                    }
229                    if (isPreviousChar(local, n, 'G')) {
230                        // NOTE: Given that duplicated chars are removed, I don't see how this can ever be true
231                        hard = true;
232                    } else {
233                        hard = false;
234                    }
235                    if (!isLastChar(wdsz, n) &&
236                        FRONTV.indexOf(local.charAt(n + 1)) >= 0 &&
237                        !hard) {
238                        code.append('J');
239                    } else {
240                        code.append('K');
241                    }
242                    break;
243                case 'H':
244                    if (isLastChar(wdsz, n)) {
245                        break; // terminal H
246                    }
247                    if (n > 0 &&
248                        VARSON.indexOf(local.charAt(n - 1)) >= 0) {
249                        break;
250                    }
251                    if (isVowel(local,n+1)) {
252                        code.append('H'); // Hvowel
253                    }
254                    break;
255                case 'F':
256                case 'J':
257                case 'L':
258                case 'M':
259                case 'N':
260                case 'R':
261                    code.append(symb);
262                    break;
263                case 'K':
264                    if (n > 0) { // not initial
265                        if (!isPreviousChar(local, n, 'C')) {
266                            code.append(symb);
267                        }
268                    } else {
269                        code.append(symb); // initial K
270                    }
271                    break;
272                case 'P':
273                    if (isNextChar(local,n,'H')) {
274                        // PH -> F
275                        code.append('F');
276                    } else {
277                        code.append(symb);
278                    }
279                    break;
280                case 'Q':
281                    code.append('K');
282                    break;
283                case 'S':
284                    if (regionMatch(local,n,"SH") ||
285                        regionMatch(local,n,"SIO") ||
286                        regionMatch(local,n,"SIA")) {
287                        code.append('X');
288                    } else {
289                        code.append('S');
290                    }
291                    break;
292                case 'T':
293                    if (regionMatch(local,n,"TIA") ||
294                        regionMatch(local,n,"TIO")) {
295                        code.append('X');
296                        break;
297                    }
298                    if (regionMatch(local,n,"TCH")) {
299                        // Silent if in "TCH"
300                        break;
301                    }
302                    // substitute numeral 0 for TH (resembles theta after all)
303                    if (regionMatch(local,n,"TH")) {
304                        code.append('0');
305                    } else {
306                        code.append('T');
307                    }
308                    break;
309                case 'V':
310                    code.append('F'); break;
311                case 'W':
312                case 'Y': // silent if not followed by vowel
313                    if (!isLastChar(wdsz,n) &&
314                        isVowel(local,n+1)) {
315                        code.append(symb);
316                    }
317                    break;
318                case 'X':
319                    code.append('K');
320                    code.append('S');
321                    break;
322                case 'Z':
323                    code.append('S');
324                    break;
325                default:
326                    // do nothing
327                    break;
328                } // end switch
329                n++;
330            } // end else from symb != 'C'
331            if (code.length() > this.getMaxCodeLen()) {
332                code.setLength(this.getMaxCodeLen());
333            }
334        }
335        return code.toString();
336    }
337
338    private boolean isVowel(final StringBuilder string, final int index) {
339        return VOWELS.indexOf(string.charAt(index)) >= 0;
340    }
341
342    private boolean isPreviousChar(final StringBuilder string, final int index, final char c) {
343        boolean matches = false;
344        if( index > 0 &&
345            index < string.length() ) {
346            matches = string.charAt(index - 1) == c;
347        }
348        return matches;
349    }
350
351    private boolean isNextChar(final StringBuilder string, final int index, final char c) {
352        boolean matches = false;
353        if( index >= 0 &&
354            index < string.length() - 1 ) {
355            matches = string.charAt(index + 1) == c;
356        }
357        return matches;
358    }
359
360    private boolean regionMatch(final StringBuilder string, final int index, final String test) {
361        boolean matches = false;
362        if( index >= 0 &&
363            index + test.length() - 1 < string.length() ) {
364            final String substring = string.substring( index, index + test.length());
365            matches = substring.equals( test );
366        }
367        return matches;
368    }
369
370    private boolean isLastChar(final int wdsz, final int n) {
371        return n + 1 == wdsz;
372    }
373
374
375    /**
376     * Encodes an Object using the metaphone algorithm.  This method
377     * is provided in order to satisfy the requirements of the
378     * Encoder interface, and will throw an EncoderException if the
379     * supplied object is not of type java.lang.String.
380     *
381     * @param obj Object to encode
382     * @return An object (or type java.lang.String) containing the
383     *         metaphone code which corresponds to the String supplied.
384     * @throws EncoderException if the parameter supplied is not
385     *                          of type java.lang.String
386     */
387    @Override
388    public Object encode(final Object obj) throws EncoderException {
389        if (!(obj instanceof String)) {
390            throw new EncoderException("Parameter supplied to Metaphone encode is not of type java.lang.String");
391        }
392        return metaphone((String) obj);
393    }
394
395    /**
396     * Encodes a String using the Metaphone algorithm.
397     *
398     * @param str String object to encode
399     * @return The metaphone code corresponding to the String supplied
400     */
401    @Override
402    public String encode(final String str) {
403        return metaphone(str);
404    }
405
406    /**
407     * Tests is the metaphones of two strings are identical.
408     *
409     * @param str1 First of two strings to compare
410     * @param str2 Second of two strings to compare
411     * @return <code>true</code> if the metaphones of these strings are identical,
412     *        <code>false</code> otherwise.
413     */
414    public boolean isMetaphoneEqual(final String str1, final String str2) {
415        return metaphone(str1).equals(metaphone(str2));
416    }
417
418    /**
419     * Returns the maxCodeLen.
420     * @return int
421     */
422    public int getMaxCodeLen() { return this.maxCodeLen; }
423
424    /**
425     * Sets the maxCodeLen.
426     * @param maxCodeLen The maxCodeLen to set
427     */
428    public void setMaxCodeLen(final int maxCodeLen) { this.maxCodeLen = maxCodeLen; }
429
430}