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 > 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 }