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 * https://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 */ 017package org.apache.commons.codec.language; 018 019import java.util.Locale; 020 021import org.apache.commons.codec.EncoderException; 022import org.apache.commons.codec.StringEncoder; 023 024/** 025 * Match Rating Approach Phonetic Algorithm Developed by <CITE>Western Airlines</CITE> in 1977. 026 * <p> 027 * This class is immutable and thread-safe. 028 * </p> 029 * 030 * @see <a href="https://en.wikipedia.org/wiki/Match_rating_approach">Wikipedia - Match Rating Approach</a> 031 * @since 1.8 032 */ 033public class MatchRatingApproachEncoder implements StringEncoder { 034 035 private static final String SPACE = " "; 036 037 private static final String EMPTY = ""; 038 039 /** 040 * The plain letter equivalent of the accented letters. 041 */ 042 private static final String PLAIN_ASCII = "AaEeIiOoUu" + // grave 043 "AaEeIiOoUuYy" + // acute 044 "AaEeIiOoUuYy" + // circumflex 045 "AaOoNn" + // tilde 046 "AaEeIiOoUuYy" + // umlaut 047 "Aa" + // ring 048 "Cc" + // cedilla 049 "OoUu"; // double acute 050 051 /** 052 * Unicode characters corresponding to various accented letters. For example: \u00DA is U acute etc... 053 */ 054 private static final String UNICODE = "\u00C0\u00E0\u00C8\u00E8\u00CC\u00EC\u00D2\u00F2\u00D9\u00F9" + 055 "\u00C1\u00E1\u00C9\u00E9\u00CD\u00ED\u00D3\u00F3\u00DA\u00FA\u00DD\u00FD" + 056 "\u00C2\u00E2\u00CA\u00EA\u00CE\u00EE\u00D4\u00F4\u00DB\u00FB\u0176\u0177\u00C3\u00E3\u00D5\u00F5\u00D1\u00F1" + 057 "\u00C4\u00E4\u00CB\u00EB\u00CF\u00EF\u00D6\u00F6\u00DC\u00FC\u0178\u00FF\u00C5\u00E5\u00C7\u00E7\u0150\u0151\u0170\u0171"; 058 059 /** 060 * Double consonants. 061 */ 062 private static final String[] DOUBLE_CONSONANT = 063 { "BB", "CC", "DD", "FF", "GG", "HH", "JJ", "KK", "LL", "MM", "NN", "PP", "QQ", "RR", "SS", 064 "TT", "VV", "WW", "XX", "YY", "ZZ" }; 065 066 /** 067 * Constructs a new instance. 068 */ 069 public MatchRatingApproachEncoder() { 070 // empty 071 } 072 073 /** 074 * Cleans up a name: 1. Upper-cases everything 2. Removes some common punctuation 3. Removes accents 4. Removes any 075 * spaces. 076 * 077 * <h2>API Usage</h2> 078 * <p> 079 * Consider this method private; it has package access for unit testing only. 080 * </p> 081 * 082 * @param name 083 * The name to be cleaned. 084 * @return The cleaned name. 085 */ 086 String cleanName(final String name) { 087 String upperName = name.toUpperCase(Locale.ENGLISH); 088 089 final String[] charsToTrim = { "\\-", "[&]", "\\'", "\\.", "[\\,]" }; 090 for (final String str : charsToTrim) { 091 upperName = upperName.replaceAll(str, EMPTY); 092 } 093 094 upperName = removeAccents(upperName); 095 return upperName.replaceAll("\\s+", EMPTY); 096 } 097 098 /** 099 * 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}