View Javadoc
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  
19  import java.util.Locale;
20  
21  import org.apache.commons.codec.EncoderException;
22  import org.apache.commons.codec.StringEncoder;
23  
24  /**
25   * Match Rating Approach Phonetic Algorithm Developed by <CITE>Western Airlines</CITE> in 1977.
26   * <p>
27   * This class is immutable and thread-safe.
28   * </p>
29   *
30   * @see <a href="https://en.wikipedia.org/wiki/Match_rating_approach">Wikipedia - Match Rating Approach</a>
31   * @since 1.8
32   */
33  public class MatchRatingApproachEncoder implements StringEncoder {
34  
35      private static final String SPACE = " ";
36  
37      private static final String EMPTY = "";
38  
39      /**
40       * The plain letter equivalent of the accented letters.
41       */
42      private static final String PLAIN_ASCII = "AaEeIiOoUu" + // grave
43              "AaEeIiOoUuYy" + // acute
44              "AaEeIiOoUuYy" + // circumflex
45              "AaOoNn" + // tilde
46              "AaEeIiOoUuYy" + // umlaut
47              "Aa" + // ring
48              "Cc" + // cedilla
49              "OoUu"; // double acute
50  
51      /**
52       * Unicode characters corresponding to various accented letters. For example: \u00DA is U acute etc...
53       */
54      private static final String UNICODE = "\u00C0\u00E0\u00C8\u00E8\u00CC\u00EC\u00D2\u00F2\u00D9\u00F9" +
55              "\u00C1\u00E1\u00C9\u00E9\u00CD\u00ED\u00D3\u00F3\u00DA\u00FA\u00DD\u00FD" +
56              "\u00C2\u00E2\u00CA\u00EA\u00CE\u00EE\u00D4\u00F4\u00DB\u00FB\u0176\u0177\u00C3\u00E3\u00D5\u00F5\u00D1\u00F1" +
57              "\u00C4\u00E4\u00CB\u00EB\u00CF\u00EF\u00D6\u00F6\u00DC\u00FC\u0178\u00FF\u00C5\u00E5\u00C7\u00E7\u0150\u0151\u0170\u0171";
58  
59      /**
60       * Double consonants.
61       */
62      private static final String[] DOUBLE_CONSONANT =
63              { "BB", "CC", "DD", "FF", "GG", "HH", "JJ", "KK", "LL", "MM", "NN", "PP", "QQ", "RR", "SS",
64                     "TT", "VV", "WW", "XX", "YY", "ZZ" };
65  
66      /**
67       * Constructs a new instance.
68       */
69      public MatchRatingApproachEncoder() {
70          // empty
71      }
72  
73      /**
74       * Cleans up a name: 1. Upper-cases everything 2. Removes some common punctuation 3. Removes accents 4. Removes any
75       * spaces.
76       *
77       * <h2>API Usage</h2>
78       * <p>
79       * Consider this method private; it has package access for unit testing only.
80       * </p>
81       *
82       * @param name
83       *            The name to be cleaned.
84       * @return The cleaned name.
85       */
86      String cleanName(final String name) {
87          String upperName = name.toUpperCase(Locale.ENGLISH);
88  
89          final String[] charsToTrim = { "\\-", "[&]", "\\'", "\\.", "[\\,]" };
90          for (final String str : charsToTrim) {
91              upperName = upperName.replaceAll(str, EMPTY);
92          }
93  
94          upperName = removeAccents(upperName);
95          return upperName.replaceAll("\\s+", EMPTY);
96      }
97  
98      /**
99       * Encodes an Object using the Match Rating Approach algorithm. Method is here to satisfy the requirements of the
100      * Encoder interface Throws an EncoderException if input object is not of type {@link String}.
101      *
102      * @param object
103      *            Object to encode.
104      * @return An object (or type {@link String}) containing the Match Rating Approach code which corresponds to the
105      *         String supplied.
106      * @throws EncoderException
107      *             Thrown if the parameter supplied is not of type {@link String}.
108      */
109     @Override
110     public final Object encode(final Object object) throws EncoderException {
111         if (!(object instanceof String)) {
112             throw new EncoderException("Parameter supplied to Match Rating Approach encoder is not of type java.lang.String");
113         }
114         return encode((String) object);
115     }
116 
117     /**
118      * Encodes a String using the Match Rating Approach (MRA) algorithm.
119      *
120      * @param name
121      *            String object to encode.
122      * @return The MRA code corresponding to the String supplied.
123      */
124     @Override
125     public final String encode(String name) {
126         // Bulletproof for trivial input - NINO
127         if (name == null || EMPTY.equalsIgnoreCase(name) || SPACE.equalsIgnoreCase(name) || name.length() == 1) {
128             return EMPTY;
129         }
130 
131         // Preprocessing
132         name = cleanName(name);
133 
134         // Bulletproof if name becomes empty after cleanName(name)
135         if (SPACE.equals(name) || name.isEmpty()) {
136             return EMPTY;
137         }
138 
139         // BEGIN: Actual encoding part of the algorithm...
140         // 1. Delete all vowels unless the vowel begins the word
141         name = removeVowels(name);
142 
143         // Bulletproof if name becomes empty after removeVowels(name)
144         if (SPACE.equals(name) || name.isEmpty()) {
145             return EMPTY;
146         }
147 
148         // 2. Remove second consonant from any double consonant
149         name = removeDoubleConsonants(name);
150 
151         return getFirst3Last3(name);
152     }
153 
154     /**
155      * Gets the first and last 3 letters of a name (if &gt; 6 characters) Else just returns the name.
156      *
157      * <h2>API Usage</h2>
158      * <p>
159      * Consider this method private; it has package access for unit testing only.
160      * </p>
161      *
162      * @param name
163      *            The string to get the substrings from.
164      * @return Annexed first and last 3 letters of input word.
165      */
166     String getFirst3Last3(final String name) {
167         final int nameLength = name.length();
168 
169         if (nameLength > 6) {
170             final String firstThree = name.substring(0, 3);
171             final String lastThree = name.substring(nameLength - 3, nameLength);
172             return firstThree + lastThree;
173         }
174         return name;
175     }
176 
177     /**
178      * Gets the minimum rating for the sum of the lengths of the two names.
179      * <p>
180      * The larger the sum of the lengths, the smaller the minimum rating. The values come directly from the documentation.
181      * </p>
182      *
183      * <h2>API Usage</h2>
184      * <p>
185      * Consider this method private; it has package access for unit testing only.
186      * </p>
187      *
188      * @param sumLength
189      *            The length of 2 strings sent down.
190      * @return The min rating value.
191      */
192     int getMinRating(final int sumLength) {
193         int minRating = 0;
194 
195         if (sumLength <= 4) {
196             minRating = 5;
197         } else if (sumLength <= 7) { // already know it is at least 5
198             minRating = 4;
199         } else if (sumLength <= 11) { // already know it is at least 8
200             minRating = 3;
201         } else if (sumLength == 12) {
202             minRating = 2;
203         } else {
204             minRating = 1; // docs said little here.
205         }
206 
207         return minRating;
208     }
209 
210     /**
211      * Tests if two names are homophonous via Match Rating Approach (MRA) algorithm. It should be noted that the
212      * strings are cleaned in the same way as {@link #encode(String)}.
213      *
214      * @param name1
215      *            First of the 2 strings (names) to compare.
216      * @param name2
217      *            Second of the 2 names to compare.
218      * @return {@code true} if the encodings are identical {@code false} otherwise.
219      */
220     public boolean isEncodeEquals(String name1, String name2) {
221         // Bulletproof for trivial input - NINO
222         if (name1 == null || EMPTY.equalsIgnoreCase(name1) || SPACE.equalsIgnoreCase(name1)) {
223             return false;
224         }
225         if (name2 == null || EMPTY.equalsIgnoreCase(name2) || SPACE.equalsIgnoreCase(name2)) {
226             return false;
227         }
228         if (name1.length() == 1 || name2.length() == 1) {
229             return false;
230         }
231         if (name1.equalsIgnoreCase(name2)) {
232             return true;
233         }
234 
235         // Preprocessing
236         name1 = cleanName(name1);
237         name2 = cleanName(name2);
238 
239         // Actual MRA Algorithm
240 
241         // 1. Remove vowels
242         name1 = removeVowels(name1);
243         name2 = removeVowels(name2);
244 
245         // 2. Remove double consonants
246         name1 = removeDoubleConsonants(name1);
247         name2 = removeDoubleConsonants(name2);
248 
249         // 3. Reduce down to 3 letters
250         name1 = getFirst3Last3(name1);
251         name2 = getFirst3Last3(name2);
252 
253         // 4. Check for length difference - if 3 or greater, then no similarity
254         // comparison is done
255         if (Math.abs(name1.length() - name2.length()) >= 3) {
256             return false;
257         }
258 
259         // 5. Obtain the minimum rating value by calculating the length sum of the
260         // encoded Strings and sending it down.
261         final int sumLength = Math.abs(name1.length() + name2.length());
262         final int minRating = getMinRating(sumLength);
263 
264         // 6. Process the encoded Strings from left to right and remove any
265         // identical characters found from both Strings respectively.
266         final int count = leftToRightThenRightToLeftProcessing(name1, name2);
267 
268         // 7. Each PNI item that has a similarity rating equal to or greater than
269         // the min is considered to be a good candidate match
270         return count >= minRating;
271 
272     }
273 
274     /**
275      * Tests if a letter is a vowel.
276      *
277      * <h2>API Usage</h2>
278      * <p>
279      * Consider this method private; it has package access for unit testing only.
280      * </p>
281      *
282      * @param letter
283      *            The letter under investigation.
284      * @return True if a vowel, else false.
285      */
286     boolean isVowel(final String letter) {
287         return letter.equalsIgnoreCase("E") || letter.equalsIgnoreCase("A") || letter.equalsIgnoreCase("O") ||
288                letter.equalsIgnoreCase("I") || letter.equalsIgnoreCase("U");
289     }
290 
291     /**
292      * Processes the names from left to right (first) then right to left removing identical letters in same positions.
293      * Then subtracts the longer string that remains from 6 and returns this.
294      *
295      * <h2>API Usage</h2>
296      * <p>
297      * Consider this method private; it has package access for unit testing only.
298      * </p>
299      *
300      * @param name1 first name.
301      * @param name1 second name.
302      * @return The length as above.
303      */
304     int leftToRightThenRightToLeftProcessing(final String name1, final String name2) {
305         final char[] name1Char = name1.toCharArray();
306         final char[] name2Char = name2.toCharArray();
307 
308         final int name1Size = name1.length() - 1;
309         final int name2Size = name2.length() - 1;
310 
311         String name1LtRStart = EMPTY;
312         String name1LtREnd = EMPTY;
313 
314         String name2RtLStart = EMPTY;
315         String name2RtLEnd = EMPTY;
316 
317         for (int i = 0; i < name1Char.length; i++) {
318             if (i > name2Size) {
319                 break;
320             }
321 
322             name1LtRStart = name1.substring(i, i + 1);
323             name1LtREnd = name1.substring(name1Size - i, name1Size - i + 1);
324 
325             name2RtLStart = name2.substring(i, i + 1);
326             name2RtLEnd = name2.substring(name2Size - i, name2Size - i + 1);
327 
328             // Left to right...
329             if (name1LtRStart.equals(name2RtLStart)) {
330                 name1Char[i] = ' ';
331                 name2Char[i] = ' ';
332             }
333 
334             // Right to left...
335             if (name1LtREnd.equals(name2RtLEnd)) {
336                 name1Char[name1Size - i] = ' ';
337                 name2Char[name2Size - i] = ' ';
338             }
339         }
340 
341         // Char arrays -> string & remove extraneous space
342         final String strA = new String(name1Char).replaceAll("\\s+", EMPTY);
343         final String strB = new String(name2Char).replaceAll("\\s+", EMPTY);
344 
345         // Final bit - subtract the longest string from 6 and return this int value
346         if (strA.length() > strB.length()) {
347             return Math.abs(6 - strA.length());
348         }
349         return Math.abs(6 - strB.length());
350     }
351 
352     /**
353      * Removes accented letters and replaces with non-accented ASCII equivalent Case is preserved.
354      * http://www.codecodex.com/wiki/Remove_accent_from_letters_%28ex_.%C3%A9_to_e%29
355      *
356      * @param accentedWord
357      *            The word that may have accents in it.
358      * @return De-accented word.
359      */
360     String removeAccents(final String accentedWord) {
361         if (accentedWord == null) {
362             return null;
363         }
364 
365         final StringBuilder sb = new StringBuilder();
366         final int n = accentedWord.length();
367 
368         for (int i = 0; i < n; i++) {
369             final char c = accentedWord.charAt(i);
370             final int pos = UNICODE.indexOf(c);
371             if (pos > -1) {
372                 sb.append(PLAIN_ASCII.charAt(pos));
373             } else {
374                 sb.append(c);
375             }
376         }
377 
378         return sb.toString();
379     }
380 
381     /**
382      * Replaces any double consonant pair with the single letter equivalent.
383      *
384      * <h2>API Usage</h2>
385      * <p>
386      * Consider this method private; it has package access for unit testing only.
387      * </p>
388      *
389      * @param name
390      *            String to have double consonants removed.
391      * @return Single consonant word.
392      */
393     String removeDoubleConsonants(final String name) {
394         String replacedName = name.toUpperCase(Locale.ENGLISH);
395         for (final String dc : DOUBLE_CONSONANT) {
396             if (replacedName.contains(dc)) {
397                 final String singleLetter = dc.substring(0, 1);
398                 replacedName = replacedName.replace(dc, singleLetter);
399             }
400         }
401         return replacedName;
402     }
403 
404     /**
405      * Deletes all vowels unless the vowel begins the word.
406      *
407      * <h2>API Usage</h2>
408      * <p>
409      * Consider this method private; it has package access for unit testing only.
410      * </p>
411      *
412      * @param name
413      *            The name to have vowels removed.
414      * @return De-voweled word.
415      */
416     String removeVowels(String name) {
417         // Extract first letter
418         final String firstLetter = name.substring(0, 1);
419 
420         name = name.replace("A", EMPTY);
421         name = name.replace("E", EMPTY);
422         name = name.replace("I", EMPTY);
423         name = name.replace("O", EMPTY);
424         name = name.replace("U", EMPTY);
425 
426         name = name.replaceAll("\\s{2,}\\b", SPACE);
427 
428         // return isVowel(firstLetter) ? (firstLetter + name) : name;
429         if (isVowel(firstLetter)) {
430             return firstLetter + name;
431         }
432         return name;
433     }
434 }