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
18 package org.apache.commons.codec.language.bm;
19
20 import java.util.ArrayList;
21 import java.util.Arrays;
22 import java.util.Collections;
23 import java.util.EnumMap;
24 import java.util.HashSet;
25 import java.util.LinkedHashSet;
26 import java.util.List;
27 import java.util.Locale;
28 import java.util.Map;
29 import java.util.Objects;
30 import java.util.Set;
31 import java.util.TreeMap;
32 import java.util.function.Supplier;
33 import java.util.regex.Pattern;
34 import java.util.stream.Collectors;
35
36 import org.apache.commons.codec.language.bm.Languages.LanguageSet;
37 import org.apache.commons.codec.language.bm.Rule.Phoneme;
38
39 /**
40 * Converts words into potential phonetic representations.
41 * <p>
42 * This is a two-stage process. Firstly, the word is converted into a phonetic representation that takes into account the likely source language. Next, this
43 * phonetic representation is converted into a pan-European 'average' representation, allowing comparison between different versions of essentially the same
44 * word from different languages.
45 * </p>
46 * <p>
47 * This class is intentionally immutable and thread-safe. If you wish to alter the settings for a PhoneticEngine, you must make a new one with the updated
48 * settings.
49 * </p>
50 * <p>
51 * Ported from phoneticengine.php
52 * </p>
53 *
54 * @since 1.6
55 */
56 public class PhoneticEngine {
57
58 /**
59 * Builder for a PhoneticEngine.
60 *
61 * @since 1.23.0
62 */
63 public static class Builder implements Supplier<PhoneticEngine> {
64
65 /** See https://en.wikipedia.org/wiki/Hubert_Blaine_Wolfeschlegelsteinhausenbergerdorff_Sr. */
66 private static final int MAX_INPUT_LENGTH = 666;
67
68 private static final int MAX_PHONEMES = 20;
69
70 private boolean concat = true;
71
72 private int maxInputLength = MAX_INPUT_LENGTH;
73
74 private int maxPhonemes = MAX_PHONEMES;
75
76 private NameType nameType = NameType.GENERIC;
77
78 private RuleType ruleType = RuleType.APPROX;
79
80 private Builder() {
81 // empty
82 }
83
84 @Override
85 public PhoneticEngine get() {
86 return new PhoneticEngine(this);
87 }
88
89 /**
90 * Sets all the properties of this builder to match those of the given engine.
91 *
92 * @param engine The engine to copy properties from.
93 * @return This builder.
94 */
95 public Builder setAll(final PhoneticEngine engine) {
96 this.nameType = engine.getNameType();
97 this.ruleType = engine.getRuleType();
98 this.concat = engine.isConcat();
99 this.maxPhonemes = engine.getMaxPhonemes();
100 return this;
101 }
102
103 /**
104 * Sets whether the engine will concatenate multiple encodings.
105 *
106 * @param concat Whether the engine will concatenate multiple encodings.
107 * @return This builder.
108 */
109 public Builder setConcat(final boolean concat) {
110 this.concat = concat;
111 return this;
112 }
113
114 /**
115 * Sets the maximum input length allowed.
116 * <p>
117 * A value less than 0 will reset the maximum input length to the default value of {@value #MAX_INPUT_LENGTH}, see
118 * <a href="https://en.wikipedia.org/wiki/Hubert_Blaine_Wolfeschlegelsteinhausenbergerdorff_Sr.">Hubert Blaine Wolfeschlegelsteinhausenbergerdorff
119 * Sr.</a>.
120 * </p>
121 *
122 * @param maxInputLength the maximum input length allowed.
123 * @return This builder.
124 */
125 public Builder setMaxInputLength(final int maxInputLength) {
126 this.maxInputLength = maxInputLength < 0 ? MAX_INPUT_LENGTH : maxInputLength;
127 return this;
128 }
129
130 /**
131 * Sets maximum number of phonemes the engine will handle.
132 * <p>
133 * A value less than 0 will reset the maximum number of phonemes to the default {@value #MAX_PHONEMES}.
134 * </p>
135 *
136 * @param maxPhonemes The maximum number of phonemes the engine will handle.
137 * @return This builder.
138 */
139 public Builder setMaxPhonemes(final int maxPhonemes) {
140 this.maxPhonemes = maxPhonemes < 0 ? MAX_PHONEMES : maxPhonemes;
141 return this;
142 }
143
144 /**
145 * Sets the name type for the engine to be built.
146 * <p>
147 * A null value will reset the name type to the default of {@link NameType#GENERIC}.
148 * </p>
149 *
150 * @param nameType The type of names the engine will use.
151 * @return This builder.
152 */
153 public Builder setNameType(final NameType nameType) {
154 this.nameType = nameType != null ? nameType : NameType.GENERIC;
155 return this;
156 }
157
158 /**
159 * Sets the rule type for the engine to be built.
160 * <p>
161 * A null value will reset the rule type to the default of {@link RuleType#APPROX}.
162 * </p>
163 *
164 * @param ruleType The type of rules the engine will use.
165 * @return This builder.
166 */
167 public Builder setRuleType(final RuleType ruleType) {
168 this.ruleType = ruleType != null ? ruleType : RuleType.APPROX;
169 return this;
170 }
171 }
172
173 /**
174 * Manipulates a set of phonemes as they are being built up. Not intended for use outside this package, and probably not outside the {@link PhoneticEngine}
175 * class.
176 *
177 * @since 1.6
178 */
179 static final class PhonemeBuilder {
180
181 /**
182 * An empty builder where all phonemes must come from some set of languages. This will contain a single phoneme of zero characters. This can then be
183 * appended to. This should be the only way to create a new phoneme from scratch.
184 *
185 * @param languages The set of languages.
186 * @return a new, empty phoneme builder.
187 */
188 public static PhonemeBuilder empty(final Languages.LanguageSet languages) {
189 return new PhonemeBuilder(new Rule.Phoneme("", languages));
190 }
191
192 private final Set<Rule.Phoneme> phonemes;
193
194 private PhonemeBuilder(final Rule.Phoneme phoneme) {
195 this.phonemes = new LinkedHashSet<>();
196 this.phonemes.add(phoneme);
197 }
198
199 private PhonemeBuilder(final Set<Rule.Phoneme> phonemes) {
200 this.phonemes = phonemes;
201 }
202
203 /**
204 * Creates a new phoneme builder containing all phonemes in this one extended by {@code str}.
205 *
206 * @param str The characters to append to the phonemes.
207 */
208 public void append(final CharSequence str) {
209 phonemes.forEach(ph -> ph.append(str));
210 }
211
212 /**
213 * Applies the given phoneme expression to all phonemes in this phoneme builder.
214 * <p>
215 * This will lengthen phonemes that have compatible language sets to the expression, and drop those that are incompatible.
216 * </p>
217 *
218 * @param phonemeExpr The expression to apply.
219 * @param maxPhonemes The maximum number of phonemes to build up.
220 */
221 public void apply(final Rule.PhonemeExpr phonemeExpr, final int maxPhonemes) {
222 final Set<Rule.Phoneme> newPhonemes = new LinkedHashSet<>(Math.min(phonemes.size() * phonemeExpr.size(), maxPhonemes));
223 EXPR: for (final Rule.Phoneme left : phonemes) {
224 for (final Rule.Phoneme right : phonemeExpr.getPhonemes()) {
225 final LanguageSet languages = left.getLanguages().restrictTo(right.getLanguages());
226 if (!languages.isEmpty()) {
227 final Rule.Phoneme join = new Phoneme(left, right, languages);
228 if (newPhonemes.size() < maxPhonemes) {
229 newPhonemes.add(join);
230 if (newPhonemes.size() >= maxPhonemes) {
231 break EXPR;
232 }
233 }
234 }
235 }
236 }
237 phonemes.clear();
238 phonemes.addAll(newPhonemes);
239 }
240
241 /**
242 * Gets underlying phoneme set. Please don't mutate.
243 *
244 * @return the phoneme set.
245 */
246 public Set<Rule.Phoneme> getPhonemes() {
247 return phonemes;
248 }
249
250 /**
251 * Stringifies the phoneme set. This produces a single string of the strings of each phoneme, joined with a pipe. This is explicitly provided in place
252 * of toString as it is a potentially expensive operation, which should be avoided when debugging.
253 *
254 * @return the stringified phoneme set.
255 */
256 public String makeString() {
257 return phonemes.stream().map(Rule.Phoneme::getPhonemeText).collect(Collectors.joining("|"));
258 }
259 }
260
261 /**
262 * A function closure capturing the application of a list of rules to an input sequence at a particular offset. After invocation, the values {@code i} and
263 * {@code found} are updated. {@code i} points to the index of the next char in {@code input} that must be processed next (the input up to that index having
264 * been processed already), and {@code found} indicates if a matching rule was found or not. In the case where a matching rule was found,
265 * {@code phonemeBuilder} is replaced with a new builder containing the phonemes updated by the matching rule.
266 * <p>
267 * Although this class is not thread-safe (it has mutable unprotected fields), it is not shared between threads as it is constructed as needed by the
268 * calling methods.
269 * </p>
270 */
271 private static final class RulesApplication {
272
273 private final Map<String, List<Rule>> finalRules;
274
275 private boolean found;
276
277 private int i;
278
279 private final CharSequence input;
280
281 private final int maxPhonemes;
282
283 private final PhonemeBuilder phonemeBuilder;
284
285 RulesApplication(final Map<String, List<Rule>> finalRules, final CharSequence input, final PhonemeBuilder phonemeBuilder, final int i,
286 final int maxPhonemes) {
287 this.finalRules = Objects.requireNonNull(finalRules, "finalRules");
288 this.phonemeBuilder = phonemeBuilder;
289 this.input = input;
290 this.i = i;
291 this.maxPhonemes = maxPhonemes;
292 }
293
294 public int getI() {
295 return i;
296 }
297
298 public PhonemeBuilder getPhonemeBuilder() {
299 return phonemeBuilder;
300 }
301
302 /**
303 * Invokes the rules. Loops over the rules list, stopping at the first one that has a matching context and pattern. Then applies this rule to the
304 * phoneme builder to produce updated phonemes. If there was no match, {@code i} is advanced one and the character is silently dropped from the phonetic
305 * spelling.
306 *
307 * @return {@code this}.
308 */
309 public RulesApplication invoke() {
310 found = false;
311 int patternLength = 1;
312 final List<Rule> rules = finalRules.get(input.subSequence(i, i + patternLength));
313 if (rules != null) {
314 for (final Rule rule : rules) {
315 final String pattern = rule.getPattern();
316 patternLength = pattern.length();
317 if (rule.patternAndContextMatches(input, i)) {
318 phonemeBuilder.apply(rule.getPhoneme(), maxPhonemes);
319 found = true;
320 break;
321 }
322 }
323 }
324 if (!found) {
325 patternLength = 1;
326 }
327 i += patternLength;
328 return this;
329 }
330
331 public boolean isFound() {
332 return found;
333 }
334 }
335
336 private static final Map<NameType, Set<String>> NAME_PREFIXES = new EnumMap<>(NameType.class);
337
338 private static final Pattern QUOTE = Pattern.compile("'");
339 static {
340 NAME_PREFIXES.put(NameType.ASHKENAZI, Collections.unmodifiableSet(new HashSet<>(Arrays.asList("bar", "ben", "da", "de", "van", "von"))));
341 NAME_PREFIXES.put(NameType.SEPHARDIC, Collections.unmodifiableSet(
342 new HashSet<>(Arrays.asList("al", "el", "da", "dal", "de", "del", "dela", "de la", "della", "des", "di", "do", "dos", "du", "van", "von"))));
343 NAME_PREFIXES.put(NameType.GENERIC, Collections.unmodifiableSet(
344 new HashSet<>(Arrays.asList("da", "dal", "de", "del", "dela", "de la", "della", "des", "di", "do", "dos", "du", "van", "von"))));
345 }
346
347 /**
348 * Creates a new builder for a PhoneticEngine.
349 *
350 * @return a new builder for a PhoneticEngine.
351 * @since 1.23.0
352 */
353 public static Builder builder() {
354 return new Builder();
355 }
356
357 /**
358 * Joins some strings with an internal separator.
359 *
360 * @param strings Strings to join.
361 * @param sep String to separate them with.
362 * @return A single String consisting of each element of {@code strings} interleaved by {@code sep}.
363 */
364 private static String join(final List<String> strings, final String sep) {
365 return strings.stream().collect(Collectors.joining(sep));
366 }
367
368 private final boolean concat;
369
370 private final Lang lang;
371
372 private final int maxInputLength;
373
374 private final int maxPhonemes;
375
376 private final NameType nameType;
377
378 private final RuleType ruleType;
379
380 /**
381 * Creates a new, fully-configured phonetic engine.
382 *
383 * @param builder The builder to use for configuration.
384 * @throws IllegalArgumentException Thrown if ruleType is RULES.
385 */
386 private PhoneticEngine(final Builder builder) {
387 if (builder.ruleType == RuleType.RULES) {
388 throw new IllegalArgumentException("ruleType must not be " + RuleType.RULES);
389 }
390 this.nameType = builder.nameType;
391 this.ruleType = builder.ruleType;
392 this.concat = builder.concat;
393 this.lang = Lang.instance(builder.nameType);
394 this.maxPhonemes = builder.maxPhonemes;
395 this.maxInputLength = builder.maxInputLength;
396 }
397
398 /**
399 * Generates a new, fully-configured phonetic engine.
400 *
401 * @param nameType the type of names it will use, null is treated as {@link NameType#GENERIC}.
402 * @param ruleType the type of rules it will apply, null is treated as {@link RuleType#APPROX}.
403 * @param concatenate if it will concatenate multiple encodings.
404 * @deprecated Use {@link #builder()} instead.
405 */
406 @Deprecated
407 public PhoneticEngine(final NameType nameType, final RuleType ruleType, final boolean concatenate) {
408 this(nameType, ruleType, concatenate, Builder.MAX_PHONEMES);
409 }
410
411 /**
412 * Generates a new, fully-configured phonetic engine.
413 *
414 * @param nameType the type of names it will use, null is treated as {@link NameType#GENERIC}.
415 * @param ruleType the type of rules it will apply, null is treated as {@link RuleType#APPROX}.
416 * @param concatenate if it will concatenate multiple encodings.
417 * @param maxPhonemes the maximum number of phonemes that will be handled, less than 0 will reset to the default of {@value Builder#MAX_PHONEMES}.
418 * @throws IllegalArgumentException Thrown if ruleType is RULES.
419 * @since 1.7
420 * @deprecated Use {@link #builder()} instead.
421 */
422 @Deprecated
423 public PhoneticEngine(final NameType nameType, final RuleType ruleType, final boolean concatenate, final int maxPhonemes) {
424 this(builder().setNameType(nameType).setRuleType(ruleType).setConcat(concatenate).setMaxPhonemes(maxPhonemes)
425 .setMaxInputLength(Builder.MAX_INPUT_LENGTH));
426 }
427
428 /**
429 * Applies the final rules to convert from a language-specific phonetic representation to a language-independent representation.
430 *
431 * @param phonemeBuilder The current phonemes.
432 * @param finalRules The final rules to apply.
433 * @return The resulting phonemes.
434 */
435 private PhonemeBuilder applyFinalRules(final PhonemeBuilder phonemeBuilder, final Map<String, List<Rule>> finalRules) {
436 Objects.requireNonNull(finalRules, "finalRules");
437 if (finalRules.isEmpty()) {
438 return phonemeBuilder;
439 }
440 final Map<Rule.Phoneme, Rule.Phoneme> phonemes = new TreeMap<>(Rule.Phoneme.COMPARATOR);
441 phonemeBuilder.getPhonemes().forEach(phoneme -> {
442 PhonemeBuilder subBuilder = PhonemeBuilder.empty(phoneme.getLanguages());
443 final CharSequence phonemeText = phoneme.getPhonemeText();
444 final int length = phonemeText.length();
445 for (int i = 0; i < length;) {
446 final RulesApplication rulesApplication = new RulesApplication(finalRules, phonemeText, subBuilder, i, maxPhonemes).invoke();
447 final boolean found = rulesApplication.isFound();
448 subBuilder = rulesApplication.getPhonemeBuilder();
449 if (!found) {
450 // not found, appending as-is
451 subBuilder.append(phonemeText.subSequence(i, i + 1));
452 }
453 i = rulesApplication.getI();
454 }
455 // the phonemes map orders the phonemes only based on their text, but ignores the language set
456 // when adding new phonemes, check for equal phonemes and merge their language set, otherwise
457 // phonemes with the same text but different language set get lost
458 subBuilder.getPhonemes().forEach(newPhoneme -> {
459 if (phonemes.containsKey(newPhoneme)) {
460 final Rule.Phoneme oldPhoneme = phonemes.remove(newPhoneme);
461 final Rule.Phoneme mergedPhoneme = oldPhoneme.mergeWithLanguage(newPhoneme.getLanguages());
462 phonemes.put(mergedPhoneme, mergedPhoneme);
463 } else {
464 phonemes.put(newPhoneme, newPhoneme);
465 }
466 });
467 });
468 return new PhonemeBuilder(phonemes.keySet());
469 }
470
471 /**
472 * Encodes a string to its phonetic representation.
473 *
474 * @param input the String to encode, not null.
475 * @return The encoding of the input.
476 * @throws IllegalArgumentException Thrown if the input is longer than the maximum allowed length.
477 */
478 public String encode(final String input) {
479 // enforce the input length limit before language guessing runs over the input,
480 // so over-limit input cannot buy a full multi-pass scan before the guard fires
481 if (input.length() > maxInputLength) {
482 throw new IllegalArgumentException("Input is greater than maxInputLength (" + maxInputLength + ").");
483 }
484 return encode(input, lang.guessLanguages(input));
485 }
486
487 /**
488 * Encodes an input string into an output phonetic representation, given a set of possible origin languages.
489 *
490 * @param input String to phoneticise; a String with dashes or spaces separating each word, not null.
491 * @param languageSet set of possible origin languages.
492 * @return A phonetic representation of the input; a String containing '-'-separated phonetic representations of the input.
493 * @throws IllegalArgumentException Thrown if the input is longer than the maximum allowed length.
494 */
495 public String encode(String input, final Languages.LanguageSet languageSet) {
496 if (input.length() > maxInputLength) {
497 throw new IllegalArgumentException("Input is greater than maxInputLength (" + maxInputLength + ").");
498 }
499 final Map<String, List<Rule>> rules = Rule.getInstanceMap(this.nameType, RuleType.RULES, languageSet);
500 // rules common across many (all) languages
501 final Map<String, List<Rule>> finalRules1 = Rule.getInstanceMap(this.nameType, this.ruleType, "common");
502 // rules that apply to a specific language that may be ambiguous or wrong if applied to other languages
503 final Map<String, List<Rule>> finalRules2 = Rule.getInstanceMap(this.nameType, this.ruleType, languageSet);
504 // tidy the input
505 // lower case is a locale-dependent operation
506 input = input.toLowerCase(Locale.ENGLISH).replace('-', ' ').trim();
507 if (this.nameType == NameType.GENERIC) {
508 final String dQuotePrefix = "d'";
509 final int dqpLen = dQuotePrefix.length();
510 if (input.startsWith(dQuotePrefix)) { // check for d'
511 String remainder = input.substring(dqpLen);
512 // Find remainder without allocating new string.
513 final int start = lastRepeat(remainder, dQuotePrefix, dqpLen);
514 remainder = remainder.substring(start);
515 final String combined = "d" + remainder;
516 return "(" + encode(remainder) + ")-(" + encode(combined) + ")";
517 }
518 for (final String l : NAME_PREFIXES.get(this.nameType)) {
519 // handle generic prefixes
520 if (input.startsWith(l + " ")) {
521 // check for any prefix in the words list
522 final String remainder = input.substring(l.length() + 1); // input without the prefix
523 final String combined = l + remainder; // input with prefix without space
524 return "(" + encode(remainder) + ")-(" + encode(combined) + ")";
525 }
526 }
527 }
528 final List<String> words = Arrays.asList(ResourceConstants.SPACES.split(input));
529 final List<String> words2 = new ArrayList<>();
530 // special-case handling of word prefixes based upon the name type
531 switch (this.nameType) {
532 case SEPHARDIC:
533 words.forEach(aWord -> {
534 final String[] parts = QUOTE.split(aWord, -1);
535 words2.add(parts[parts.length - 1]);
536 });
537 words2.removeAll(NAME_PREFIXES.get(this.nameType));
538 break;
539 case ASHKENAZI:
540 words2.addAll(words);
541 words2.removeAll(NAME_PREFIXES.get(this.nameType));
542 break;
543 case GENERIC:
544 words2.addAll(words);
545 break;
546 default:
547 throw new IllegalStateException("Unreachable case: " + this.nameType);
548 }
549 if (this.concat) {
550 // concat mode enabled
551 input = join(words2, " ");
552 } else if (words2.size() == 1) {
553 // not a multi-word name
554 input = words.iterator().next();
555 } else if (!words2.isEmpty()) {
556 // encode each word in a multi-word name separately (normally used for approx matches)
557 final StringBuilder result = new StringBuilder();
558 words2.forEach(word -> result.append("-").append(encode(word)));
559 // return the result without the leading "-"
560 return result.substring(1);
561 }
562 PhonemeBuilder phonemeBuilder = PhonemeBuilder.empty(languageSet);
563 // loop over each char in the input - we will handle the increment manually
564 for (int i = 0; i < input.length();) {
565 final RulesApplication rulesApplication = new RulesApplication(rules, input, phonemeBuilder, i, maxPhonemes).invoke();
566 i = rulesApplication.getI();
567 phonemeBuilder = rulesApplication.getPhonemeBuilder();
568 }
569 // Apply the general rules
570 phonemeBuilder = applyFinalRules(phonemeBuilder, finalRules1);
571 // Apply the language-specific rules
572 phonemeBuilder = applyFinalRules(phonemeBuilder, finalRules2);
573 return phonemeBuilder.makeString();
574 }
575
576 /**
577 * Gets the Lang language guessing rules being used.
578 *
579 * @return The Lang in use.
580 */
581 public Lang getLang() {
582 return this.lang;
583 }
584
585 /**
586 * Gets the maximum number of phonemes the engine will calculate for a given input.
587 *
588 * @return The maximum number of phonemes.
589 * @since 1.7
590 */
591 public int getMaxPhonemes() {
592 return this.maxPhonemes;
593 }
594
595 /**
596 * Gets the NameType being used.
597 *
598 * @return The NameType in use.
599 */
600 public NameType getNameType() {
601 return this.nameType;
602 }
603
604 /**
605 * Gets the RuleType being used.
606 *
607 * @return The RuleType in use.
608 */
609 public RuleType getRuleType() {
610 return this.ruleType;
611 }
612
613 /**
614 * Tests whether multiple phonetic encodings are concatenated or just the first one is kept.
615 *
616 * @return true if multiple phonetic encodings are returned, false if just the first is.
617 */
618 public boolean isConcat() {
619 return this.concat;
620 }
621
622 /**
623 * Finds the index past the last occurrence of a repeating prefix in a string, starting from the beginning of the string and moving forward.
624 *
625 * @param source The source string to search within.
626 * @param prefix The prefix to look for.
627 * @param prefixLen The length of the prefix.
628 * @return The index in the source string where the last occurrence of the repeating prefix ends.
629 */
630 private int lastRepeat(final String source, final String prefix, final int prefixLen) {
631 // Find without allocating new string.
632 int start = 0;
633 while (start + prefixLen <= source.length()) {
634 int i = 0;
635 while (i < prefixLen && source.charAt(start + i) == prefix.charAt(i)) {
636 i++;
637 }
638 if (i != prefixLen) {
639 break;
640 }
641 start += prefixLen;
642 }
643 return start;
644 }
645 }