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