| // © 2017 and later: Unicode, Inc. and others. |
| // License & terms of use: http://www.unicode.org/copyright.html |
| package com.ibm.icu.impl.number; |
| |
| import java.math.BigDecimal; |
| |
| import com.ibm.icu.impl.StandardPlural; |
| import com.ibm.icu.impl.number.Modifier.Signum; |
| import com.ibm.icu.impl.number.Padder.PadPosition; |
| import com.ibm.icu.number.NumberFormatter.SignDisplay; |
| import com.ibm.icu.text.DecimalFormatSymbols; |
| |
| /** |
| * Assorted utilities relating to decimal formatting pattern strings. |
| */ |
| public class PatternStringUtils { |
| |
| // Note: the order of fields in this enum matters for parsing. |
| public static enum PatternSignType { |
| // Render using normal positive subpattern rules |
| POS, |
| // Render using rules to force the display of a plus sign |
| POS_SIGN, |
| // Render using negative subpattern rules |
| NEG; |
| |
| public static final PatternSignType[] VALUES = PatternSignType.values(); |
| }; |
| |
| /** |
| * Determine whether a given roundingIncrement should be ignored for formatting |
| * based on the current maxFrac value (maximum fraction digits). For example a |
| * roundingIncrement of 0.01 should be ignored if maxFrac is 1, but not if maxFrac |
| * is 2 or more. Note that roundingIncrements are rounded up in significance, so |
| * a roundingIncrement of 0.006 is treated like 0.01 for this determination, i.e. |
| * it should not be ignored if maxFrac is 2 or more (but a roundingIncrement of |
| * 0.005 is treated like 0.001 for significance). |
| * |
| * This test is needed for both NumberPropertyMapper.oldToNew and |
| * PatternStringUtils.propertiesToPatternString, but NumberPropertyMapper |
| * is package-private so we have it here. |
| * |
| * @param roundIncrDec |
| * The roundingIncrement to be checked. Must be non-null. |
| * @param maxFrac |
| * The current maximum fraction digits value. |
| * @return true if roundIncr should be ignored for formatting. |
| */ |
| public static boolean ignoreRoundingIncrement(BigDecimal roundIncrDec, int maxFrac) { |
| double roundIncr = roundIncrDec.doubleValue(); |
| if (roundIncr == 0.0) { |
| return true; |
| } |
| if (maxFrac < 0) { |
| return false; |
| } |
| int frac = 0; |
| roundIncr *= 2.0; // This handles the rounding up of values above e.g. 0.005 or 0.0005 |
| for (frac = 0; frac <= maxFrac && roundIncr <= 1.0; frac++, roundIncr *= 10.0); |
| return (frac > maxFrac); |
| } |
| |
| /** |
| * Creates a pattern string from a property bag. |
| * |
| * <p> |
| * Since pattern strings support only a subset of the functionality available in a property bag, a |
| * new property bag created from the string returned by this function may not be the same as the |
| * original property bag. |
| * |
| * @param properties |
| * The property bag to serialize. |
| * @return A pattern string approximately serializing the property bag. |
| */ |
| public static String propertiesToPatternString(DecimalFormatProperties properties) { |
| StringBuilder sb = new StringBuilder(); |
| |
| // Convenience references |
| // The Math.min() calls prevent DoS |
| int dosMax = 100; |
| int grouping1 = Math.max(0, Math.min(properties.getGroupingSize(), dosMax)); |
| int grouping2 = Math.max(0, Math.min(properties.getSecondaryGroupingSize(), dosMax)); |
| boolean useGrouping = properties.getGroupingUsed(); |
| int paddingWidth = Math.min(properties.getFormatWidth(), dosMax); |
| PadPosition paddingLocation = properties.getPadPosition(); |
| String paddingString = properties.getPadString(); |
| int minInt = Math.max(0, Math.min(properties.getMinimumIntegerDigits(), dosMax)); |
| int maxInt = Math.min(properties.getMaximumIntegerDigits(), dosMax); |
| int minFrac = Math.max(0, Math.min(properties.getMinimumFractionDigits(), dosMax)); |
| int maxFrac = Math.min(properties.getMaximumFractionDigits(), dosMax); |
| int minSig = Math.min(properties.getMinimumSignificantDigits(), dosMax); |
| int maxSig = Math.min(properties.getMaximumSignificantDigits(), dosMax); |
| boolean alwaysShowDecimal = properties.getDecimalSeparatorAlwaysShown(); |
| int exponentDigits = Math.min(properties.getMinimumExponentDigits(), dosMax); |
| boolean exponentShowPlusSign = properties.getExponentSignAlwaysShown(); |
| AffixPatternProvider affixes = PropertiesAffixPatternProvider.forProperties(properties); |
| |
| // Prefixes |
| sb.append(affixes.getString(AffixPatternProvider.FLAG_POS_PREFIX)); |
| int afterPrefixPos = sb.length(); |
| |
| // Figure out the grouping sizes. |
| if (!useGrouping) { |
| grouping1 = 0; |
| grouping2 = 0; |
| } else if (grouping1 == grouping2) { |
| grouping1 = 0; |
| } |
| int groupingLength = grouping1 + grouping2 + 1; |
| |
| // Figure out the digits we need to put in the pattern. |
| BigDecimal roundingInterval = properties.getRoundingIncrement(); |
| StringBuilder digitsString = new StringBuilder(); |
| int digitsStringScale = 0; |
| if (maxSig != Math.min(dosMax, -1)) { |
| // Significant Digits. |
| while (digitsString.length() < minSig) { |
| digitsString.append('@'); |
| } |
| while (digitsString.length() < maxSig) { |
| digitsString.append('#'); |
| } |
| } else if (roundingInterval != null && !ignoreRoundingIncrement(roundingInterval,maxFrac)) { |
| // Rounding Interval. |
| digitsStringScale = -roundingInterval.scale(); |
| // TODO: Check for DoS here? |
| String str = roundingInterval.scaleByPowerOfTen(roundingInterval.scale()).toPlainString(); |
| if (str.charAt(0) == '-') { |
| // TODO: Unsupported operation exception or fail silently? |
| digitsString.append(str, 1, str.length()); |
| } else { |
| digitsString.append(str); |
| } |
| } |
| while (digitsString.length() + digitsStringScale < minInt) { |
| digitsString.insert(0, '0'); |
| } |
| while (-digitsStringScale < minFrac) { |
| digitsString.append('0'); |
| digitsStringScale--; |
| } |
| |
| // Write the digits to the string builder |
| int m0 = Math.max(groupingLength, digitsString.length() + digitsStringScale); |
| m0 = (maxInt != dosMax) ? Math.max(maxInt, m0) - 1 : m0 - 1; |
| int mN = (maxFrac != dosMax) ? Math.min(-maxFrac, digitsStringScale) : digitsStringScale; |
| for (int magnitude = m0; magnitude >= mN; magnitude--) { |
| int di = digitsString.length() + digitsStringScale - magnitude - 1; |
| if (di < 0 || di >= digitsString.length()) { |
| sb.append('#'); |
| } else { |
| sb.append(digitsString.charAt(di)); |
| } |
| // Decimal separator |
| if (magnitude == 0 && (alwaysShowDecimal || mN < 0)) { |
| sb.append('.'); |
| } |
| if (!useGrouping) { |
| continue; |
| } |
| // Least-significant grouping separator |
| if (magnitude > 0 && magnitude == grouping1) { |
| sb.append(','); |
| } |
| // All other grouping separators |
| if (magnitude > grouping1 && grouping2 > 0 && (magnitude - grouping1) % grouping2 == 0) { |
| sb.append(','); |
| } |
| } |
| |
| // Exponential notation |
| if (exponentDigits != Math.min(dosMax, -1)) { |
| sb.append('E'); |
| if (exponentShowPlusSign) { |
| sb.append('+'); |
| } |
| for (int i = 0; i < exponentDigits; i++) { |
| sb.append('0'); |
| } |
| } |
| |
| // Suffixes |
| int beforeSuffixPos = sb.length(); |
| sb.append(affixes.getString(AffixPatternProvider.FLAG_POS_SUFFIX)); |
| |
| // Resolve Padding |
| if (paddingWidth > 0) { |
| while (paddingWidth - sb.length() > 0) { |
| sb.insert(afterPrefixPos, '#'); |
| beforeSuffixPos++; |
| } |
| int addedLength; |
| switch (paddingLocation) { |
| case BEFORE_PREFIX: |
| addedLength = PatternStringUtils.escapePaddingString(paddingString, sb, 0); |
| sb.insert(0, '*'); |
| afterPrefixPos += addedLength + 1; |
| beforeSuffixPos += addedLength + 1; |
| break; |
| case AFTER_PREFIX: |
| addedLength = PatternStringUtils.escapePaddingString(paddingString, sb, afterPrefixPos); |
| sb.insert(afterPrefixPos, '*'); |
| afterPrefixPos += addedLength + 1; |
| beforeSuffixPos += addedLength + 1; |
| break; |
| case BEFORE_SUFFIX: |
| PatternStringUtils.escapePaddingString(paddingString, sb, beforeSuffixPos); |
| sb.insert(beforeSuffixPos, '*'); |
| break; |
| case AFTER_SUFFIX: |
| sb.append('*'); |
| PatternStringUtils.escapePaddingString(paddingString, sb, sb.length()); |
| break; |
| } |
| } |
| |
| // Negative affixes |
| // Ignore if the negative prefix pattern is "-" and the negative suffix is empty |
| if (affixes.hasNegativeSubpattern()) { |
| sb.append(';'); |
| sb.append(affixes.getString(AffixPatternProvider.FLAG_NEG_PREFIX)); |
| // Copy the positive digit format into the negative. |
| // This is optional; the pattern is the same as if '#' were appended here instead. |
| sb.append(sb, afterPrefixPos, beforeSuffixPos); |
| sb.append(affixes.getString(AffixPatternProvider.FLAG_NEG_SUFFIX)); |
| } |
| |
| return sb.toString(); |
| } |
| |
| /** @return The number of chars inserted. */ |
| private static int escapePaddingString(CharSequence input, StringBuilder output, int startIndex) { |
| if (input == null || input.length() == 0) |
| input = Padder.FALLBACK_PADDING_STRING; |
| int startLength = output.length(); |
| if (input.length() == 1) { |
| if (input.equals("'")) { |
| output.insert(startIndex, "''"); |
| } else { |
| output.insert(startIndex, input); |
| } |
| } else { |
| output.insert(startIndex, '\''); |
| int offset = 1; |
| for (int i = 0; i < input.length(); i++) { |
| // it's okay to deal in chars here because the quote mark is the only interesting thing. |
| char ch = input.charAt(i); |
| if (ch == '\'') { |
| output.insert(startIndex + offset, "''"); |
| offset += 2; |
| } else { |
| output.insert(startIndex + offset, ch); |
| offset += 1; |
| } |
| } |
| output.insert(startIndex + offset, '\''); |
| } |
| return output.length() - startLength; |
| } |
| |
| /** |
| * Converts a pattern between standard notation and localized notation. Localized notation means that |
| * instead of using generic placeholders in the pattern, you use the corresponding locale-specific |
| * characters instead. For example, in locale <em>fr-FR</em>, the period in the pattern "0.000" means |
| * "decimal" in standard notation (as it does in every other locale), but it means "grouping" in |
| * localized notation. |
| * |
| * <p> |
| * A greedy string-substitution strategy is used to substitute locale symbols. If two symbols are |
| * ambiguous or have the same prefix, the result is not well-defined. |
| * |
| * <p> |
| * Locale symbols are not allowed to contain the ASCII quote character. |
| * |
| * <p> |
| * This method is provided for backwards compatibility and should not be used in any new code. |
| * |
| * @param input |
| * The pattern to convert. |
| * @param symbols |
| * The symbols corresponding to the localized pattern. |
| * @param toLocalized |
| * true to convert from standard to localized notation; false to convert from localized to |
| * standard notation. |
| * @return The pattern expressed in the other notation. |
| */ |
| public static String convertLocalized( |
| String input, |
| DecimalFormatSymbols symbols, |
| boolean toLocalized) { |
| if (input == null) |
| return null; |
| |
| // Construct a table of strings to be converted between localized and standard. |
| String[][] table = new String[21][2]; |
| int standIdx = toLocalized ? 0 : 1; |
| int localIdx = toLocalized ? 1 : 0; |
| table[0][standIdx] = "%"; |
| table[0][localIdx] = symbols.getPercentString(); |
| table[1][standIdx] = "‰"; |
| table[1][localIdx] = symbols.getPerMillString(); |
| table[2][standIdx] = "."; |
| table[2][localIdx] = symbols.getDecimalSeparatorString(); |
| table[3][standIdx] = ","; |
| table[3][localIdx] = symbols.getGroupingSeparatorString(); |
| table[4][standIdx] = "-"; |
| table[4][localIdx] = symbols.getMinusSignString(); |
| table[5][standIdx] = "+"; |
| table[5][localIdx] = symbols.getPlusSignString(); |
| table[6][standIdx] = ";"; |
| table[6][localIdx] = Character.toString(symbols.getPatternSeparator()); |
| table[7][standIdx] = "@"; |
| table[7][localIdx] = Character.toString(symbols.getSignificantDigit()); |
| table[8][standIdx] = "E"; |
| table[8][localIdx] = symbols.getExponentSeparator(); |
| table[9][standIdx] = "*"; |
| table[9][localIdx] = Character.toString(symbols.getPadEscape()); |
| table[10][standIdx] = "#"; |
| table[10][localIdx] = Character.toString(symbols.getDigit()); |
| for (int i = 0; i < 10; i++) { |
| table[11 + i][standIdx] = Character.toString((char) ('0' + i)); |
| table[11 + i][localIdx] = symbols.getDigitStringsLocal()[i]; |
| } |
| |
| // Special case: quotes are NOT allowed to be in any localIdx strings. |
| // Substitute them with '’' instead. |
| for (int i = 0; i < table.length; i++) { |
| table[i][localIdx] = table[i][localIdx].replace('\'', '’'); |
| } |
| |
| // Iterate through the string and convert. |
| // State table: |
| // 0 => base state |
| // 1 => first char inside a quoted sequence in input and output string |
| // 2 => inside a quoted sequence in input and output string |
| // 3 => first char after a close quote in input string; |
| // close quote still needs to be written to output string |
| // 4 => base state in input string; inside quoted sequence in output string |
| // 5 => first char inside a quoted sequence in input string; |
| // inside quoted sequence in output string |
| StringBuilder result = new StringBuilder(); |
| int state = 0; |
| outer: for (int offset = 0; offset < input.length(); offset++) { |
| char ch = input.charAt(offset); |
| |
| // Handle a quote character (state shift) |
| if (ch == '\'') { |
| if (state == 0) { |
| result.append('\''); |
| state = 1; |
| continue; |
| } else if (state == 1) { |
| result.append('\''); |
| state = 0; |
| continue; |
| } else if (state == 2) { |
| state = 3; |
| continue; |
| } else if (state == 3) { |
| result.append('\''); |
| result.append('\''); |
| state = 1; |
| continue; |
| } else if (state == 4) { |
| state = 5; |
| continue; |
| } else { |
| assert state == 5; |
| result.append('\''); |
| result.append('\''); |
| state = 4; |
| continue; |
| } |
| } |
| |
| if (state == 0 || state == 3 || state == 4) { |
| for (String[] pair : table) { |
| // Perform a greedy match on this symbol string |
| if (input.regionMatches(offset, pair[0], 0, pair[0].length())) { |
| // Skip ahead past this region for the next iteration |
| offset += pair[0].length() - 1; |
| if (state == 3 || state == 4) { |
| result.append('\''); |
| state = 0; |
| } |
| result.append(pair[1]); |
| continue outer; |
| } |
| } |
| // No replacement found. Check if a special quote is necessary |
| for (String[] pair : table) { |
| if (input.regionMatches(offset, pair[1], 0, pair[1].length())) { |
| if (state == 0) { |
| result.append('\''); |
| state = 4; |
| } |
| result.append(ch); |
| continue outer; |
| } |
| } |
| // Still nothing. Copy the char verbatim. (Add a close quote if necessary) |
| if (state == 3 || state == 4) { |
| result.append('\''); |
| state = 0; |
| } |
| result.append(ch); |
| } else { |
| assert state == 1 || state == 2 || state == 5; |
| result.append(ch); |
| state = 2; |
| } |
| } |
| // Resolve final quotes |
| if (state == 3 || state == 4) { |
| result.append('\''); |
| state = 0; |
| } |
| if (state != 0) { |
| throw new IllegalArgumentException("Malformed localized pattern: unterminated quote"); |
| } |
| return result.toString(); |
| } |
| |
| /** |
| * This method contains the heart of the logic for rendering LDML affix strings. It handles |
| * sign-always-shown resolution, whether to use the positive or negative subpattern, permille |
| * substitution, and plural forms for CurrencyPluralInfo. |
| */ |
| public static void patternInfoToStringBuilder( |
| AffixPatternProvider patternInfo, |
| boolean isPrefix, |
| PatternSignType patternSignType, |
| StandardPlural plural, |
| boolean perMilleReplacesPercent, |
| StringBuilder output) { |
| |
| boolean plusReplacesMinusSign = (patternSignType == PatternSignType.POS_SIGN) |
| && !patternInfo.positiveHasPlusSign(); |
| |
| // Should we use the affix from the negative subpattern? |
| // (If not, we will use the positive subpattern.) |
| boolean useNegativeAffixPattern = patternInfo.hasNegativeSubpattern() |
| && (patternSignType == PatternSignType.NEG |
| || (patternInfo.negativeHasMinusSign() && plusReplacesMinusSign)); |
| |
| // Resolve the flags for the affix pattern. |
| int flags = 0; |
| if (useNegativeAffixPattern) { |
| flags |= AffixPatternProvider.Flags.NEGATIVE_SUBPATTERN; |
| } |
| if (isPrefix) { |
| flags |= AffixPatternProvider.Flags.PREFIX; |
| } |
| if (plural != null) { |
| assert plural.ordinal() == (AffixPatternProvider.Flags.PLURAL_MASK & plural.ordinal()); |
| flags |= plural.ordinal(); |
| } |
| |
| // Should we prepend a sign to the pattern? |
| boolean prependSign; |
| if (!isPrefix || useNegativeAffixPattern) { |
| prependSign = false; |
| } else if (patternSignType == PatternSignType.NEG) { |
| prependSign = true; |
| } else { |
| prependSign = plusReplacesMinusSign; |
| } |
| |
| // Compute the length of the affix pattern. |
| int length = patternInfo.length(flags) + (prependSign ? 1 : 0); |
| |
| // Finally, set the result into the StringBuilder. |
| output.setLength(0); |
| for (int index = 0; index < length; index++) { |
| char candidate; |
| if (prependSign && index == 0) { |
| candidate = '-'; |
| } else if (prependSign) { |
| candidate = patternInfo.charAt(flags, index - 1); |
| } else { |
| candidate = patternInfo.charAt(flags, index); |
| } |
| if (plusReplacesMinusSign && candidate == '-') { |
| candidate = '+'; |
| } |
| if (perMilleReplacesPercent && candidate == '%') { |
| candidate = '‰'; |
| } |
| output.append(candidate); |
| } |
| } |
| |
| public static PatternSignType resolveSignDisplay(SignDisplay signDisplay, Signum signum) { |
| switch (signDisplay) { |
| case AUTO: |
| case ACCOUNTING: |
| switch (signum) { |
| case NEG: |
| case NEG_ZERO: |
| return PatternSignType.NEG; |
| case POS_ZERO: |
| case POS: |
| return PatternSignType.POS; |
| } |
| break; |
| |
| case ALWAYS: |
| case ACCOUNTING_ALWAYS: |
| switch (signum) { |
| case NEG: |
| case NEG_ZERO: |
| return PatternSignType.NEG; |
| case POS_ZERO: |
| case POS: |
| return PatternSignType.POS_SIGN; |
| } |
| break; |
| |
| case EXCEPT_ZERO: |
| case ACCOUNTING_EXCEPT_ZERO: |
| switch (signum) { |
| case NEG: |
| return PatternSignType.NEG; |
| case NEG_ZERO: |
| case POS_ZERO: |
| return PatternSignType.POS; |
| case POS: |
| return PatternSignType.POS_SIGN; |
| } |
| break; |
| |
| case NEGATIVE: |
| case ACCOUNTING_NEGATIVE: |
| switch (signum) { |
| case NEG: |
| return PatternSignType.NEG; |
| case NEG_ZERO: |
| case POS_ZERO: |
| case POS: |
| return PatternSignType.POS; |
| } |
| break; |
| |
| case NEVER: |
| return PatternSignType.POS; |
| |
| default: |
| break; |
| } |
| |
| throw new AssertionError("Unreachable"); |
| } |
| |
| } |