    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.text;
    018
    019import java.io.UnsupportedEncodingException;
    020import java.util.Arrays;
    021import java.util.Collection;
    022import java.util.Collections;
    023import java.util.HashMap;
    024import java.util.Iterator;
    025import java.util.LinkedHashMap;
    026import java.util.LinkedHashSet;
    027import java.util.Map;
    028import java.util.Map.Entry;
    029import java.util.Objects;
    030import java.util.Set;
    031
    032import org.apache.commons.lang3.ArrayUtils;
    033import org.apache.commons.lang3.StringUtils;
    034
    035/**
    036 * <p>
    037 * Convert from one alphabet to another, with the possibility of leaving certain
    038 * characters unencoded.
    039 * </p>
    040 *
    041 * <p>
    042 * The target and 'do not encode' languages must be in the Unicode BMP, but the
    043 * source language does not.
    044 * </p>
    045 *
    046 * <p>
    047 * The encoding will all be of a fixed length, except for the 'do not encode'
    048 * chars, which will be of length 1
    049 * </p>
    050 *
    051 * <h2>Sample usage</h2>
    052 *
    053 * <pre>
    054 * Character[] originals;   // a, b, c, d
    055 * Character[] encoding;    // 0, 1, d
    056 * Character[] doNotEncode; // d
    057 *
    058 * AlphabetConverter ac = AlphabetConverter.createConverterFromChars(originals,
    059 * encoding, doNotEncode);
    060 *
    061 * ac.encode("a");    // 00
    062 * ac.encode("b");    // 01
    063 * ac.encode("c");    // 0d
    064 * ac.encode("d");    // d
    065 * ac.encode("abcd"); // 00010dd
    066 * </pre>
    067 *
    068 * <p>
    069 * #ThreadSafe# AlphabetConverter class methods are thread-safe as they do not
    070 * change internal state.
    071 * </p>
    072 *
    073 * @since 1.0
    074 */
    075public final class AlphabetConverter {
    076
    077    /**
    078     * Arrow constant, used for converting the object into a string.
    079     */
    080    private static final String ARROW = " -> ";
    081
    082    /**
    083     * Creates new String that contains just the given code point.
    084     *
    085     * @param i code point
    086     * @return a new string with the new code point
    087     * @see "http://www.oracle.com/us/technologies/java/supplementary-142654.html"
    088     */
    089    private static String codePointToString(final int i) {
    090        if (Character.charCount(i) == 1) {
    091            return String.valueOf((char) i);
    092        }
    093        return new String(Character.toChars(i));
    094    }
    095
    096    /**
    097     * Converts characters to integers.
    098     *
    099     * @param chars array of characters
    100     * @return an equivalent array of integers
    101     */
    102    private static Integer[] convertCharsToIntegers(final Character[] chars) {
    103        if (ArrayUtils.isEmpty(chars)) {
    104            return ArrayUtils.EMPTY_INTEGER_OBJECT_ARRAY;
    105        }
    106        final Integer[] integers = new Integer[chars.length];
    107        Arrays.setAll(integers, i -> (int) chars[i]);
    108        return integers;
    109    }
    110
    111    /**
    112     * Creates an alphabet converter, for converting from the original alphabet,
    113     * to the encoded alphabet, while leaving
    114     * the characters in <em>doNotEncode</em> as they are (if possible).
    115     *
    116     * <p>Duplicate letters in either original or encoding will be ignored.</p>
    117     *
    118     * @param original an array of ints representing the original alphabet in
    119     *                 code points
    120     * @param encoding an array of ints representing the alphabet to be used for
    121     *                 encoding, in code points
    122     * @param doNotEncode an array of ints representing the chars to be encoded
    123     *                    using the original alphabet - every char
    124     *                    here must appear in both the previous params
    125     * @return The AlphabetConverter
    126     * @throws IllegalArgumentException if an AlphabetConverter cannot be
    127     *                                   constructed
    128     */
    129    public static AlphabetConverter createConverter(
    130            final Integer[] original,
    131            final Integer[] encoding,
    132            final Integer[] doNotEncode) {
    133        final Set<Integer> originalCopy = new LinkedHashSet<>(Arrays.asList(original));
    134        final Set<Integer> encodingCopy = new LinkedHashSet<>(Arrays.asList(encoding));
    135        final Set<Integer> doNotEncodeCopy = new LinkedHashSet<>(Arrays.asList(doNotEncode));
    136
    137        final Map<Integer, String> originalToEncoded = new LinkedHashMap<>();
    138        final Map<String, String> encodedToOriginal = new LinkedHashMap<>();
    139        final Map<Integer, String> doNotEncodeMap = new HashMap<>();
    140
    141        final int encodedLetterLength;
    142
    143        for (final int i : doNotEncodeCopy) {
    144            if (!originalCopy.contains(i)) {
    145                throw new IllegalArgumentException(
    146                        "Can not use 'do not encode' list because original "
    147                                + "alphabet does not contain '"
    148                                + codePointToString(i) + "'");
    149            }
    150
    151            if (!encodingCopy.contains(i)) {
    152                throw new IllegalArgumentException(
    153                        "Can not use 'do not encode' list because encoding alphabet does not contain '"
    154                                + codePointToString(i) + "'");
    155            }
    156
    157            doNotEncodeMap.put(i, codePointToString(i));
    158        }
    159
    160        if (encodingCopy.size() >= originalCopy.size()) {
    161            encodedLetterLength = 1;
    162
    163            final Iterator<Integer> it = encodingCopy.iterator();
    164
    165            for (final int originalLetter : originalCopy) {
    166                final String originalLetterAsString = codePointToString(originalLetter);
    167
    168                if (doNotEncodeMap.containsKey(originalLetter)) {
    169                    originalToEncoded.put(originalLetter, originalLetterAsString);
    170                    encodedToOriginal.put(originalLetterAsString, originalLetterAsString);
    171                } else {
    172                    Integer next = it.next();
    173
    174                    while (doNotEncodeCopy.contains(next)) {
    175                        next = it.next();
    176                    }
    177
    178                    final String encodedLetter = codePointToString(next);
    179
    180                    originalToEncoded.put(originalLetter, encodedLetter);
    181                    encodedToOriginal.put(encodedLetter, originalLetterAsString);
    182                }
    183            }
    184
    185            return new AlphabetConverter(originalToEncoded, encodedToOriginal, encodedLetterLength);
    186
    187        }
    188        if (encodingCopy.size() - doNotEncodeCopy.size() < 2) {
    189            throw new IllegalArgumentException(
    190                    "Must have at least two encoding characters (excluding "
    191                            + "those in the 'do not encode' list), but has "
    192                            + (encodingCopy.size() - doNotEncodeCopy.size()));
    193        }
    194        // we start with one which is our minimum, and because we do the
    195        // first division outside the loop
    196        int lettersSoFar = 1;
    197
    198        // the first division takes into account that the doNotEncode
    199        // letters can't be in the leftmost place
    200        int lettersLeft = (originalCopy.size() - doNotEncodeCopy.size())
    201                / (encodingCopy.size() - doNotEncodeCopy.size());
    202
    203        while (lettersLeft / encodingCopy.size() >= 1) {
    204            lettersLeft /= encodingCopy.size();
    205            lettersSoFar++;
    206        }
    207
    208        encodedLetterLength = lettersSoFar + 1;
    209
    210        final AlphabetConverter ac =
    211                new AlphabetConverter(originalToEncoded,
    212                        encodedToOriginal,
    213                        encodedLetterLength);
    214
    215        ac.addSingleEncoding(encodedLetterLength,
    216                StringUtils.EMPTY,
    217                encodingCopy,
    218                originalCopy.iterator(),
    219                doNotEncodeMap);
    220
    221        return ac;
    222    }
    223
    224    /**
    225     * Creates an alphabet converter, for converting from the original alphabet,
    226     * to the encoded alphabet, while leaving the characters in
    227     * <em>doNotEncode</em> as they are (if possible).
    228     *
    229     * <p>Duplicate letters in either original or encoding will be ignored.</p>
    230     *
    231     * @param original an array of chars representing the original alphabet
    232     * @param encoding an array of chars representing the alphabet to be used
    233     *                 for encoding
    234     * @param doNotEncode an array of chars to be encoded using the original
    235     *                    alphabet - every char here must appear in
    236     *                    both the previous params
    237     * @return The AlphabetConverter
    238     * @throws IllegalArgumentException if an AlphabetConverter cannot be
    239     *                                  constructed
    240     */
    241    public static AlphabetConverter createConverterFromChars(
    242            final Character[] original,
    243            final Character[] encoding,
    244            final Character[] doNotEncode) {
    245        return createConverter(
    246                convertCharsToIntegers(original),
    247                convertCharsToIntegers(encoding),
    248                convertCharsToIntegers(doNotEncode));
    249    }
    250
    251    /**
    252     * Creates a new converter from a map.
    253     *
    254     * @param originalToEncoded a map returned from getOriginalToEncoded()
    255     * @return The reconstructed AlphabetConverter
    256     * @see AlphabetConverter#getOriginalToEncoded()
    257     */
    258    public static AlphabetConverter createConverterFromMap(final Map<Integer, String> originalToEncoded) {
    259        final Map<Integer, String> unmodifiableOriginalToEncoded = Collections.unmodifiableMap(originalToEncoded);
    260        final Map<String, String> encodedToOriginal = new LinkedHashMap<>();
    261
    262        int encodedLetterLength = 1;
    263
    264        for (final Entry<Integer, String> e : unmodifiableOriginalToEncoded.entrySet()) {
    265            encodedToOriginal.put(e.getValue(), codePointToString(e.getKey()));
    266
    267            if (e.getValue().length() > encodedLetterLength) {
    268                encodedLetterLength = e.getValue().length();
    269            }
    270        }
    271
    272        return new AlphabetConverter(unmodifiableOriginalToEncoded, encodedToOriginal, encodedLetterLength);
    273    }
    274
    275    /**
    276     * Original string to be encoded.
    277     */
    278    private final Map<Integer, String> originalToEncoded;
    279
    280    /**
    281     * Encoding alphabet.
    282     */
    283    private final Map<String, String> encodedToOriginal;
    284
    285    /**
    286     * Length of the encoded letter.
    287     */
    288    private final int encodedLetterLength;
    289
    290    /**
    291     * Hidden constructor for alphabet converter. Used by static helper methods.
    292     *
    293     * @param originalToEncoded original string to be encoded
    294     * @param encodedToOriginal encoding alphabet
    295     * @param encodedLetterLength length of the encoded letter
    296     */
    297    private AlphabetConverter(final Map<Integer, String> originalToEncoded,
    298                              final Map<String, String> encodedToOriginal,
    299                              final int encodedLetterLength) {
    300
    301        this.originalToEncoded = originalToEncoded;
    302        this.encodedToOriginal = encodedToOriginal;
    303        this.encodedLetterLength = encodedLetterLength;
    304    }
    305
    306    /**
    307     * Recursive method used when creating encoder/decoder.
    308     *
    309     * @param level at which point it should add a single encoding
    310     * @param currentEncoding current encoding
    311     * @param encoding letters encoding
    312     * @param originals original values
    313     * @param doNotEncodeMap map of values that should not be encoded
    314     */
    315    private void addSingleEncoding(final int level,
    316                                   final String currentEncoding,
    317                                   final Collection<Integer> encoding,
    318                                   final Iterator<Integer> originals,
    319                                   final Map<Integer, String> doNotEncodeMap) {
    320
    321        if (level > 0) {
    322            for (final int encodingLetter : encoding) {
    323                if (!originals.hasNext()) {
    324                    return; // done encoding all the original alphabet
    325                }
    326                // this skips the doNotEncode chars if they are in the
    327                // leftmost place
    328                if (level != encodedLetterLength
    329                        || !doNotEncodeMap.containsKey(encodingLetter)) {
    330                    addSingleEncoding(level - 1,
    331                            currentEncoding
    332                                    + codePointToString(encodingLetter),
    333                            encoding,
    334                            originals,
    335                            doNotEncodeMap
    336                    );
    337                }
    338            }
    339        } else {
    340            Integer next = originals.next();
    341
    342            while (doNotEncodeMap.containsKey(next)) {
    343                final String originalLetterAsString = codePointToString(next);
    344
    345                originalToEncoded.put(next, originalLetterAsString);
    346                encodedToOriginal.put(originalLetterAsString,
    347                        originalLetterAsString);
    348
    349                if (!originals.hasNext()) {
    350                    return;
    351                }
    352
    353                next = originals.next();
    354            }
    355
    356            final String originalLetterAsString = codePointToString(next);
    357
    358            originalToEncoded.put(next, currentEncoding);
    359            encodedToOriginal.put(currentEncoding, originalLetterAsString);
    360        }
    361    }
    362
    363    /**
    364     * Decodes a given string.
    365     *
    366     * @param encoded a string that has been encoded using this
    367     *                AlphabetConverter
    368     * @return The decoded string, {@code null} if the given string is null
    369     * @throws UnsupportedEncodingException if unexpected characters that
    370     *                                      cannot be handled are encountered
    371     */
    372    public String decode(final String encoded)
    373            throws UnsupportedEncodingException {
    374        if (encoded == null) {
    375            return null;
    376        }
    377
    378        final StringBuilder result = new StringBuilder();
    379
    380        for (int j = 0; j < encoded.length();) {
    381            final int i = encoded.codePointAt(j);
    382            final String s = codePointToString(i);
    383
    384            if (s.equals(originalToEncoded.get(i))) {
    385                result.append(s);
    386                j++; // because we do not encode in Unicode extended the
    387                     // length of each encoded char is 1
    388            } else {
    389                if (j + encodedLetterLength > encoded.length()) {
    390                    throw new UnsupportedEncodingException("Unexpected end "
    391                            + "of string while decoding " + encoded);
    392                }
    393                final String nextGroup = encoded.substring(j,
    394                        j + encodedLetterLength);
    395                final String next = encodedToOriginal.get(nextGroup);
    396                if (next == null) {
    397                    throw new UnsupportedEncodingException(
    398                            "Unexpected string without decoding ("
    399                                    + nextGroup + ") in " + encoded);
    400                }
    401                result.append(next);
    402                j += encodedLetterLength;
    403            }
    404        }
    405
    406        return result.toString();
    407    }
    408
    409    /**
    410     * Encodes a given string.
    411     *
    412     * @param original the string to be encoded
    413     * @return The encoded string, {@code null} if the given string is null
    414     * @throws UnsupportedEncodingException if chars that are not supported are
    415     *                                      encountered
    416     */
    417    public String encode(final String original)
    418            throws UnsupportedEncodingException {
    419        if (original == null) {
    420            return null;
    421        }
    422
    423        final StringBuilder sb = new StringBuilder();
    424
    425        for (int i = 0; i < original.length();) {
    426            final int codePoint = original.codePointAt(i);
    427
    428            final String nextLetter = originalToEncoded.get(codePoint);
    429
    430            if (nextLetter == null) {
    431                throw new UnsupportedEncodingException(
    432                        "Couldn't find encoding for '"
    433                                + codePointToString(codePoint)
    434                                + "' in "
    435                                + original
    436                );
    437            }
    438
    439            sb.append(nextLetter);
    440
    441            i += Character.charCount(codePoint);
    442        }
    443
    444        return sb.toString();
    445    }
    446
    447    @Override
    448    public boolean equals(final Object obj) {
    449        if (obj == null) {
    450            return false;
    451        }
    452        if (obj == this) {
    453            return true;
    454        }
    455        if (!(obj instanceof AlphabetConverter)) {
    456            return false;
    457        }
    458        final AlphabetConverter other = (AlphabetConverter) obj;
    459        return originalToEncoded.equals(other.originalToEncoded)
    460                && encodedToOriginal.equals(other.encodedToOriginal)
    461                && encodedLetterLength == other.encodedLetterLength;
    462    }
    463
    464    /**
    465     * Gets the length of characters in the encoded alphabet that are necessary
    466     * for each character in the original
    467     * alphabet.
    468     *
    469     * @return The length of the encoded char
    470     */
    471    public int getEncodedCharLength() {
    472        return encodedLetterLength;
    473    }
    474
    475    /**
    476     * Gets the mapping from integer code point of source language to encoded
    477     * string. Use to reconstruct converter from
    478     * serialized map.
    479     *
    480     * @return The original map
    481     */
    482    public Map<Integer, String> getOriginalToEncoded() {
    483        return Collections.unmodifiableMap(originalToEncoded);
    484    }
    485
    486    @Override
    487    public int hashCode() {
    488        return Objects.hash(originalToEncoded,
    489                encodedToOriginal,
    490                encodedLetterLength);
    491    }
    492
    493    @Override
    494    public String toString() {
    495        final StringBuilder sb = new StringBuilder();
    496        // @formatter:off
    497        originalToEncoded.forEach((k, v) ->
    498            sb.append(codePointToString(k))
    499              .append(ARROW)
    500              .append(k)
    501              .append(System.lineSeparator()));
    502        // @formatter:on
    503        return sb.toString();
    504    }
    505}