You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@commons.apache.org by GitBox <gi...@apache.org> on 2021/07/06 03:18:41 UTC

[GitHub] [commons-text] darkma773r opened a new pull request #248: TEXT-207: adding DoubleFormat utility

darkma773r opened a new pull request #248:
URL: https://github.com/apache/commons-text/pull/248


   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] kinow commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
kinow commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r667413903



##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,732 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>

Review comment:
       Maybe before the examples, besides talking about this implementation & `DecimalFormat`, and thread-safety, also include a one-sentence long description for locale?
   
   The examples contain an example with a `locale` variable, and some javadocs in the other class mention locale & thousands separators I think. Maybe worth describing how/if we will handle it too?

##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,725 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in

Review comment:
       I normally use {@code true} to change the formatting in Javadoc, but not really important here.

##########
File path: pom.xml
##########
@@ -116,6 +119,22 @@
       <version>${graalvm.version}</version>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-rng-simple</artifactId>
+      <version>${commons.rng.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.openjdk.jmh</groupId>
+      <artifactId>jmh-core</artifactId>
+      <version>${jmh.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>org.openjdk.jmh</groupId>
+      <artifactId>jmh-generator-annprocess</artifactId>
+      <version>${jmh.version}</version>

Review comment:
       Ditto this new dependency too?

##########
File path: pom.xml
##########
@@ -116,6 +119,22 @@
       <version>${graalvm.version}</version>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-rng-simple</artifactId>
+      <version>${commons.rng.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.openjdk.jmh</groupId>
+      <artifactId>jmh-core</artifactId>
+      <version>${jmh.version}</version>

Review comment:
       Shouldn't it be scoped to test too? Or moved to the other profile perhaps?

##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,725 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;

Review comment:
       I think in Imaging I got SpotBug warnings now for storing arrays directly (will check later whether that could affect text if we update spotbugs, or if we have an exclusion for that, or if CI is not running it, etc)




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] coveralls edited a comment on pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
coveralls edited a comment on pull request #248:
URL: https://github.com/apache/commons-text/pull/248#issuecomment-875718096


   
   [![Coverage Status](https://coveralls.io/builds/41453863/badge)](https://coveralls.io/builds/41453863)
   
   Coverage increased (+0.1%) to 98.077% when pulling **ff372c2dd5400c636433ac1d40c885692f7195ac on darkma773r:text-207-double-format** into **23a17acd1fa408b41988866e3df480e9c89b4903 on apache:master**.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r664642525



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,715 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int resultExponent = digitCount + exponent - decimalPos;
+
+        // only include the exponent string if it is non-zero or it is requested to be present
+        final char[] resultExponentChars = resultExponent != 0 || opts.getAlwaysIncludeExponent()
+                ? Integer.toString(Math.abs(resultExponent)).toCharArray()
+                : null;
+
+        prepareOutput(getScientificStringSize(decimalPos, resultExponent, resultExponentChars, opts));
+
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (resultExponentChars != null) {
+            append(opts.getExponentSeparatorChars());
+
+            if (resultExponent < 0) {
+                append(opts.getMinusSign());
+            }
+
+            final char[] localizedDigits = opts.getDigits();
+            for (int i = 0; i < resultExponentChars.length; ++i) {
+                final int val = digitValue(resultExponentChars[i]);
+                appendLocalizedDigit(val, localizedDigits);
+            }
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return true if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index
+     * @param opts format options
+     */
+    private void appendFraction(final int zeroCount, final int startIdx, final FormatOptions opts) {
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        if (startIdx < digitCount) {
+            append(opts.getDecimalSeparator());
+
+            // add the zero prefix
+            for (int i = 0; i < zeroCount; ++i) {
+                append(localizedZero);
+            }
+
+            // add the fraction digits
+            for (int i = startIdx; i < digitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+        } else if (opts.getIncludeFractionPlaceholder()) {
+            append(opts.getDecimalSeparator());
+            append(localizedZero);
+        }
+    }
+
+    /** Get the number of characters required to create a plain format representation
+     * of this value.
+     * @param decimalPos decimal position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters in the plain string representation of this value,
+     *      created using the given parameters
+     */
+    private int getPlainStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+
+        // adjust for groupings if needed
+        if (opts.getGroupThousands() && decimalPos > 0) {
+            size += (decimalPos - 1) / THOUSANDS_GROUP_SIZE;
+        }
+
+        return size;
+    }
+
+    /** Get the number of characters required to create a scientific format representation
+     * of this value.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param resultExponent target exponent
+     * @param resultExponentChars target exponent characters; if null, no explicit exponent is to be included
+     * @param opts format options
+     * @return number of characters in the scientific representation of this string, using the
+     *      given parameters
+     */
+    private int getScientificStringSize(final int decimalPos, final int resultExponent,
+            final char[] resultExponentChars, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+        if (resultExponentChars != null) {
+            size += opts.getExponentSeparatorChars().length + resultExponentChars.length;
+            if (resultExponent < 0) {
+                // adjust for the exponent minus sign
+                ++size;
+            }
+        }
+        return size;
+    }
+
+    /** Get the number of characters required for the digit portion of a string representation of
+     * this value. This excludes any exponent or thousands groupings characters.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters required for the digit portion of a string representation of
+     *      this value
+     */
+    private int getDigitStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = digitCount;
+        if (shouldIncludeMinus(opts)) {
+            ++size;
+        }
+        if (decimalPos < 1) {
+            // no whole component;
+            // add decimal point and leading zeros
+            size += 2 + Math.abs(decimalPos);
+        } else if (decimalPos >= digitCount) {
+            // no fraction component;
+            // add trailing zeros
+            size += decimalPos - digitCount;
+            if (opts.getIncludeFractionPlaceholder()) {
+                size += 2;
+            }
+        } else {
+            // whole and fraction components;
+            // add decimal point
+            size += 1;
+        }
+
+        return size;
+    }
+
+
+    /** Return true if formatted strings should include the minus sign, considering
+     * the value of this instance and the given format options.
+     * @param opts format options
+     * @return true if a minus sign should be included in the output
+     */
+    private boolean shouldIncludeMinus(final FormatOptions opts) {
+        return negative && (opts.getSignedZero() || !isZero());
+    }
+
+    /** Return true if a rounding operation for the given number of digits should
+     * round up.
+     * @param count number of digits to round to; must be greater than zero and less
+     *      than the current number of digits
+     * @return true if a rounding operation for the given number of digits should
+     *      round up
+     */
+    private boolean shouldRoundUp(final int count) {
+        // Round up in the following cases:
+        // 1. The digit after the last digit is greater than 5.
+        // 2. The digit after the last digit is 5 and there are additional (non-zero)
+        //      digits after it.
+        // 3. The digit after the last digit is 5, there are no additional digits afterward,
+        //      and the last digit is odd (half-even rounding).
+        final int digitAfterLast = digits[count];
+
+        return digitAfterLast > ROUND_CENTER || (digitAfterLast == ROUND_CENTER
+                && (count < digitCount - 1 || (digits[count - 1] % 2) != 0));
+    }
+
+    /** Round the value up to the given number of digits.
+     * @param count target number of digits; must be greater than zero and
+     *      less than the current number of digits
+     */
+    private void roundUp(final int count) {
+        int removedDigits = digitCount - count;
+        int i;
+        for (i = count - 1; i >= 0; --i) {
+            final int d = digits[i] + 1;
+
+            if (d < DECIMAL_RADIX) {
+                // value did not carry over; done adding
+                digits[i] = d;
+                break;
+            } else {
+                // value carried over; the current position is 0
+                // which we will ignore by shortening the digit count
+                ++removedDigits;
+            }
+        }
+
+        if (i < 0) {
+            // all values carried over
+            setSingleDigitValue(1, exponent + removedDigits);
+        } else {
+            // values were updated in-place; just need to update the length
+            truncate(digitCount - removedDigits);
+        }
+    }
+
+    /** Return true if this value is equal to zero. The sign field is ignored,
+     * meaning that this method will return true for both {@code +0} and {@code -0}.
+     * @return true if the value is equal to zero
+     */
+    boolean isZero() {
+        return digits[0] == 0;
+    }
+
+    /** Set the value of this instance to a single digit with the given exponent.
+     * The sign of the value is retained.
+     * @param digit digit value
+     * @param newExponent new exponent value
+     */
+    private void setSingleDigitValue(final int digit, final int newExponent) {
+        digits[0] = digit;
+        digitCount = 1;
+        exponent = newExponent;
+    }
+
+    /** Truncate the value to the given number of digits.
+     * @param count number of digits; must be greater than zero and less than
+     *      the current number of digits
+     */
+    private void truncate(final int count) {
+        int nonZeroCount = count;
+        for (int i = count - 1;
+                i >= 0 && digits[i] == 0;
+                --i) {
+            --nonZeroCount;
+        }
+        exponent += digitCount - nonZeroCount;
+        digitCount = nonZeroCount;
+    }
+
+    /** Construct a new instance from the given double value.
+     * @param d double value
+     * @return a new instance containing the parsed components of the given double value
+     * @throws IllegalArgumentException if {@code d} is {@code NaN} or infinite
+     */
+    public static ParsedDecimal from(final double d) {
+        if (!Double.isFinite(d)) {
+            throw new IllegalArgumentException("Double is not finite");
+        }
+
+        // Get the canonical string representation of the double value and parse
+        // it to extract the components of the decimal value. From the documentation
+        // of Double.toString() and the fact that d is finite, we are guaranteed the
+        // following:
+        // - the string will not be empty
+        // - it will contain exactly one decimal point character
+        // - all digit characters are in the ASCII range
+        final char[] strChars = Double.toString(d).toCharArray();
+
+        final boolean negative = strChars[0] == MINUS_CHAR;
+        final int digitStartIdx = negative ? 1 : 0;
+
+        final int[] digits = new int[strChars.length];
+
+        boolean foundDecimalPoint = false;
+        int digitCount = 0;
+        int significantDigitCount = 0;
+        int decimalPos = 0;
+
+        int i;
+        for (i = digitStartIdx; i < strChars.length; ++i) {
+            final char ch = strChars[i];
+
+            if (ch == DECIMAL_SEP_CHAR) {
+                foundDecimalPoint = true;
+                decimalPos = digitCount;
+            } else if (ch == EXPONENT_CHAR) {
+                // no more mantissa digits
+                break;
+            } else if (ch != ZERO_CHAR || digitCount > 0) {
+                // this is either the first non-zero digit or one after it
+                final int val = digitValue(ch);
+                digits[digitCount++] = val;
+
+                if (val > 0) {
+                    significantDigitCount = digitCount;
+                }
+            } else if (foundDecimalPoint) {
+                // leading zero in a fraction; adjust the decimal position
+                --decimalPos;
+            }
+        }
+
+        if (digitCount > 0) {
+            // determine the exponent
+            final int explicitExponent = i < strChars.length
+                    ? parseExponent(strChars, i + 1)
+                    : 0;
+            final int exponent = explicitExponent + decimalPos - significantDigitCount;
+
+            return new ParsedDecimal(negative, digits, significantDigitCount, exponent);
+        }
+
+        // no non-zero digits, so value is zero
+        return new ParsedDecimal(negative, new int[] {0}, 1, 0);

Review comment:
       I made `ParsedDecimal` instances mutable so that rounding operations can modify the digit array in place instead of creating a copy. So, I don't see how a singleton would work here.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] coveralls edited a comment on pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
coveralls edited a comment on pull request #248:
URL: https://github.com/apache/commons-text/pull/248#issuecomment-875718096


   
   [![Coverage Status](https://coveralls.io/builds/41275148/badge)](https://coveralls.io/builds/41275148)
   
   Coverage increased (+0.1%) to 98.078% when pulling **c97695dbfed978ef70e950ab479eb77e2218b2e4 on darkma773r:text-207-double-format** into **0ec85b01b043e0d838e706d14b0cd1d21dc48d54 on apache:master**.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r671600155



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,724 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return {@code true} if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return {@code true} if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return {@code true} if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return {@code true} if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int targetExponent = digitCount + exponent - decimalPos;
+        final int absTargetExponent = Math.abs(targetExponent);
+        final boolean includeExponent = shouldIncludeExponent(targetExponent, opts);
+        final boolean negativeExponent = targetExponent < 0;
+
+        // determine the size of the full formatted string, including the number of
+        // characters needed for the exponent digits
+        int size = getDigitStringSize(decimalPos, opts);
+        int exponentDigitCount = 0;
+        if (includeExponent) {
+            exponentDigitCount = absTargetExponent > 0
+                    ? (int) Math.floor(Math.log10(absTargetExponent)) + 1
+                    : 1;
+
+            size += opts.getExponentSeparatorChars().length + exponentDigitCount;
+            if (negativeExponent) {
+                ++size;
+            }
+        }
+
+        prepareOutput(size);
+
+        // append the portion before the exponent field
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (includeExponent) {
+            // append the exponent field
+            append(opts.getExponentSeparatorChars());
+
+            if (negativeExponent) {
+                append(opts.getMinusSign());
+            }
+
+            // append the exponent digits themselves; compute the
+            // string representation directly and add it to the output
+            // buffer to avoid the overhead of Integer.toString()
+            final char[] localizedDigits = opts.getDigits();
+            int rem = absTargetExponent;
+            for (int i = size - 1; i >= outputIdx; --i) {
+                outputChars[i] = localizedDigits[rem % DECIMAL_RADIX];
+                rem /= DECIMAL_RADIX;
+            }
+            outputIdx = size;
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }

Review comment:
       That's more readable. I'll update it.

##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,724 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return {@code true} if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return {@code true} if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return {@code true} if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return {@code true} if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int targetExponent = digitCount + exponent - decimalPos;
+        final int absTargetExponent = Math.abs(targetExponent);
+        final boolean includeExponent = shouldIncludeExponent(targetExponent, opts);
+        final boolean negativeExponent = targetExponent < 0;
+
+        // determine the size of the full formatted string, including the number of
+        // characters needed for the exponent digits
+        int size = getDigitStringSize(decimalPos, opts);
+        int exponentDigitCount = 0;
+        if (includeExponent) {
+            exponentDigitCount = absTargetExponent > 0
+                    ? (int) Math.floor(Math.log10(absTargetExponent)) + 1
+                    : 1;
+
+            size += opts.getExponentSeparatorChars().length + exponentDigitCount;
+            if (negativeExponent) {
+                ++size;
+            }
+        }
+
+        prepareOutput(size);
+
+        // append the portion before the exponent field
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (includeExponent) {
+            // append the exponent field
+            append(opts.getExponentSeparatorChars());
+
+            if (negativeExponent) {
+                append(opts.getMinusSign());
+            }
+
+            // append the exponent digits themselves; compute the
+            // string representation directly and add it to the output
+            // buffer to avoid the overhead of Integer.toString()
+            final char[] localizedDigits = opts.getDigits();
+            int rem = absTargetExponent;
+            for (int i = size - 1; i >= outputIdx; --i) {
+                outputChars[i] = localizedDigits[rem % DECIMAL_RADIX];
+                rem /= DECIMAL_RADIX;
+            }
+            outputIdx = size;
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return {@code true} if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index

Review comment:
       Fixed.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r664674087



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,715 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int resultExponent = digitCount + exponent - decimalPos;
+
+        // only include the exponent string if it is non-zero or it is requested to be present
+        final char[] resultExponentChars = resultExponent != 0 || opts.getAlwaysIncludeExponent()
+                ? Integer.toString(Math.abs(resultExponent)).toCharArray()
+                : null;
+
+        prepareOutput(getScientificStringSize(decimalPos, resultExponent, resultExponentChars, opts));
+
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (resultExponentChars != null) {
+            append(opts.getExponentSeparatorChars());
+
+            if (resultExponent < 0) {
+                append(opts.getMinusSign());
+            }
+
+            final char[] localizedDigits = opts.getDigits();
+            for (int i = 0; i < resultExponentChars.length; ++i) {
+                final int val = digitValue(resultExponentChars[i]);
+                appendLocalizedDigit(val, localizedDigits);
+            }
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return true if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index
+     * @param opts format options
+     */
+    private void appendFraction(final int zeroCount, final int startIdx, final FormatOptions opts) {
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        if (startIdx < digitCount) {
+            append(opts.getDecimalSeparator());
+
+            // add the zero prefix
+            for (int i = 0; i < zeroCount; ++i) {
+                append(localizedZero);
+            }
+
+            // add the fraction digits
+            for (int i = startIdx; i < digitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+        } else if (opts.getIncludeFractionPlaceholder()) {
+            append(opts.getDecimalSeparator());
+            append(localizedZero);
+        }
+    }
+
+    /** Get the number of characters required to create a plain format representation
+     * of this value.
+     * @param decimalPos decimal position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters in the plain string representation of this value,
+     *      created using the given parameters
+     */
+    private int getPlainStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+
+        // adjust for groupings if needed
+        if (opts.getGroupThousands() && decimalPos > 0) {
+            size += (decimalPos - 1) / THOUSANDS_GROUP_SIZE;
+        }
+
+        return size;
+    }
+
+    /** Get the number of characters required to create a scientific format representation
+     * of this value.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param resultExponent target exponent
+     * @param resultExponentChars target exponent characters; if null, no explicit exponent is to be included
+     * @param opts format options
+     * @return number of characters in the scientific representation of this string, using the
+     *      given parameters
+     */
+    private int getScientificStringSize(final int decimalPos, final int resultExponent,
+            final char[] resultExponentChars, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+        if (resultExponentChars != null) {
+            size += opts.getExponentSeparatorChars().length + resultExponentChars.length;
+            if (resultExponent < 0) {
+                // adjust for the exponent minus sign
+                ++size;
+            }
+        }
+        return size;
+    }
+
+    /** Get the number of characters required for the digit portion of a string representation of
+     * this value. This excludes any exponent or thousands groupings characters.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters required for the digit portion of a string representation of
+     *      this value
+     */
+    private int getDigitStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = digitCount;
+        if (shouldIncludeMinus(opts)) {
+            ++size;
+        }
+        if (decimalPos < 1) {
+            // no whole component;
+            // add decimal point and leading zeros
+            size += 2 + Math.abs(decimalPos);
+        } else if (decimalPos >= digitCount) {
+            // no fraction component;
+            // add trailing zeros
+            size += decimalPos - digitCount;
+            if (opts.getIncludeFractionPlaceholder()) {
+                size += 2;
+            }
+        } else {
+            // whole and fraction components;
+            // add decimal point
+            size += 1;
+        }
+
+        return size;
+    }
+
+
+    /** Return true if formatted strings should include the minus sign, considering
+     * the value of this instance and the given format options.
+     * @param opts format options
+     * @return true if a minus sign should be included in the output
+     */
+    private boolean shouldIncludeMinus(final FormatOptions opts) {
+        return negative && (opts.getSignedZero() || !isZero());
+    }
+
+    /** Return true if a rounding operation for the given number of digits should
+     * round up.
+     * @param count number of digits to round to; must be greater than zero and less
+     *      than the current number of digits
+     * @return true if a rounding operation for the given number of digits should
+     *      round up
+     */
+    private boolean shouldRoundUp(final int count) {
+        // Round up in the following cases:
+        // 1. The digit after the last digit is greater than 5.
+        // 2. The digit after the last digit is 5 and there are additional (non-zero)
+        //      digits after it.
+        // 3. The digit after the last digit is 5, there are no additional digits afterward,
+        //      and the last digit is odd (half-even rounding).
+        final int digitAfterLast = digits[count];
+
+        return digitAfterLast > ROUND_CENTER || (digitAfterLast == ROUND_CENTER
+                && (count < digitCount - 1 || (digits[count - 1] % 2) != 0));
+    }
+
+    /** Round the value up to the given number of digits.
+     * @param count target number of digits; must be greater than zero and
+     *      less than the current number of digits
+     */
+    private void roundUp(final int count) {
+        int removedDigits = digitCount - count;
+        int i;
+        for (i = count - 1; i >= 0; --i) {
+            final int d = digits[i] + 1;
+
+            if (d < DECIMAL_RADIX) {
+                // value did not carry over; done adding
+                digits[i] = d;
+                break;
+            } else {
+                // value carried over; the current position is 0
+                // which we will ignore by shortening the digit count
+                ++removedDigits;
+            }
+        }
+
+        if (i < 0) {
+            // all values carried over
+            setSingleDigitValue(1, exponent + removedDigits);
+        } else {
+            // values were updated in-place; just need to update the length
+            truncate(digitCount - removedDigits);
+        }
+    }
+
+    /** Return true if this value is equal to zero. The sign field is ignored,
+     * meaning that this method will return true for both {@code +0} and {@code -0}.
+     * @return true if the value is equal to zero
+     */
+    boolean isZero() {
+        return digits[0] == 0;
+    }
+
+    /** Set the value of this instance to a single digit with the given exponent.
+     * The sign of the value is retained.
+     * @param digit digit value
+     * @param newExponent new exponent value
+     */
+    private void setSingleDigitValue(final int digit, final int newExponent) {
+        digits[0] = digit;
+        digitCount = 1;
+        exponent = newExponent;
+    }
+
+    /** Truncate the value to the given number of digits.
+     * @param count number of digits; must be greater than zero and less than
+     *      the current number of digits
+     */
+    private void truncate(final int count) {
+        int nonZeroCount = count;
+        for (int i = count - 1;
+                i >= 0 && digits[i] == 0;
+                --i) {
+            --nonZeroCount;
+        }
+        exponent += digitCount - nonZeroCount;
+        digitCount = nonZeroCount;
+    }
+
+    /** Construct a new instance from the given double value.
+     * @param d double value
+     * @return a new instance containing the parsed components of the given double value
+     * @throws IllegalArgumentException if {@code d} is {@code NaN} or infinite
+     */
+    public static ParsedDecimal from(final double d) {
+        if (!Double.isFinite(d)) {
+            throw new IllegalArgumentException("Double is not finite");
+        }
+
+        // Get the canonical string representation of the double value and parse
+        // it to extract the components of the decimal value. From the documentation
+        // of Double.toString() and the fact that d is finite, we are guaranteed the
+        // following:
+        // - the string will not be empty
+        // - it will contain exactly one decimal point character
+        // - all digit characters are in the ASCII range
+        final char[] strChars = Double.toString(d).toCharArray();
+
+        final boolean negative = strChars[0] == MINUS_CHAR;
+        final int digitStartIdx = negative ? 1 : 0;
+
+        final int[] digits = new int[strChars.length];

Review comment:
       True. I can also subtract one for the decimal point.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] aherbert commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
aherbert commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r664479645



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,715 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int resultExponent = digitCount + exponent - decimalPos;
+
+        // only include the exponent string if it is non-zero or it is requested to be present
+        final char[] resultExponentChars = resultExponent != 0 || opts.getAlwaysIncludeExponent()
+                ? Integer.toString(Math.abs(resultExponent)).toCharArray()
+                : null;
+
+        prepareOutput(getScientificStringSize(decimalPos, resultExponent, resultExponentChars, opts));
+
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (resultExponentChars != null) {
+            append(opts.getExponentSeparatorChars());
+
+            if (resultExponent < 0) {
+                append(opts.getMinusSign());
+            }
+
+            final char[] localizedDigits = opts.getDigits();
+            for (int i = 0; i < resultExponentChars.length; ++i) {
+                final int val = digitValue(resultExponentChars[i]);
+                appendLocalizedDigit(val, localizedDigits);
+            }
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return true if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index
+     * @param opts format options
+     */
+    private void appendFraction(final int zeroCount, final int startIdx, final FormatOptions opts) {
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        if (startIdx < digitCount) {
+            append(opts.getDecimalSeparator());
+
+            // add the zero prefix
+            for (int i = 0; i < zeroCount; ++i) {
+                append(localizedZero);
+            }
+
+            // add the fraction digits
+            for (int i = startIdx; i < digitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+        } else if (opts.getIncludeFractionPlaceholder()) {
+            append(opts.getDecimalSeparator());
+            append(localizedZero);
+        }
+    }
+
+    /** Get the number of characters required to create a plain format representation
+     * of this value.
+     * @param decimalPos decimal position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters in the plain string representation of this value,
+     *      created using the given parameters
+     */
+    private int getPlainStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+
+        // adjust for groupings if needed
+        if (opts.getGroupThousands() && decimalPos > 0) {
+            size += (decimalPos - 1) / THOUSANDS_GROUP_SIZE;
+        }
+
+        return size;
+    }
+
+    /** Get the number of characters required to create a scientific format representation
+     * of this value.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param resultExponent target exponent
+     * @param resultExponentChars target exponent characters; if null, no explicit exponent is to be included
+     * @param opts format options
+     * @return number of characters in the scientific representation of this string, using the
+     *      given parameters
+     */
+    private int getScientificStringSize(final int decimalPos, final int resultExponent,
+            final char[] resultExponentChars, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+        if (resultExponentChars != null) {
+            size += opts.getExponentSeparatorChars().length + resultExponentChars.length;
+            if (resultExponent < 0) {
+                // adjust for the exponent minus sign
+                ++size;
+            }
+        }
+        return size;
+    }
+
+    /** Get the number of characters required for the digit portion of a string representation of
+     * this value. This excludes any exponent or thousands groupings characters.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters required for the digit portion of a string representation of
+     *      this value
+     */
+    private int getDigitStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = digitCount;
+        if (shouldIncludeMinus(opts)) {
+            ++size;
+        }
+        if (decimalPos < 1) {
+            // no whole component;
+            // add decimal point and leading zeros
+            size += 2 + Math.abs(decimalPos);
+        } else if (decimalPos >= digitCount) {
+            // no fraction component;
+            // add trailing zeros
+            size += decimalPos - digitCount;
+            if (opts.getIncludeFractionPlaceholder()) {
+                size += 2;
+            }
+        } else {
+            // whole and fraction components;
+            // add decimal point
+            size += 1;
+        }
+
+        return size;
+    }
+
+
+    /** Return true if formatted strings should include the minus sign, considering
+     * the value of this instance and the given format options.
+     * @param opts format options
+     * @return true if a minus sign should be included in the output
+     */
+    private boolean shouldIncludeMinus(final FormatOptions opts) {
+        return negative && (opts.getSignedZero() || !isZero());
+    }
+
+    /** Return true if a rounding operation for the given number of digits should
+     * round up.
+     * @param count number of digits to round to; must be greater than zero and less
+     *      than the current number of digits
+     * @return true if a rounding operation for the given number of digits should
+     *      round up
+     */
+    private boolean shouldRoundUp(final int count) {
+        // Round up in the following cases:
+        // 1. The digit after the last digit is greater than 5.
+        // 2. The digit after the last digit is 5 and there are additional (non-zero)
+        //      digits after it.
+        // 3. The digit after the last digit is 5, there are no additional digits afterward,
+        //      and the last digit is odd (half-even rounding).
+        final int digitAfterLast = digits[count];
+
+        return digitAfterLast > ROUND_CENTER || (digitAfterLast == ROUND_CENTER
+                && (count < digitCount - 1 || (digits[count - 1] % 2) != 0));
+    }
+
+    /** Round the value up to the given number of digits.
+     * @param count target number of digits; must be greater than zero and
+     *      less than the current number of digits
+     */
+    private void roundUp(final int count) {
+        int removedDigits = digitCount - count;
+        int i;
+        for (i = count - 1; i >= 0; --i) {
+            final int d = digits[i] + 1;
+
+            if (d < DECIMAL_RADIX) {
+                // value did not carry over; done adding
+                digits[i] = d;
+                break;
+            } else {
+                // value carried over; the current position is 0
+                // which we will ignore by shortening the digit count
+                ++removedDigits;
+            }
+        }
+
+        if (i < 0) {
+            // all values carried over
+            setSingleDigitValue(1, exponent + removedDigits);
+        } else {
+            // values were updated in-place; just need to update the length
+            truncate(digitCount - removedDigits);
+        }
+    }
+
+    /** Return true if this value is equal to zero. The sign field is ignored,
+     * meaning that this method will return true for both {@code +0} and {@code -0}.
+     * @return true if the value is equal to zero
+     */
+    boolean isZero() {
+        return digits[0] == 0;
+    }
+
+    /** Set the value of this instance to a single digit with the given exponent.
+     * The sign of the value is retained.
+     * @param digit digit value
+     * @param newExponent new exponent value
+     */
+    private void setSingleDigitValue(final int digit, final int newExponent) {
+        digits[0] = digit;
+        digitCount = 1;
+        exponent = newExponent;
+    }
+
+    /** Truncate the value to the given number of digits.
+     * @param count number of digits; must be greater than zero and less than
+     *      the current number of digits
+     */
+    private void truncate(final int count) {
+        int nonZeroCount = count;
+        for (int i = count - 1;
+                i >= 0 && digits[i] == 0;
+                --i) {
+            --nonZeroCount;
+        }
+        exponent += digitCount - nonZeroCount;
+        digitCount = nonZeroCount;
+    }
+
+    /** Construct a new instance from the given double value.
+     * @param d double value
+     * @return a new instance containing the parsed components of the given double value
+     * @throws IllegalArgumentException if {@code d} is {@code NaN} or infinite
+     */
+    public static ParsedDecimal from(final double d) {
+        if (!Double.isFinite(d)) {
+            throw new IllegalArgumentException("Double is not finite");
+        }
+
+        // Get the canonical string representation of the double value and parse
+        // it to extract the components of the decimal value. From the documentation
+        // of Double.toString() and the fact that d is finite, we are guaranteed the
+        // following:
+        // - the string will not be empty
+        // - it will contain exactly one decimal point character
+        // - all digit characters are in the ASCII range
+        final char[] strChars = Double.toString(d).toCharArray();
+
+        final boolean negative = strChars[0] == MINUS_CHAR;
+        final int digitStartIdx = negative ? 1 : 0;
+
+        final int[] digits = new int[strChars.length];
+
+        boolean foundDecimalPoint = false;
+        int digitCount = 0;
+        int significantDigitCount = 0;
+        int decimalPos = 0;
+
+        int i;
+        for (i = digitStartIdx; i < strChars.length; ++i) {
+            final char ch = strChars[i];
+
+            if (ch == DECIMAL_SEP_CHAR) {
+                foundDecimalPoint = true;
+                decimalPos = digitCount;
+            } else if (ch == EXPONENT_CHAR) {
+                // no more mantissa digits
+                break;
+            } else if (ch != ZERO_CHAR || digitCount > 0) {
+                // this is either the first non-zero digit or one after it
+                final int val = digitValue(ch);
+                digits[digitCount++] = val;
+
+                if (val > 0) {
+                    significantDigitCount = digitCount;
+                }
+            } else if (foundDecimalPoint) {
+                // leading zero in a fraction; adjust the decimal position
+                --decimalPos;
+            }
+        }
+
+        if (digitCount > 0) {
+            // determine the exponent
+            final int explicitExponent = i < strChars.length
+                    ? parseExponent(strChars, i + 1)
+                    : 0;
+            final int exponent = explicitExponent + decimalPos - significantDigitCount;
+
+            return new ParsedDecimal(negative, digits, significantDigitCount, exponent);
+        }
+
+        // no non-zero digits, so value is zero
+        return new ParsedDecimal(negative, new int[] {0}, 1, 0);

Review comment:
       A case for a singleton specialisation here.

##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,715 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int resultExponent = digitCount + exponent - decimalPos;
+
+        // only include the exponent string if it is non-zero or it is requested to be present
+        final char[] resultExponentChars = resultExponent != 0 || opts.getAlwaysIncludeExponent()
+                ? Integer.toString(Math.abs(resultExponent)).toCharArray()
+                : null;
+
+        prepareOutput(getScientificStringSize(decimalPos, resultExponent, resultExponentChars, opts));
+
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (resultExponentChars != null) {
+            append(opts.getExponentSeparatorChars());
+
+            if (resultExponent < 0) {
+                append(opts.getMinusSign());
+            }
+
+            final char[] localizedDigits = opts.getDigits();
+            for (int i = 0; i < resultExponentChars.length; ++i) {
+                final int val = digitValue(resultExponentChars[i]);
+                appendLocalizedDigit(val, localizedDigits);
+            }
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return true if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index
+     * @param opts format options
+     */
+    private void appendFraction(final int zeroCount, final int startIdx, final FormatOptions opts) {
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        if (startIdx < digitCount) {
+            append(opts.getDecimalSeparator());
+
+            // add the zero prefix
+            for (int i = 0; i < zeroCount; ++i) {
+                append(localizedZero);
+            }
+
+            // add the fraction digits
+            for (int i = startIdx; i < digitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+        } else if (opts.getIncludeFractionPlaceholder()) {
+            append(opts.getDecimalSeparator());
+            append(localizedZero);
+        }
+    }
+
+    /** Get the number of characters required to create a plain format representation
+     * of this value.
+     * @param decimalPos decimal position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters in the plain string representation of this value,
+     *      created using the given parameters
+     */
+    private int getPlainStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+
+        // adjust for groupings if needed
+        if (opts.getGroupThousands() && decimalPos > 0) {
+            size += (decimalPos - 1) / THOUSANDS_GROUP_SIZE;
+        }
+
+        return size;
+    }
+
+    /** Get the number of characters required to create a scientific format representation
+     * of this value.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param resultExponent target exponent
+     * @param resultExponentChars target exponent characters; if null, no explicit exponent is to be included
+     * @param opts format options
+     * @return number of characters in the scientific representation of this string, using the
+     *      given parameters
+     */
+    private int getScientificStringSize(final int decimalPos, final int resultExponent,
+            final char[] resultExponentChars, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+        if (resultExponentChars != null) {
+            size += opts.getExponentSeparatorChars().length + resultExponentChars.length;
+            if (resultExponent < 0) {
+                // adjust for the exponent minus sign
+                ++size;
+            }
+        }
+        return size;
+    }
+
+    /** Get the number of characters required for the digit portion of a string representation of
+     * this value. This excludes any exponent or thousands groupings characters.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters required for the digit portion of a string representation of
+     *      this value
+     */
+    private int getDigitStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = digitCount;
+        if (shouldIncludeMinus(opts)) {
+            ++size;
+        }
+        if (decimalPos < 1) {
+            // no whole component;
+            // add decimal point and leading zeros
+            size += 2 + Math.abs(decimalPos);
+        } else if (decimalPos >= digitCount) {
+            // no fraction component;
+            // add trailing zeros
+            size += decimalPos - digitCount;
+            if (opts.getIncludeFractionPlaceholder()) {
+                size += 2;
+            }
+        } else {
+            // whole and fraction components;
+            // add decimal point
+            size += 1;
+        }
+
+        return size;
+    }
+
+
+    /** Return true if formatted strings should include the minus sign, considering
+     * the value of this instance and the given format options.
+     * @param opts format options
+     * @return true if a minus sign should be included in the output
+     */
+    private boolean shouldIncludeMinus(final FormatOptions opts) {
+        return negative && (opts.getSignedZero() || !isZero());
+    }
+
+    /** Return true if a rounding operation for the given number of digits should
+     * round up.
+     * @param count number of digits to round to; must be greater than zero and less
+     *      than the current number of digits
+     * @return true if a rounding operation for the given number of digits should
+     *      round up
+     */
+    private boolean shouldRoundUp(final int count) {
+        // Round up in the following cases:
+        // 1. The digit after the last digit is greater than 5.
+        // 2. The digit after the last digit is 5 and there are additional (non-zero)
+        //      digits after it.
+        // 3. The digit after the last digit is 5, there are no additional digits afterward,
+        //      and the last digit is odd (half-even rounding).
+        final int digitAfterLast = digits[count];
+
+        return digitAfterLast > ROUND_CENTER || (digitAfterLast == ROUND_CENTER
+                && (count < digitCount - 1 || (digits[count - 1] % 2) != 0));
+    }
+
+    /** Round the value up to the given number of digits.
+     * @param count target number of digits; must be greater than zero and
+     *      less than the current number of digits
+     */
+    private void roundUp(final int count) {
+        int removedDigits = digitCount - count;
+        int i;
+        for (i = count - 1; i >= 0; --i) {
+            final int d = digits[i] + 1;
+
+            if (d < DECIMAL_RADIX) {
+                // value did not carry over; done adding
+                digits[i] = d;
+                break;
+            } else {
+                // value carried over; the current position is 0
+                // which we will ignore by shortening the digit count
+                ++removedDigits;
+            }
+        }
+
+        if (i < 0) {
+            // all values carried over
+            setSingleDigitValue(1, exponent + removedDigits);
+        } else {
+            // values were updated in-place; just need to update the length
+            truncate(digitCount - removedDigits);
+        }
+    }
+
+    /** Return true if this value is equal to zero. The sign field is ignored,
+     * meaning that this method will return true for both {@code +0} and {@code -0}.
+     * @return true if the value is equal to zero
+     */
+    boolean isZero() {
+        return digits[0] == 0;
+    }
+
+    /** Set the value of this instance to a single digit with the given exponent.
+     * The sign of the value is retained.
+     * @param digit digit value
+     * @param newExponent new exponent value
+     */
+    private void setSingleDigitValue(final int digit, final int newExponent) {
+        digits[0] = digit;
+        digitCount = 1;
+        exponent = newExponent;
+    }
+
+    /** Truncate the value to the given number of digits.
+     * @param count number of digits; must be greater than zero and less than
+     *      the current number of digits
+     */
+    private void truncate(final int count) {
+        int nonZeroCount = count;
+        for (int i = count - 1;
+                i >= 0 && digits[i] == 0;
+                --i) {
+            --nonZeroCount;
+        }
+        exponent += digitCount - nonZeroCount;
+        digitCount = nonZeroCount;
+    }
+
+    /** Construct a new instance from the given double value.
+     * @param d double value
+     * @return a new instance containing the parsed components of the given double value
+     * @throws IllegalArgumentException if {@code d} is {@code NaN} or infinite
+     */
+    public static ParsedDecimal from(final double d) {
+        if (!Double.isFinite(d)) {
+            throw new IllegalArgumentException("Double is not finite");
+        }
+
+        // Get the canonical string representation of the double value and parse
+        // it to extract the components of the decimal value. From the documentation
+        // of Double.toString() and the fact that d is finite, we are guaranteed the
+        // following:
+        // - the string will not be empty
+        // - it will contain exactly one decimal point character
+        // - all digit characters are in the ASCII range
+        final char[] strChars = Double.toString(d).toCharArray();
+
+        final boolean negative = strChars[0] == MINUS_CHAR;
+        final int digitStartIdx = negative ? 1 : 0;
+
+        final int[] digits = new int[strChars.length];

Review comment:
       This can be `new int[strChars.length - digitStartIdx]`

##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,715 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int resultExponent = digitCount + exponent - decimalPos;
+
+        // only include the exponent string if it is non-zero or it is requested to be present
+        final char[] resultExponentChars = resultExponent != 0 || opts.getAlwaysIncludeExponent()
+                ? Integer.toString(Math.abs(resultExponent)).toCharArray()
+                : null;
+
+        prepareOutput(getScientificStringSize(decimalPos, resultExponent, resultExponentChars, opts));
+
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (resultExponentChars != null) {
+            append(opts.getExponentSeparatorChars());
+
+            if (resultExponent < 0) {
+                append(opts.getMinusSign());
+            }
+
+            final char[] localizedDigits = opts.getDigits();
+            for (int i = 0; i < resultExponentChars.length; ++i) {
+                final int val = digitValue(resultExponentChars[i]);
+                appendLocalizedDigit(val, localizedDigits);
+            }
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return true if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index
+     * @param opts format options
+     */
+    private void appendFraction(final int zeroCount, final int startIdx, final FormatOptions opts) {
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        if (startIdx < digitCount) {
+            append(opts.getDecimalSeparator());
+
+            // add the zero prefix
+            for (int i = 0; i < zeroCount; ++i) {
+                append(localizedZero);
+            }
+
+            // add the fraction digits
+            for (int i = startIdx; i < digitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+        } else if (opts.getIncludeFractionPlaceholder()) {
+            append(opts.getDecimalSeparator());
+            append(localizedZero);
+        }
+    }
+
+    /** Get the number of characters required to create a plain format representation
+     * of this value.
+     * @param decimalPos decimal position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters in the plain string representation of this value,
+     *      created using the given parameters
+     */
+    private int getPlainStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+
+        // adjust for groupings if needed
+        if (opts.getGroupThousands() && decimalPos > 0) {
+            size += (decimalPos - 1) / THOUSANDS_GROUP_SIZE;
+        }
+
+        return size;
+    }
+
+    /** Get the number of characters required to create a scientific format representation
+     * of this value.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param resultExponent target exponent
+     * @param resultExponentChars target exponent characters; if null, no explicit exponent is to be included
+     * @param opts format options
+     * @return number of characters in the scientific representation of this string, using the
+     *      given parameters
+     */
+    private int getScientificStringSize(final int decimalPos, final int resultExponent,
+            final char[] resultExponentChars, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+        if (resultExponentChars != null) {
+            size += opts.getExponentSeparatorChars().length + resultExponentChars.length;
+            if (resultExponent < 0) {
+                // adjust for the exponent minus sign
+                ++size;
+            }
+        }
+        return size;
+    }
+
+    /** Get the number of characters required for the digit portion of a string representation of
+     * this value. This excludes any exponent or thousands groupings characters.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters required for the digit portion of a string representation of
+     *      this value
+     */
+    private int getDigitStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = digitCount;
+        if (shouldIncludeMinus(opts)) {
+            ++size;
+        }
+        if (decimalPos < 1) {
+            // no whole component;
+            // add decimal point and leading zeros
+            size += 2 + Math.abs(decimalPos);
+        } else if (decimalPos >= digitCount) {
+            // no fraction component;
+            // add trailing zeros
+            size += decimalPos - digitCount;
+            if (opts.getIncludeFractionPlaceholder()) {
+                size += 2;
+            }
+        } else {
+            // whole and fraction components;
+            // add decimal point
+            size += 1;
+        }
+
+        return size;
+    }
+
+
+    /** Return true if formatted strings should include the minus sign, considering
+     * the value of this instance and the given format options.
+     * @param opts format options
+     * @return true if a minus sign should be included in the output
+     */
+    private boolean shouldIncludeMinus(final FormatOptions opts) {
+        return negative && (opts.getSignedZero() || !isZero());
+    }
+
+    /** Return true if a rounding operation for the given number of digits should
+     * round up.
+     * @param count number of digits to round to; must be greater than zero and less
+     *      than the current number of digits
+     * @return true if a rounding operation for the given number of digits should
+     *      round up
+     */
+    private boolean shouldRoundUp(final int count) {
+        // Round up in the following cases:
+        // 1. The digit after the last digit is greater than 5.
+        // 2. The digit after the last digit is 5 and there are additional (non-zero)
+        //      digits after it.
+        // 3. The digit after the last digit is 5, there are no additional digits afterward,
+        //      and the last digit is odd (half-even rounding).
+        final int digitAfterLast = digits[count];
+
+        return digitAfterLast > ROUND_CENTER || (digitAfterLast == ROUND_CENTER
+                && (count < digitCount - 1 || (digits[count - 1] % 2) != 0));
+    }
+
+    /** Round the value up to the given number of digits.
+     * @param count target number of digits; must be greater than zero and
+     *      less than the current number of digits
+     */
+    private void roundUp(final int count) {
+        int removedDigits = digitCount - count;
+        int i;
+        for (i = count - 1; i >= 0; --i) {
+            final int d = digits[i] + 1;
+
+            if (d < DECIMAL_RADIX) {
+                // value did not carry over; done adding
+                digits[i] = d;
+                break;
+            } else {
+                // value carried over; the current position is 0
+                // which we will ignore by shortening the digit count
+                ++removedDigits;
+            }
+        }
+
+        if (i < 0) {
+            // all values carried over
+            setSingleDigitValue(1, exponent + removedDigits);
+        } else {
+            // values were updated in-place; just need to update the length
+            truncate(digitCount - removedDigits);
+        }
+    }
+
+    /** Return true if this value is equal to zero. The sign field is ignored,
+     * meaning that this method will return true for both {@code +0} and {@code -0}.
+     * @return true if the value is equal to zero
+     */
+    boolean isZero() {
+        return digits[0] == 0;
+    }
+
+    /** Set the value of this instance to a single digit with the given exponent.
+     * The sign of the value is retained.
+     * @param digit digit value
+     * @param newExponent new exponent value
+     */
+    private void setSingleDigitValue(final int digit, final int newExponent) {
+        digits[0] = digit;
+        digitCount = 1;
+        exponent = newExponent;
+    }
+
+    /** Truncate the value to the given number of digits.
+     * @param count number of digits; must be greater than zero and less than
+     *      the current number of digits
+     */
+    private void truncate(final int count) {
+        int nonZeroCount = count;
+        for (int i = count - 1;
+                i >= 0 && digits[i] == 0;
+                --i) {
+            --nonZeroCount;
+        }
+        exponent += digitCount - nonZeroCount;
+        digitCount = nonZeroCount;
+    }
+
+    /** Construct a new instance from the given double value.
+     * @param d double value
+     * @return a new instance containing the parsed components of the given double value
+     * @throws IllegalArgumentException if {@code d} is {@code NaN} or infinite
+     */
+    public static ParsedDecimal from(final double d) {
+        if (!Double.isFinite(d)) {
+            throw new IllegalArgumentException("Double is not finite");
+        }
+
+        // Get the canonical string representation of the double value and parse
+        // it to extract the components of the decimal value. From the documentation
+        // of Double.toString() and the fact that d is finite, we are guaranteed the
+        // following:
+        // - the string will not be empty
+        // - it will contain exactly one decimal point character
+        // - all digit characters are in the ASCII range
+        final char[] strChars = Double.toString(d).toCharArray();

Review comment:
       @darkma773r Did you look at using `String.charAt(int)` in-place of getting a copy of the `char[]`? It would save a trivial amount of memory allocation at the cost of method calls to get each char.
   
   I believe the largest `char[]` required for a double is length 24, e.g.
   ```java
   Double.toString(-Math.nextDown(Math.nextDown(Double.MIN_NORMAL)))
   -2.2250738585072004E-308
   ```
   So memory allocation is low. But it if it makes no speed difference then the `charAt` version would have less garbage collection.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] kinow commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
kinow commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r667582372



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,724 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return {@code true} if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return {@code true} if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return {@code true} if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return {@code true} if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}

Review comment:
       I think `{@link #getDigits() digit}` needs to be `{@link FormatOptions#getDigits() digit}`

##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;
+
+        /** Construct a new instance that delegates double function construction
+         * to the given factory object.
+         * @param factory factory function
+         */
+        private Builder(final Function<Builder, DoubleFunction<String>> factory) {
+            this.factory = factory;
+        }
+
+        /** Set the maximum number of significant decimal digits used in format
+         * results. A value of {@code 0} indicates no limit. The default value is {@code 0}.
+         * @param maxPrecision maximum precision
+         * @return this instance
+         */
+        public Builder maxPrecision(final int maxPrecision) {
+            this.maxPrecision = maxPrecision;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for formatted strings. No digits with an
+         * absolute value of less than <code>10<sup>minDecimalExponent</sup></code> will
+         * be included in format results. If the number being formatted does not contain
+         * any such digits, then zero is returned. For example, if {@code minDecimalExponent}
+         * is set to {@code -2} and the number {@code 3.14159} is formatted, the plain
+         * format result will be {@code "3.14"}. If {@code 0.001} is formatted, then the
+         * result is the zero string.
+         * @param minDecimalExponent minimum decimal exponent
+         * @return this instance
+         */
+        public Builder minDecimalExponent(final int minDecimalExponent) {
+            this.minDecimalExponent = minDecimalExponent;
+            return this;
+        }
+
+        /** Set the maximum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code 2}, the number {@code 999} will be formatted as {@code "999.0"}
+         * while {@code 1000} will be formatted as {@code "1.0E3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMaxDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMaxDecimalExponent(final int plainFormatMaxDecimalExponent) {
+            this.plainFormatMaxDecimalExponent = plainFormatMaxDecimalExponent;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code -2}, the number {@code 0.01} will be formatted as {@code "0.01"}
+         * while {@code 0.0099} will be formatted as {@code "9.9E-3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMinDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMinDecimalExponent(final int plainFormatMinDecimalExponent) {
+            this.plainFormatMinDecimalExponent = plainFormatMinDecimalExponent;
+            return this;
+        }
+
+        /** Set the flag determining whether or not the zero string may be returned with the minus
+         * sign or if it will always be returned in the positive form. For example, if set to true,
+         * the string {@code "-0.0"} may be returned for some input numbers. If false, only {@code "0.0"}
+         * will be returned, regardless of the sign of the input number. The default value is {@code true}.
+         * @param signedZero if true, the zero string may be returned with a preceding minus sign; if false,
+         *      the zero string will only be returned in its positive form
+         * @return this instance
+         */
+        public Builder allowSignedZero(final boolean signedZero) {
+            this.signedZero = signedZero;
+            return this;
+        }
+
+        /** Set the string containing the digit characters 0-9, in that order. The
+         * default value is the string {@code "0123456789"}.
+         * @param digits string containing the digit characters 0-9
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         * @throws IllegalArgumentException if the argument does not have a length of exactly 10
+         */
+        public Builder digits(final String digits) {
+            Objects.requireNonNull(digits, "Digits string cannot be null");
+            if (digits.length() != DEFAULT_DECIMAL_DIGITS.length()) {
+                throw new IllegalArgumentException("Digits string must contain exactly "
+                        + DEFAULT_DECIMAL_DIGITS.length() + " characters.");
+            }
+
+            this.digits = digits;
+            return this;
+        }
+
+        /** Set the flag determining whether or not a zero character is added in the fraction position
+         * when no fractional value is present. For example, if set to true, the number {@code 1} would
+         * be formatted as {@code "1.0"}. If false, it would be formatted as {@code "1"}. The default
+         * value is {@code true}.
+         * @param fractionPlaceholder if true, a zero character is placed in the fraction position when
+         *      no fractional value is present; if false, fractional digits are only included when needed
+         * @return this instance
+         */
+        public Builder includeFractionPlaceholder(final boolean fractionPlaceholder) {
+            this.fractionPlaceholder = fractionPlaceholder;
+            return this;
+        }
+
+        /** Set the character used as the minus sign.
+         * @param minusSign character to use as the minus sign
+         * @return this instance
+         */
+        public Builder minusSign(final char minusSign) {
+            this.minusSign = minusSign;
+            return this;
+        }
+
+        /** Set the decimal separator character, i.e., the character placed between the
+         * whole number and fractional portions of the formatted strings. The default value
+         * is {@code '.'}.
+         * @param decimalSeparator decimal separator character
+         * @return this instance
+         */
+        public Builder decimalSeparator(final char decimalSeparator) {
+            this.decimalSeparator = decimalSeparator;
+            return this;
+        }
+
+        /** Set the character used to separate groups of thousands. Default value is {@code ','}.
+         * @param groupingSeparator character used to separate groups of thousands
+         * @return this instance
+         * @see #groupThousands(boolean)
+         */
+        public Builder groupingSeparator(final char groupingSeparator) {
+            this.groupingSeparator = groupingSeparator;
+            return this;
+        }
+
+        /** If set to true, thousands will be grouped with the

Review comment:
       Nit-pick, but there are other `true`'s and `false`s (and I think `null`s?) that are formatted as plain text. Not a blocker for this PR though :+1: can be fixed later/someday.

##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,724 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return {@code true} if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return {@code true} if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return {@code true} if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return {@code true} if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int targetExponent = digitCount + exponent - decimalPos;
+        final int absTargetExponent = Math.abs(targetExponent);
+        final boolean includeExponent = shouldIncludeExponent(targetExponent, opts);
+        final boolean negativeExponent = targetExponent < 0;
+
+        // determine the size of the full formatted string, including the number of
+        // characters needed for the exponent digits
+        int size = getDigitStringSize(decimalPos, opts);
+        int exponentDigitCount = 0;
+        if (includeExponent) {
+            exponentDigitCount = absTargetExponent > 0
+                    ? (int) Math.floor(Math.log10(absTargetExponent)) + 1
+                    : 1;
+
+            size += opts.getExponentSeparatorChars().length + exponentDigitCount;
+            if (negativeExponent) {
+                ++size;
+            }
+        }
+
+        prepareOutput(size);
+
+        // append the portion before the exponent field
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (includeExponent) {
+            // append the exponent field
+            append(opts.getExponentSeparatorChars());
+
+            if (negativeExponent) {
+                append(opts.getMinusSign());
+            }
+
+            // append the exponent digits themselves; compute the
+            // string representation directly and add it to the output
+            // buffer to avoid the overhead of Integer.toString()
+            final char[] localizedDigits = opts.getDigits();
+            int rem = absTargetExponent;
+            for (int i = size - 1; i >= outputIdx; --i) {
+                outputChars[i] = localizedDigits[rem % DECIMAL_RADIX];
+                rem /= DECIMAL_RADIX;
+            }
+            outputIdx = size;
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return {@code true} if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index

Review comment:
       s/fractionStartIdx/startIdx ?

##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;
+
+        /** Construct a new instance that delegates double function construction
+         * to the given factory object.
+         * @param factory factory function
+         */
+        private Builder(final Function<Builder, DoubleFunction<String>> factory) {
+            this.factory = factory;
+        }
+
+        /** Set the maximum number of significant decimal digits used in format
+         * results. A value of {@code 0} indicates no limit. The default value is {@code 0}.
+         * @param maxPrecision maximum precision
+         * @return this instance
+         */
+        public Builder maxPrecision(final int maxPrecision) {
+            this.maxPrecision = maxPrecision;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for formatted strings. No digits with an
+         * absolute value of less than <code>10<sup>minDecimalExponent</sup></code> will
+         * be included in format results. If the number being formatted does not contain
+         * any such digits, then zero is returned. For example, if {@code minDecimalExponent}
+         * is set to {@code -2} and the number {@code 3.14159} is formatted, the plain
+         * format result will be {@code "3.14"}. If {@code 0.001} is formatted, then the
+         * result is the zero string.
+         * @param minDecimalExponent minimum decimal exponent
+         * @return this instance
+         */
+        public Builder minDecimalExponent(final int minDecimalExponent) {
+            this.minDecimalExponent = minDecimalExponent;
+            return this;
+        }
+
+        /** Set the maximum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code 2}, the number {@code 999} will be formatted as {@code "999.0"}
+         * while {@code 1000} will be formatted as {@code "1.0E3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMaxDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMaxDecimalExponent(final int plainFormatMaxDecimalExponent) {
+            this.plainFormatMaxDecimalExponent = plainFormatMaxDecimalExponent;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code -2}, the number {@code 0.01} will be formatted as {@code "0.01"}
+         * while {@code 0.0099} will be formatted as {@code "9.9E-3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMinDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMinDecimalExponent(final int plainFormatMinDecimalExponent) {
+            this.plainFormatMinDecimalExponent = plainFormatMinDecimalExponent;
+            return this;
+        }
+
+        /** Set the flag determining whether or not the zero string may be returned with the minus
+         * sign or if it will always be returned in the positive form. For example, if set to true,
+         * the string {@code "-0.0"} may be returned for some input numbers. If false, only {@code "0.0"}
+         * will be returned, regardless of the sign of the input number. The default value is {@code true}.
+         * @param signedZero if true, the zero string may be returned with a preceding minus sign; if false,
+         *      the zero string will only be returned in its positive form
+         * @return this instance
+         */
+        public Builder allowSignedZero(final boolean signedZero) {
+            this.signedZero = signedZero;
+            return this;
+        }
+
+        /** Set the string containing the digit characters 0-9, in that order. The
+         * default value is the string {@code "0123456789"}.
+         * @param digits string containing the digit characters 0-9
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         * @throws IllegalArgumentException if the argument does not have a length of exactly 10
+         */
+        public Builder digits(final String digits) {
+            Objects.requireNonNull(digits, "Digits string cannot be null");

Review comment:
       `digits` appears to be null-safe. But `minusSign` and other builder methods are not. Was it intentional? Or should we follow the example of this method and protect others too?
   
   e.g.
   
   ```java
           DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder()
                   .allowSignedZero(true)
                   .minusSign((Character) null) // NPE
                   .build();
           String s = fmt.apply(1.3);
           System.out.println(s);
   ```

##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;
+
+        /** Construct a new instance that delegates double function construction
+         * to the given factory object.
+         * @param factory factory function
+         */
+        private Builder(final Function<Builder, DoubleFunction<String>> factory) {
+            this.factory = factory;
+        }
+
+        /** Set the maximum number of significant decimal digits used in format
+         * results. A value of {@code 0} indicates no limit. The default value is {@code 0}.
+         * @param maxPrecision maximum precision
+         * @return this instance
+         */
+        public Builder maxPrecision(final int maxPrecision) {
+            this.maxPrecision = maxPrecision;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for formatted strings. No digits with an
+         * absolute value of less than <code>10<sup>minDecimalExponent</sup></code> will
+         * be included in format results. If the number being formatted does not contain
+         * any such digits, then zero is returned. For example, if {@code minDecimalExponent}
+         * is set to {@code -2} and the number {@code 3.14159} is formatted, the plain
+         * format result will be {@code "3.14"}. If {@code 0.001} is formatted, then the
+         * result is the zero string.
+         * @param minDecimalExponent minimum decimal exponent
+         * @return this instance
+         */
+        public Builder minDecimalExponent(final int minDecimalExponent) {
+            this.minDecimalExponent = minDecimalExponent;
+            return this;
+        }
+
+        /** Set the maximum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code 2}, the number {@code 999} will be formatted as {@code "999.0"}
+         * while {@code 1000} will be formatted as {@code "1.0E3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMaxDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMaxDecimalExponent(final int plainFormatMaxDecimalExponent) {
+            this.plainFormatMaxDecimalExponent = plainFormatMaxDecimalExponent;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code -2}, the number {@code 0.01} will be formatted as {@code "0.01"}
+         * while {@code 0.0099} will be formatted as {@code "9.9E-3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMinDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMinDecimalExponent(final int plainFormatMinDecimalExponent) {
+            this.plainFormatMinDecimalExponent = plainFormatMinDecimalExponent;
+            return this;
+        }
+
+        /** Set the flag determining whether or not the zero string may be returned with the minus
+         * sign or if it will always be returned in the positive form. For example, if set to true,
+         * the string {@code "-0.0"} may be returned for some input numbers. If false, only {@code "0.0"}
+         * will be returned, regardless of the sign of the input number. The default value is {@code true}.
+         * @param signedZero if true, the zero string may be returned with a preceding minus sign; if false,
+         *      the zero string will only be returned in its positive form
+         * @return this instance
+         */
+        public Builder allowSignedZero(final boolean signedZero) {
+            this.signedZero = signedZero;
+            return this;
+        }
+
+        /** Set the string containing the digit characters 0-9, in that order. The
+         * default value is the string {@code "0123456789"}.
+         * @param digits string containing the digit characters 0-9
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         * @throws IllegalArgumentException if the argument does not have a length of exactly 10
+         */
+        public Builder digits(final String digits) {
+            Objects.requireNonNull(digits, "Digits string cannot be null");
+            if (digits.length() != DEFAULT_DECIMAL_DIGITS.length()) {
+                throw new IllegalArgumentException("Digits string must contain exactly "
+                        + DEFAULT_DECIMAL_DIGITS.length() + " characters.");
+            }
+
+            this.digits = digits;
+            return this;
+        }
+
+        /** Set the flag determining whether or not a zero character is added in the fraction position
+         * when no fractional value is present. For example, if set to true, the number {@code 1} would
+         * be formatted as {@code "1.0"}. If false, it would be formatted as {@code "1"}. The default
+         * value is {@code true}.
+         * @param fractionPlaceholder if true, a zero character is placed in the fraction position when
+         *      no fractional value is present; if false, fractional digits are only included when needed
+         * @return this instance
+         */
+        public Builder includeFractionPlaceholder(final boolean fractionPlaceholder) {
+            this.fractionPlaceholder = fractionPlaceholder;
+            return this;
+        }
+
+        /** Set the character used as the minus sign.
+         * @param minusSign character to use as the minus sign
+         * @return this instance
+         */
+        public Builder minusSign(final char minusSign) {
+            this.minusSign = minusSign;
+            return this;
+        }
+
+        /** Set the decimal separator character, i.e., the character placed between the
+         * whole number and fractional portions of the formatted strings. The default value
+         * is {@code '.'}.
+         * @param decimalSeparator decimal separator character
+         * @return this instance
+         */
+        public Builder decimalSeparator(final char decimalSeparator) {
+            this.decimalSeparator = decimalSeparator;
+            return this;
+        }
+
+        /** Set the character used to separate groups of thousands. Default value is {@code ','}.
+         * @param groupingSeparator character used to separate groups of thousands
+         * @return this instance
+         * @see #groupThousands(boolean)
+         */
+        public Builder groupingSeparator(final char groupingSeparator) {
+            this.groupingSeparator = groupingSeparator;
+            return this;
+        }
+
+        /** If set to true, thousands will be grouped with the
+         * {@link #groupingSeparator(char) grouping separator}. For example, if set to true,
+         * the number {@code 1000} could be formatted as {@code "1,000"}. This property only applies
+         * to the {@link DoubleFormat#PLAIN PLAIN} format. Default value is {@code false}.
+         * @param groupThousands if true, thousands will be grouped
+         * @return this instance
+         * @see #groupingSeparator(char)
+         */
+        public Builder groupThousands(final boolean groupThousands) {
+            this.groupThousands = groupThousands;
+            return this;
+        }
+
+        /** Set the exponent separator character, i.e., the string placed between
+         * the mantissa and the exponent. The default value is {@code "E"}, as in
+         * {@code "1.2E6"}.
+         * @param exponentSeparator exponent separator string
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder exponentSeparator(final String exponentSeparator) {
+            Objects.requireNonNull(exponentSeparator, "Exponent separator cannot be null");
+
+            this.exponentSeparator = exponentSeparator;
+            return this;
+        }
+
+        /** Set the flag indicating if an exponent value should always be included in the
+         * formatted value, even if the exponent value is zero. This property only applies
+         * to formats that use scientific notation, namely
+         * {@link DoubleFormat#SCIENTIFIC SCIENTIFIC},
+         * {@link DoubleFormat#ENGINEERING ENGINEERING}, and
+         * {@link DoubleFormat#MIXED MIXED}. The default value is {@code false}.
+         * @param alwaysIncludeExponent if true, exponents will always be included in formatted
+         *      output even if the exponent value is zero
+         * @return this instance
+         */
+        public Builder alwaysIncludeExponent(final boolean alwaysIncludeExponent) {
+            this.alwaysIncludeExponent = alwaysIncludeExponent;
+            return this;
+        }
+
+        /** Set the string used to represent infinity. For negative infinity, this string
+         * is prefixed with the {@link #minusSign(char) minus sign}.
+         * @param infinity string used to represent infinity
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder infinity(final String infinity) {
+            Objects.requireNonNull(infinity, "Infinity string cannot be null");
+
+            this.infinity = infinity;
+            return this;
+        }
+
+        /** Set the string used to represent {@link Double#NaN}.
+         * @param nan string used to represent {@link Double#NaN}
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder nan(final String nan) {
+            Objects.requireNonNull(nan, "NaN string cannot be null");
+
+            this.nan = nan;
+            return this;
+        }
+
+        /** Configure this instance with the given format symbols. The following values
+         * are set:
+         * <ul>
+         *  <li>{@link #digits(String) digit characters}</li>
+         *  <li>{@link #decimalSeparator(char) decimal separator}</li>
+         *  <li>{@link #groupingSeparator(char) thousands grouping separator}</li>
+         *  <li>{@link #minusSign(char) minus sign}</li>
+         *  <li>{@link #exponentSeparator(String) exponent separator}</li>
+         *  <li>{@link #infinity(String) infinity}</li>
+         *  <li>{@link #nan(String) NaN}</li>
+         * </ul>
+         * The digit character string is constructed by starting at the configured
+         * {@link DecimalFormatSymbols#getZeroDigit() zero digit} and adding the next
+         * 9 consecutive characters.
+         * @param symbols format symbols
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder formatSymbols(final DecimalFormatSymbols symbols) {
+            Objects.requireNonNull(symbols, "Decimal format symbols cannot be null");
+
+            return digits(getDigitString(symbols))
+                    .decimalSeparator(symbols.getDecimalSeparator())
+                    .groupingSeparator(symbols.getGroupingSeparator())
+                    .minusSign(symbols.getMinusSign())
+                    .exponentSeparator(symbols.getExponentSeparator())
+                    .infinity(symbols.getInfinity())
+                    .nan(symbols.getNaN());
+        }
+
+        /** Get a string containing the localized digits 0-9 for the given symbols object. The
+         * string is constructed by starting at the {@link DecimalFormatSymbols#getZeroDigit() zero digit}
+         * and adding the next 9 consecutive characters.
+         * @param symbols symbols object
+         * @return string containing the localized digits 0-9
+         */
+        private String getDigitString(final DecimalFormatSymbols symbols) {
+            final int zeroDelta = symbols.getZeroDigit() - DEFAULT_DECIMAL_DIGITS.charAt(0);
+
+            final char[] digitChars = new char[DEFAULT_DECIMAL_DIGITS.length()];
+            for (int i = 0; i < DEFAULT_DECIMAL_DIGITS.length(); ++i) {
+                digitChars[i] = (char) (DEFAULT_DECIMAL_DIGITS.charAt(i) + zeroDelta);
+            }
+
+            return String.valueOf(digitChars);
+        }
+
+        /** Construct a new double format function.
+         * @return format function
+         */
+        public DoubleFunction<String> build() {
+            return factory.apply(this);
+        }
+    }
+
+    /** Base class for standard double formatting classes.
+     */
+    private abstract static class AbstractDoubleFormat
+        implements DoubleFunction<String>, ParsedDecimal.FormatOptions {
+
+        /** Maximum precision; 0 indicates no limit. */
+        private final int maxPrecision;
+
+        /** Minimum decimal exponent. */
+        private final int minDecimalExponent;
+
+        /** String representing positive infinity. */
+        private final String postiveInfinity;
+
+        /** String representing negative infinity. */
+        private final String negativeInfinity;
+
+        /** String representing NaN. */
+        private final String nan;
+
+        /** Flag determining if fraction placeholders should be used. */
+        private final boolean fractionPlaceholder;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private final boolean signedZero;
+
+        /** String containing the digits 0-9. */
+        private final char[] digits;
+
+        /** Decimal separator character. */
+        private final char decimalSeparator;
+
+        /** Thousands grouping separator. */
+        private final char groupingSeparator;
+
+        /** Flag indicating if thousands should be grouped. */
+        private final boolean groupThousands;
+
+        /** Minus sign character. */
+        private final char minusSign;
+
+        /** Exponent separator character. */
+        private final char[] exponentSeparatorChars;
+
+        /** Flag indicating if exponent values should always be included, even if zero. */
+        private final boolean alwaysIncludeExponent;
+
+        /** Construct a new instance.
+         * @param builder builder instance containing configuration values
+         */
+        AbstractDoubleFormat(final Builder builder) {
+            this.maxPrecision = builder.maxPrecision;
+            this.minDecimalExponent = builder.minDecimalExponent;
+
+            this.postiveInfinity = builder.infinity;
+            this.negativeInfinity = builder.minusSign + builder.infinity;
+            this.nan = builder.nan;
+
+            this.fractionPlaceholder = builder.fractionPlaceholder;
+            this.signedZero = builder.signedZero;
+            this.digits = builder.digits.toCharArray();
+            this.decimalSeparator = builder.decimalSeparator;
+            this.groupingSeparator = builder.groupingSeparator;
+            this.groupThousands = builder.groupThousands;
+            this.minusSign = builder.minusSign;
+            this.exponentSeparatorChars = builder.exponentSeparator.toCharArray();
+            this.alwaysIncludeExponent = builder.alwaysIncludeExponent;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean getIncludeFractionPlaceholder() {
+            return fractionPlaceholder;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean getSignedZero() {
+            return signedZero;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public char[] getDigits() {
+            return digits;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public char getDecimalSeparator() {
+            return decimalSeparator;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public char getGroupingSeparator() {
+            return groupingSeparator;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean getGroupThousands() {

Review comment:
       Should we use `isGroupThousands()` instead? e.g. see `ParsedDecimal#isZero`. I **think** we might have been using only `boolean isProperty` in Text? See comment in review text.

##########
File path: src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.java
##########
@@ -0,0 +1,764 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+import java.util.function.BiFunction;
+
+import org.apache.commons.rng.UniformRandomProvider;
+import org.apache.commons.rng.simple.RandomSource;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+class ParsedDecimalTest {
+
+    @Test
+    void testFrom() {
+        // act/assert
+        checkFrom(0.0, "0", 0);
+
+        checkFrom(1.0, "1", 0);
+        checkFrom(10.0, "1", 1);
+        checkFrom(100.0, "1", 2);
+        checkFrom(1000.0, "1", 3);
+        checkFrom(10000.0, "1", 4);
+
+        checkFrom(0.1, "1", -1);
+        checkFrom(0.01, "1", -2);
+        checkFrom(0.001, "1", -3);
+        checkFrom(0.0001, "1", -4);
+        checkFrom(0.00001, "1", -5);
+
+        checkFrom(1.2, "12", -1);
+        checkFrom(0.00971, "971", -5);
+        checkFrom(56300, "563", 2);
+
+        checkFrom(123.0, "123", 0);
+        checkFrom(1230.0, "123", 1);
+        checkFrom(12300.0, "123", 2);
+        checkFrom(123000.0, "123", 3);
+
+        checkFrom(12.3, "123", -1);
+        checkFrom(1.23, "123", -2);
+        checkFrom(0.123, "123", -3);
+        checkFrom(0.0123, "123", -4);
+
+        checkFrom(1.987654321e270, "1987654321", 261);
+        checkFrom(1.987654321e-270, "1987654321", -279);
+
+        checkFrom(Math.PI, "3141592653589793", -15);
+        checkFrom(Math.E, "2718281828459045", -15);
+
+        checkFrom(Double.MAX_VALUE, "17976931348623157", 292);
+        checkFrom(Double.MIN_VALUE, "49", -325);
+        checkFrom(Double.MIN_NORMAL, "22250738585072014", -324);
+    }
+
+    @Test
+    void testFrom_notFinite() {
+        // arrange
+        final String msg = "Double is not finite";
+
+        // act/assert
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NaN),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NEGATIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.POSITIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+    }
+
+    @Test
+    void testIsZero() {
+        // act/assert
+        Assertions.assertTrue(ParsedDecimal.from(0.0).isZero());
+        Assertions.assertTrue(ParsedDecimal.from(-0.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(1.0).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-1.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MIN_NORMAL).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_NORMAL).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MAX_VALUE).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_VALUE).isZero());
+    }
+
+    @Test
+    void testRound_one() {
+        // arrange
+        final double a = 1e-10;
+        final double b = -1;
+        final double c = 1e10;
+
+        // act/assert
+        assertRound(a, -11, false, "1", -10);
+        assertRound(a, -10, false, "1", -10);
+        assertRound(a, -9, false, "0", 0);
+
+        assertRound(b, -1, true, "1", 0);
+        assertRound(b, 0, true, "1", 0);
+        assertRound(b, 1, true, "0", 0);
+
+        assertRound(c, 9, false, "1", 10);
+        assertRound(c, 10, false, "1", 10);
+        assertRound(c, 11, false, "0", 0);
+    }
+
+    @Test
+    void testRound_nine() {
+        // arrange
+        final double a = 9e-10;
+        final double b = -9;
+        final double c = 9e10;
+
+        // act/assert
+        assertRound(a, -11, false, "9", -10);
+        assertRound(a, -10, false, "9", -10);
+        assertRound(a, -9, false, "1", -9);
+
+        assertRound(b, -1, true, "9", 0);
+        assertRound(b, 0, true, "9", 0);
+        assertRound(b, 1, true, "1", 1);
+
+        assertRound(c, 9, false, "9", 10);
+        assertRound(c, 10, false, "9", 10);
+        assertRound(c, 11, false, "1", 11);
+    }
+
+    @Test
+    void testRound_mixed() {
+        // arrange
+        final double a = 9.94e-10;
+        final double b = -3.1415;
+        final double c = 5.55e10;
+
+        // act/assert
+        assertRound(a, -13, false, "994", -12);
+        assertRound(a, -12, false, "994", -12);
+        assertRound(a, -11, false, "99", -11);
+        assertRound(a, -10, false, "1", -9);
+        assertRound(a, -9, false, "1", -9);
+        assertRound(a, -8, false, "0", 0);
+
+        assertRound(b, -5, true, "31415", -4);
+        assertRound(b, -4, true, "31415", -4);
+        assertRound(b, -3, true, "3142", -3);
+        assertRound(b, -2, true, "314", -2);
+        assertRound(b, -1, true, "31", -1);
+        assertRound(b, 0, true, "3", 0);
+        assertRound(b, 1, true, "0", 0);
+        assertRound(b, 2, true, "0", 0);
+
+        assertRound(c, 7, false, "555", 8);
+        assertRound(c, 8, false, "555", 8);
+        assertRound(c, 9, false, "56", 9);
+        assertRound(c, 10, false, "6", 10);
+        assertRound(c, 11, false, "1", 11);
+        assertRound(c, 12, false, "0", 0);
+    }
+
+    @Test
+    void testMaxPrecision() {
+        // arrange
+        final double d = 1.02576552;
+
+        // act
+        assertMaxPrecision(d, 10, false, "102576552", -8);
+        assertMaxPrecision(d, 9, false, "102576552", -8);
+        assertMaxPrecision(d, 8, false, "10257655", -7);
+        assertMaxPrecision(d, 7, false, "1025766", -6);
+        assertMaxPrecision(d, 6, false, "102577", -5);
+        assertMaxPrecision(d, 5, false, "10258", -4);
+        assertMaxPrecision(d, 4, false, "1026", -3);
+        assertMaxPrecision(d, 3, false, "103", -2);
+        assertMaxPrecision(d, 2, false, "1", 0);
+        assertMaxPrecision(d, 1, false, "1", 0);
+
+        assertMaxPrecision(d, 0, false, "102576552", -8);
+    }
+
+    @Test
+    void testMaxPrecision_carry() {
+        // arrange
+        final double d = -999.0999e50;
+
+        // act
+        assertMaxPrecision(d, 8, true, "9990999", 46);
+        assertMaxPrecision(d, 7, true, "9990999", 46);
+        assertMaxPrecision(d, 6, true, "9991", 49);
+        assertMaxPrecision(d, 5, true, "9991", 49);
+        assertMaxPrecision(d, 4, true, "9991", 49);
+        assertMaxPrecision(d, 3, true, "999", 50);
+        assertMaxPrecision(d, 2, true, "1", 53);
+        assertMaxPrecision(d, 1, true, "1", 53);
+
+        assertMaxPrecision(d, 0, true, "9990999", 46);
+    }
+
+    @Test
+    void testMaxPrecision_halfEvenRounding() {
+        // act/assert
+        // Test values taken from RoundingMode.HALF_EVEN javadocs
+        assertMaxPrecision(5.5, 1, false, "6", 0);
+        assertMaxPrecision(2.5, 1, false, "2", 0);
+        assertMaxPrecision(1.6, 1, false, "2", 0);
+        assertMaxPrecision(1.1, 1, false, "1", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-1.1, 1, true, "1", 0);
+        assertMaxPrecision(-1.6, 1, true, "2", 0);
+        assertMaxPrecision(-2.5, 1, true, "2", 0);
+        assertMaxPrecision(-5.5, 1, true, "6", 0);
+    }
+
+    @Test
+    void testMaxPrecision_singleDigits() {
+        // act
+        assertMaxPrecision(9.0, 1, false, "9", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+        assertMaxPrecision(0.0, 1, false, "0", 0);
+        assertMaxPrecision(-0.0, 1, true, "0", 0);
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-9.0, 1, true, "9", 0);
+    }
+
+    @Test
+    void testMaxPrecision_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+        final ParsedDecimal.FormatOptions opts = new FormatOptionsImpl();
+
+        for (int i = 0; i < 10_000; ++i) {
+            final double d = createRandomDouble(rand);
+            final int precision = rand.nextInt(20) + 1;
+            final MathContext ctx = new MathContext(precision, RoundingMode.HALF_EVEN);
+
+            final ParsedDecimal dec = ParsedDecimal.from(d);
+
+            // act
+            dec.maxPrecision(precision);
+
+            // assert
+            Assertions.assertEquals(new BigDecimal(Double.toString(d), ctx).doubleValue(),
+                    Double.parseDouble(scientificString(dec, opts)));
+        }
+    }
+
+    @Test
+    void testToPlainString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToPlainString(0.0, "0.0", opts);
+        checkToPlainString(-0.0, "-0.0", opts);
+        checkToPlainString(1.0, "1.0", opts);
+        checkToPlainString(1.5, "1.5", opts);
+
+        checkToPlainString(12, "12.0", opts);
+        checkToPlainString(123, "123.0", opts);
+        checkToPlainString(1234, "1234.0", opts);
+        checkToPlainString(12345, "12345.0", opts);
+        checkToPlainString(123456, "123456.0", opts);
+        checkToPlainString(1234567, "1234567.0", opts);
+        checkToPlainString(12345678, "12345678.0", opts);
+        checkToPlainString(123456789, "123456789.0", opts);
+        checkToPlainString(1234567890, "1234567890.0", opts);
+
+        checkToPlainString(-0.000123, "-0.000123", opts);
+        checkToPlainString(12301, "12301.0", opts);
+
+        checkToPlainString(Math.PI, "3.141592653589793", opts);
+        checkToPlainString(Math.E, "2.718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "-12345.6789", opts);
+        checkToPlainString(1.23e12, "1230000000000.0", opts);
+        checkToPlainString(1.23e-12, "0.00000000000123", opts);
+    }
+
+    @Test
+    void testToPlainString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setThousandsGroupingSeparator('_');
+        opts.setGroupThousands(true);
+
+        // act/assert
+        checkToPlainString(0.0, "0", opts);
+        checkToPlainString(-0.0, "0", opts);
+        checkToPlainString(1.0, "1", opts);
+        checkToPlainString(1.5, "1,5", opts);
+
+        checkToPlainString(12, "12", opts);
+        checkToPlainString(123, "123", opts);
+        checkToPlainString(1234, "1_234", opts);
+        checkToPlainString(12345, "12_345", opts);
+        checkToPlainString(123456, "123_456", opts);
+        checkToPlainString(1234567, "1_234_567", opts);
+        checkToPlainString(12345678, "12_345_678", opts);
+        checkToPlainString(123456789, "123_456_789", opts);
+        checkToPlainString(1234567890, "1_234_567_890", opts);
+
+        checkToPlainString(-0.000123, "!0,000123", opts);
+        checkToPlainString(12301, "12_301", opts);
+
+        checkToPlainString(Math.PI, "3,141592653589793", opts);
+        checkToPlainString(Math.E, "2,718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "!12_345,6789", opts);
+        checkToPlainString(1.23e12, "1_230_000_000_000", opts);
+        checkToPlainString(1.23e-12, "0,00000000000123", opts);
+    }
+
+    @Test
+    void testToScientificString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToScientificString(0.0, "0.0", opts);
+        checkToScientificString(-0.0, "-0.0", opts);
+        checkToScientificString(1.0, "1.0", opts);
+        checkToScientificString(1.5, "1.5", opts);
+
+        checkToScientificString(-0.000123, "-1.23E-4", opts);
+        checkToScientificString(12301, "1.2301E4", opts);
+
+        checkToScientificString(Math.PI, "3.141592653589793", opts);
+        checkToScientificString(Math.E, "2.718281828459045", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "-1.7976931348623157E308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2.2250738585072014E-308", opts);
+    }
+
+    @Test
+    void testToScientificString_altFormats() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToScientificString(0.0, "0x10^0", opts);
+        checkToScientificString(-0.0, "0x10^0", opts);
+        checkToScientificString(1.0, "1x10^0", opts);
+        checkToScientificString(1.5, "1,5x10^0", opts);
+
+        checkToScientificString(-0.000123, "!1,23x10^!4", opts);
+        checkToScientificString(12301, "1,2301x10^4", opts);
+
+        checkToScientificString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToScientificString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "!1,7976931348623157x10^308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2,2250738585072014x10^!308", opts);
+    }
+
+    @Test
+    void testToEngineeringString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToEngineeringString(0.0, "0.0", opts);
+        checkToEngineeringString(-0.0, "-0.0", opts);
+        checkToEngineeringString(1.0, "1.0", opts);
+        checkToEngineeringString(1.5, "1.5", opts);
+
+        checkToEngineeringString(10, "10.0", opts);
+
+        checkToEngineeringString(-0.000000123, "-123.0E-9", opts);
+        checkToEngineeringString(12300000, "12.3E6", opts);
+
+        checkToEngineeringString(Math.PI, "3.141592653589793", opts);
+        checkToEngineeringString(Math.E, "2.718281828459045", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "-179.76931348623157E306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22.250738585072014E-309", opts);
+    }
+
+    @Test
+    void testToEngineeringString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToEngineeringString(0.0, "0x10^0", opts);
+        checkToEngineeringString(-0.0, "0x10^0", opts);
+        checkToEngineeringString(1.0, "1x10^0", opts);
+        checkToEngineeringString(1.5, "1,5x10^0", opts);
+
+        checkToEngineeringString(10, "10x10^0", opts);
+
+        checkToEngineeringString(-0.000000123, "!123x10^!9", opts);
+        checkToEngineeringString(12300000, "12,3x10^6", opts);
+
+        checkToEngineeringString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToEngineeringString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "!179,76931348623157x10^306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22,250738585072014x10^!309", opts);
+    }
+
+    @Test
+    void testStringMethods_customDigits() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setDigitsFromString("abcdefghij");
+
+        // act/assert
+        Assertions.assertEquals("b.a", plainString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-a.abcd", plainString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", plainString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("baaaa.a", plainString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("jihgfedcba.a", plainString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", scientificString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-b.cdE-c", scientificString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("b.cdeEb", scientificString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("b.aEe", scientificString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", scientificString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", engineeringString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-bc.dE-d", engineeringString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", engineeringString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("ba.aEd", engineeringString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", engineeringString(ParsedDecimal.from(9876543210d), opts));
+    }
+
+    @Test
+    void testStringMethodAccuracy_sequence() {
+        // arrange
+        final double min = -1000;
+        final double max = 1000;
+        final double delta = 0.1;
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        Assertions.assertEquals(10.0, Double.parseDouble(scientificString(ParsedDecimal.from(10.0), stdOpts)));
+
+        for (double d = min; d <= max; d += delta) {
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    @Test
+    void testStringMethodAccuracy_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        double d;
+        for (int i = 0; i < 10_000; ++i) {
+            d = createRandomDouble(rand);
+
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    private static void checkFrom(final double d, final String digits, final int exponent) {
+        final boolean negative = Math.signum(d) < 0;
+
+        assertSimpleDecimal(ParsedDecimal.from(d), negative, digits, exponent);
+        assertSimpleDecimal(ParsedDecimal.from(-d), !negative, digits, exponent);
+    }
+
+    private static void checkToPlainString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::plainString, opts);
+    }
+
+    private static void checkToScientificString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::scientificString, opts);
+    }
+
+    private static void checkToEngineeringString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::engineeringString, opts);
+
+        // check the exponent value to make sure it is a multiple of 3
+        final String pos = engineeringString(ParsedDecimal.from(d), opts);
+        Assertions.assertEquals(0, parseExponent(pos, opts) % 3);
+
+        final String neg = engineeringString(ParsedDecimal.from(-d), opts);
+        Assertions.assertEquals(0, parseExponent(neg, opts) % 3);
+    }
+
+    private static int parseExponent(final String str, final ParsedDecimal.FormatOptions opts) {
+        final char[] expSep = opts.getExponentSeparatorChars();
+
+        final int expStartIdx = str.indexOf(String.valueOf(expSep));
+        if (expStartIdx > -1) {
+            int expIdx = expStartIdx + expSep.length;
+
+            boolean neg = false;
+            if (str.charAt(expIdx) == opts.getMinusSign()) {
+                ++expIdx;
+            }
+
+            final String expStr = str.substring(expIdx);
+            final int val = Integer.parseInt(expStr);
+            return neg
+                    ? -val
+                    : val;
+        }
+
+        return 0;
+    }
+
+    private static void checkToStringMethod(final double d, final String expected,
+            final BiFunction<ParsedDecimal, ParsedDecimal.FormatOptions, String> fn,
+            final ParsedDecimal.FormatOptions opts) {
+
+        final ParsedDecimal pos = ParsedDecimal.from(d);
+        final String actual = fn.apply(pos, opts);
+
+        Assertions.assertEquals(expected, actual);
+    }
+
+    private static void assertRound(final double d, final int roundExponent,
+            final boolean negative, final String digits, final int exponent) {
+        final ParsedDecimal dec = ParsedDecimal.from(d);
+        dec.round(roundExponent);
+
+        assertSimpleDecimal(dec, negative, digits, exponent);
+    }
+
+    private static void assertMaxPrecision(final double d, final int maxPrecision,
+            final boolean negative, final String digits, final int exponent) {
+        final ParsedDecimal dec = ParsedDecimal.from(d);
+        dec.maxPrecision(maxPrecision);
+
+        assertSimpleDecimal(dec, negative, digits, exponent);
+    }
+
+    private static void assertSimpleDecimal(final ParsedDecimal parsed, final boolean negative, final String digits,
+            final int exponent) {
+        Assertions.assertEquals(negative, parsed.negative);
+        Assertions.assertEquals(digits, digitString(parsed));
+        Assertions.assertEquals(exponent, parsed.getExponent());
+        Assertions.assertEquals(digits.length(), parsed.digitCount);
+        Assertions.assertEquals(exponent, parsed.getScientificExponent() - digits.length() + 1);
+    }
+
+    private static void assertThrowsWithMessage(final Executable fn, final Class<? extends Throwable> type,
+            final String msg) {
+        Throwable exc = Assertions.assertThrows(type, fn);
+        Assertions.assertEquals(msg, exc.getMessage());
+    }
+
+    private static double createRandomDouble(final UniformRandomProvider rng) {
+        final long mask = ((1L << 52) - 1) | 1L << 63;
+        final long bits = rng.nextLong() & mask;
+        final long exp = rng.nextInt(2045) + 1;
+        return Double.longBitsToDouble(bits | (exp << 52));
+    }
+
+    /** Get the raw digits in the given decimal as a string.
+     * @param dec decimal instancE
+     * @return decimal digits as a string
+     */
+    private static String digitString(final ParsedDecimal dec) {
+        final StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < dec.digitCount; ++i) {
+            sb.append(dec.digits[i]);
+        }
+        return sb.toString();
+    }
+
+    private static String plainString(final ParsedDecimal dec, final ParsedDecimal.FormatOptions opts) {
+//        try {
+//            StringBuilder sb = new StringBuilder();
+//            dec.toPlainString(sb, opts);
+//            return sb.toString();
+//        } catch (IOException exc) {
+//            throw new UncheckedIOException(exc);
+//        }
+        return dec.toPlainString(opts);
+    }
+
+    private static String scientificString(final ParsedDecimal dec, final ParsedDecimal.FormatOptions opts) {
+//        try {
+//            StringBuilder sb = new StringBuilder();
+//            dec.toScientificString(sb, opts);
+//            return sb.toString();
+//        } catch (IOException exc) {
+//            throw new UncheckedIOException(exc);
+//        }
+        return dec.toScientificString(opts);
+    }
+
+    private static String engineeringString(final ParsedDecimal dec, final ParsedDecimal.FormatOptions opts) {
+//        try {
+//            StringBuilder sb = new StringBuilder();
+//            dec.toEngineeringString(sb, opts);
+//            return sb.toString();
+//        } catch (IOException exc) {
+//            throw new UncheckedIOException(exc);
+//        }

Review comment:
       The three methods above have a similar code block commented out (with small difference being the method called I think). Was it intentional? Is it helpful to other devs that may need to change those methods? If so I think we can leave it, otherwise perhaps remove the commented-out code?

##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,724 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return {@code true} if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return {@code true} if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return {@code true} if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return {@code true} if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int targetExponent = digitCount + exponent - decimalPos;
+        final int absTargetExponent = Math.abs(targetExponent);
+        final boolean includeExponent = shouldIncludeExponent(targetExponent, opts);
+        final boolean negativeExponent = targetExponent < 0;
+
+        // determine the size of the full formatted string, including the number of
+        // characters needed for the exponent digits
+        int size = getDigitStringSize(decimalPos, opts);
+        int exponentDigitCount = 0;
+        if (includeExponent) {
+            exponentDigitCount = absTargetExponent > 0
+                    ? (int) Math.floor(Math.log10(absTargetExponent)) + 1
+                    : 1;
+
+            size += opts.getExponentSeparatorChars().length + exponentDigitCount;
+            if (negativeExponent) {
+                ++size;
+            }
+        }
+
+        prepareOutput(size);
+
+        // append the portion before the exponent field
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (includeExponent) {
+            // append the exponent field
+            append(opts.getExponentSeparatorChars());
+
+            if (negativeExponent) {
+                append(opts.getMinusSign());
+            }
+
+            // append the exponent digits themselves; compute the
+            // string representation directly and add it to the output
+            // buffer to avoid the overhead of Integer.toString()
+            final char[] localizedDigits = opts.getDigits();
+            int rem = absTargetExponent;
+            for (int i = size - 1; i >= outputIdx; --i) {
+                outputChars[i] = localizedDigits[rem % DECIMAL_RADIX];
+                rem /= DECIMAL_RADIX;
+            }
+            outputIdx = size;
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }

Review comment:
       We are using the `i` index only to access the array element, so that could be
   
   ```
           for (char aChar : chars) {
               append(aChar);
           }
   ```
   
   But I guess resulting bytecode should be the same, so either way is fine IMO

##########
File path: src/test/java/org/apache/commons/text/numbers/DoubleFormatTest.java
##########
@@ -0,0 +1,595 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormat;
+import java.text.DecimalFormatSymbols;
+import java.util.Locale;
+import java.util.Random;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class DoubleFormatTest {
+
+    @Test
+    void testBuilder_illegalArgs() {
+        // arrange
+        final DoubleFormat.Builder builder = DoubleFormat.PLAIN.builder();
+
+        // act/assert
+        Assertions.assertThrows(NullPointerException.class, () -> builder.digits(null));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> builder.digits("a"));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> builder.digits("0123456789a"));
+
+        Assertions.assertThrows(NullPointerException.class, () -> builder.exponentSeparator(null));
+        Assertions.assertThrows(NullPointerException.class, () -> builder.infinity(null));
+        Assertions.assertThrows(NullPointerException.class, () -> builder.nan(null));
+        Assertions.assertThrows(NullPointerException.class, () -> builder.formatSymbols(null));
+    }
+
+    @Test
+    void testFormatAccuracy() {
+        // act/assert
+        checkFormatAccuracyWithDefaults(DoubleFormat.PLAIN);
+        checkFormatAccuracyWithDefaults(DoubleFormat.MIXED);
+        checkFormatAccuracyWithDefaults(DoubleFormat.SCIENTIFIC);
+        checkFormatAccuracyWithDefaults(DoubleFormat.ENGINEERING);
+    }
+
+    @Test
+    void testPlain_defaults() {
+        // arrange
+        DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder()
+            .build();
+
+        // act/assert
+        checkFormat(fmt, 0.00001, "0.00001");
+        checkFormat(fmt, -0.0001, "-0.0001");
+        checkFormat(fmt, 0.001, "0.001");
+        checkFormat(fmt, -0.01, "-0.01");
+        checkFormat(fmt, 0.1, "0.1");
+        checkFormat(fmt, -0.0, "-0.0");
+        checkFormat(fmt, 0.0, "0.0");
+        checkFormat(fmt, -1.0, "-1.0");
+        checkFormat(fmt, 10.0, "10.0");
+        checkFormat(fmt, -100.0, "-100.0");
+        checkFormat(fmt, 1000.0, "1000.0");
+        checkFormat(fmt, -10000.0, "-10000.0");
+        checkFormat(fmt, 100000.0, "100000.0");
+        checkFormat(fmt, -1000000.0, "-1000000.0");
+        checkFormat(fmt, 10000000.0, "10000000.0");
+        checkFormat(fmt, -100000000.0, "-100000000.0");
+
+        checkFormat(fmt, 1.25e-3, "0.00125");
+        checkFormat(fmt, -9.975e-4, "-0.0009975");
+        checkFormat(fmt, 12345, "12345.0");
+        checkFormat(fmt, -9_999_999, "-9999999.0");
+        checkFormat(fmt, 1.00001e7, "10000100.0");
+
+        checkFormat(fmt, Float.MAX_VALUE, "340282346638528860000000000000000000000.0");
+        checkFormat(fmt, -Float.MIN_VALUE, "-0.000000000000000000000000000000000000000000001401298464324817");
+        checkFormat(fmt, Float.MIN_NORMAL, "0.000000000000000000000000000000000000011754943508222875");
+        checkFormat(fmt, Math.PI, "3.141592653589793");
+        checkFormat(fmt, Math.E, "2.718281828459045");
+    }
+
+    @Test
+    void testPlain_custom() {
+        // arrange
+        DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder()
+            .maxPrecision(3)
+            .minDecimalExponent(-3)
+            .allowSignedZero(false)
+            .includeFractionPlaceholder(false)
+            .decimalSeparator(',')
+            .exponentSeparator("e")
+            .infinity("inf")
+            .nan("nan")
+            .minusSign('!')
+            .build();
+
+        // act/assert
+        checkFormat(fmt, Double.NaN, "nan");
+        checkFormat(fmt, Double.POSITIVE_INFINITY, "inf");
+        checkFormat(fmt, Double.NEGATIVE_INFINITY, "!inf");
+
+        checkFormat(fmt, 0.00001, "0");
+        checkFormat(fmt, -0.0001, "0");
+        checkFormat(fmt, 0.001, "0,001");
+        checkFormat(fmt, -0.01, "!0,01");
+        checkFormat(fmt, 0.1, "0,1");
+        checkFormat(fmt, -0.0, "0");
+        checkFormat(fmt, 0.0, "0");
+        checkFormat(fmt, -1.0, "!1");
+        checkFormat(fmt, 10.0, "10");
+        checkFormat(fmt, -100.0, "!100");
+        checkFormat(fmt, 1000.0, "1000");
+        checkFormat(fmt, -10000.0, "!10000");
+        checkFormat(fmt, 100000.0, "100000");
+        checkFormat(fmt, -1000000.0, "!1000000");
+        checkFormat(fmt, 10000000.0, "10000000");
+        checkFormat(fmt, -100000000.0, "!100000000");
+
+        checkFormat(fmt, 1.25e-3, "0,001");
+        checkFormat(fmt, -9.975e-4, "!0,001");
+        checkFormat(fmt, 12345, "12300");
+        checkFormat(fmt, -9_999_999, "!10000000");
+        checkFormat(fmt, 1.00001e7, "10000000");
+
+        checkFormat(fmt, Float.MAX_VALUE, "340000000000000000000000000000000000000");
+        checkFormat(fmt, -Float.MIN_VALUE, "0");
+        checkFormat(fmt, Float.MIN_NORMAL, "0");
+        checkFormat(fmt, Math.PI, "3,14");
+        checkFormat(fmt, Math.E, "2,72");
+    }
+
+    @Test
+    void testPlain_localeFormatComparison() {
+        // act/assert
+        checkLocalizedFormats("0.0##", loc -> DoubleFormat.PLAIN.builder()
+                .minDecimalExponent(-3)
+                .formatSymbols(DecimalFormatSymbols.getInstance(loc))
+                .build());
+        checkLocalizedFormats("#,##0.0##", loc -> DoubleFormat.PLAIN.builder()
+                .minDecimalExponent(-3)
+                .groupThousands(true)
+                .formatSymbols(DecimalFormatSymbols.getInstance(loc))
+                .build());
+    }
+
+    @Test
+    void testScientific_defaults() {
+        // arrange
+        final DoubleFunction<String> fmt = DoubleFormat.SCIENTIFIC.builder().build();
+
+        // act/assert
+        checkDefaultFormatSpecial(fmt);
+
+        checkFormat(fmt, 0.00001, "1.0E-5");
+        checkFormat(fmt, -0.0001, "-1.0E-4");
+        checkFormat(fmt, 0.001, "1.0E-3");
+        checkFormat(fmt, -0.01, "-1.0E-2");
+        checkFormat(fmt, 0.1, "1.0E-1");
+        checkFormat(fmt, -0.0, "-0.0");
+        checkFormat(fmt, 0.0, "0.0");
+        checkFormat(fmt, -1.0, "-1.0");
+        checkFormat(fmt, 10.0, "1.0E1");
+        checkFormat(fmt, -100.0, "-1.0E2");
+        checkFormat(fmt, 1000.0, "1.0E3");
+        checkFormat(fmt, -10000.0, "-1.0E4");
+        checkFormat(fmt, 100000.0, "1.0E5");
+        checkFormat(fmt, -1000000.0, "-1.0E6");
+        checkFormat(fmt, 10000000.0, "1.0E7");
+        checkFormat(fmt, -100000000.0, "-1.0E8");
+
+        checkFormat(fmt, 1.25e-3, "1.25E-3");
+        checkFormat(fmt, -9.975e-4, "-9.975E-4");
+        checkFormat(fmt, 12345, "1.2345E4");
+        checkFormat(fmt, -9_999_999, "-9.999999E6");
+        checkFormat(fmt, 1.00001e7, "1.00001E7");
+
+        checkFormat(fmt, Double.MAX_VALUE, "1.7976931348623157E308");
+        checkFormat(fmt, Double.MIN_VALUE, "4.9E-324");
+        checkFormat(fmt, Double.MIN_NORMAL, "2.2250738585072014E-308");
+        checkFormat(fmt, Math.PI, "3.141592653589793");
+        checkFormat(fmt, Math.E, "2.718281828459045");
+    }
+
+    @Test
+    void testScientific_custom() {
+        // arrange
+        final DoubleFunction<String> fmt = DoubleFormat.SCIENTIFIC.builder()
+                .maxPrecision(3)
+                .minDecimalExponent(-3)
+                .allowSignedZero(false)
+                .includeFractionPlaceholder(false)
+                .decimalSeparator(',')
+                .exponentSeparator("e")
+                .infinity("inf")
+                .nan("nan")
+                .minusSign('!')
+                .build();
+
+        // act/assert
+        checkFormat(fmt, Double.NaN, "nan");
+        checkFormat(fmt, Double.POSITIVE_INFINITY, "inf");
+        checkFormat(fmt, Double.NEGATIVE_INFINITY, "!inf");
+
+        checkFormat(fmt, 0.00001, "0");
+        checkFormat(fmt, -0.0001, "0");
+        checkFormat(fmt, 0.001, "1e!3");
+        checkFormat(fmt, -0.01, "!1e!2");
+        checkFormat(fmt, 0.1, "1e!1");
+        checkFormat(fmt, -0.0, "0");
+        checkFormat(fmt, 0.0, "0");
+        checkFormat(fmt, -1.0, "!1");
+        checkFormat(fmt, 10.0, "1e1");
+        checkFormat(fmt, -100.0, "!1e2");
+        checkFormat(fmt, 1000.0, "1e3");
+        checkFormat(fmt, -10000.0, "!1e4");
+        checkFormat(fmt, 100000.0, "1e5");
+        checkFormat(fmt, -1000000.0, "!1e6");
+        checkFormat(fmt, 10000000.0, "1e7");
+        checkFormat(fmt, -100000000.0, "!1e8");
+
+        checkFormat(fmt, 1.25e-3, "1e!3");
+        checkFormat(fmt, -9.975e-4, "!1e!3");
+        checkFormat(fmt, 12345, "1,23e4");
+        checkFormat(fmt, -9_999_999, "!1e7");
+        checkFormat(fmt, 1.00001e7, "1e7");
+
+        checkFormat(fmt, Double.MAX_VALUE, "1,8e308");
+        checkFormat(fmt, Double.MIN_VALUE, "0");
+        checkFormat(fmt, Double.MIN_NORMAL, "0");
+        checkFormat(fmt, Math.PI, "3,14");
+        checkFormat(fmt, Math.E, "2,72");
+    }
+
+    @Test
+    void testScientific_localeFormatComparison() {
+        // act/assert
+        checkLocalizedFormats("0.0##E0", loc -> DoubleFormat.SCIENTIFIC.builder()
+                .maxPrecision(4)
+                .alwaysIncludeExponent(true)
+                .formatSymbols(DecimalFormatSymbols.getInstance(loc))
+                .build());
+    }
+
+    @Test
+    void testEngineering_defaults() {
+        // act
+        final DoubleFunction<String> fmt = DoubleFormat.ENGINEERING.builder()
+                .build();
+
+        // act/assert
+        checkDefaultFormatSpecial(fmt);
+
+        checkFormat(fmt, 0.00001, "10.0E-6");
+        checkFormat(fmt, -0.0001, "-100.0E-6");
+        checkFormat(fmt, 0.001, "1.0E-3");
+        checkFormat(fmt, -0.01, "-10.0E-3");
+        checkFormat(fmt, 0.1, "100.0E-3");
+        checkFormat(fmt, -0.0, "-0.0");
+        checkFormat(fmt, 0.0, "0.0");
+        checkFormat(fmt, -1.0, "-1.0");
+        checkFormat(fmt, 10.0, "10.0");
+        checkFormat(fmt, -100.0, "-100.0");
+        checkFormat(fmt, 1000.0, "1.0E3");
+        checkFormat(fmt, -10000.0, "-10.0E3");
+        checkFormat(fmt, 100000.0, "100.0E3");
+        checkFormat(fmt, -1000000.0, "-1.0E6");
+        checkFormat(fmt, 10000000.0, "10.0E6");
+        checkFormat(fmt, -100000000.0, "-100.0E6");
+
+        checkFormat(fmt, 1.25e-3, "1.25E-3");
+        checkFormat(fmt, -9.975e-4, "-997.5E-6");
+        checkFormat(fmt, 12345, "12.345E3");
+        checkFormat(fmt, -9_999_999, "-9.999999E6");
+        checkFormat(fmt, 1.00001e7, "10.0001E6");
+
+        checkFormat(fmt, Double.MAX_VALUE, "179.76931348623157E306");
+        checkFormat(fmt, Double.MIN_VALUE, "4.9E-324");
+        checkFormat(fmt, Double.MIN_NORMAL, "22.250738585072014E-309");
+        checkFormat(fmt, Math.PI, "3.141592653589793");
+        checkFormat(fmt, Math.E, "2.718281828459045");
+    }
+
+    @Test
+    void testEngineering_custom() {
+        // act
+        final DoubleFunction<String> fmt = DoubleFormat.ENGINEERING.builder()
+                .maxPrecision(3)
+                .minDecimalExponent(-3)
+                .allowSignedZero(false)
+                .includeFractionPlaceholder(false)
+                .decimalSeparator(',')
+                .exponentSeparator("e")
+                .infinity("inf")
+                .nan("nan")
+                .minusSign('!')
+                .build();
+
+        // act/assert
+        checkFormat(fmt, Double.NaN, "nan");
+        checkFormat(fmt, Double.POSITIVE_INFINITY, "inf");
+        checkFormat(fmt, Double.NEGATIVE_INFINITY, "!inf");
+
+        checkFormat(fmt, 0.00001, "0");
+        checkFormat(fmt, -0.0001, "0");
+        checkFormat(fmt, 0.001, "1e!3");
+        checkFormat(fmt, -0.01, "!10e!3");
+        checkFormat(fmt, 0.1, "100e!3");
+        checkFormat(fmt, -0.0, "0");
+        checkFormat(fmt, 0.0, "0");
+        checkFormat(fmt, -1.0, "!1");
+        checkFormat(fmt, 10.0, "10");
+        checkFormat(fmt, -100.0, "!100");
+        checkFormat(fmt, 1000.0, "1e3");
+        checkFormat(fmt, -10000.0, "!10e3");
+        checkFormat(fmt, 100000.0, "100e3");
+        checkFormat(fmt, -1000000.0, "!1e6");
+        checkFormat(fmt, 10000000.0, "10e6");
+        checkFormat(fmt, -100000000.0, "!100e6");
+
+        checkFormat(fmt, 1.25e-3, "1e!3");
+        checkFormat(fmt, -9.975e-4, "!1e!3");
+        checkFormat(fmt, 12345, "12,3e3");
+        checkFormat(fmt, -9_999_999, "!10e6");
+        checkFormat(fmt, 1.00001e7, "10e6");
+
+        checkFormat(fmt, Double.MAX_VALUE, "180e306");
+        checkFormat(fmt, Double.MIN_VALUE, "0");
+        checkFormat(fmt, Double.MIN_NORMAL, "0");
+        checkFormat(fmt, Math.PI, "3,14");
+        checkFormat(fmt, Math.E, "2,72");
+    }
+
+    @Test
+    void testEngineering_localeFormatComparison() {
+        // act/assert
+        checkLocalizedFormats("##0.0##E0", loc -> DoubleFormat.ENGINEERING.builder()
+                .maxPrecision(6)
+                .alwaysIncludeExponent(true)
+                .formatSymbols(DecimalFormatSymbols.getInstance(loc))
+                .build());
+    }
+
+    @Test
+    void testMixed_defaults() {
+        // arrange
+        final DoubleFunction<String> fmt = DoubleFormat.MIXED.builder().build();
+
+        // act/assert
+        checkDefaultFormatSpecial(fmt);
+
+        checkFormat(fmt, 0.00001, "1.0E-5");
+        checkFormat(fmt, -0.0001, "-1.0E-4");
+        checkFormat(fmt, 0.001, "0.001");
+        checkFormat(fmt, -0.01, "-0.01");
+        checkFormat(fmt, 0.1, "0.1");
+        checkFormat(fmt, -0.0, "-0.0");
+        checkFormat(fmt, 0.0, "0.0");
+        checkFormat(fmt, -1.0, "-1.0");
+        checkFormat(fmt, 10.0, "10.0");
+        checkFormat(fmt, -100.0, "-100.0");
+        checkFormat(fmt, 1000.0, "1000.0");
+        checkFormat(fmt, -10000.0, "-10000.0");
+        checkFormat(fmt, 100000.0, "100000.0");
+        checkFormat(fmt, -1000000.0, "-1000000.0");
+        checkFormat(fmt, 10000000.0, "1.0E7");
+        checkFormat(fmt, -100000000.0, "-1.0E8");
+
+        checkFormat(fmt, 1.25e-3, "0.00125");
+        checkFormat(fmt, -9.975e-4, "-9.975E-4");
+        checkFormat(fmt, 12345, "12345.0");
+        checkFormat(fmt, -9_999_999, "-9999999.0");
+        checkFormat(fmt, 1.00001e7, "1.00001E7");
+
+        checkFormat(fmt, Double.MAX_VALUE, "1.7976931348623157E308");
+        checkFormat(fmt, Double.MIN_VALUE, "4.9E-324");
+        checkFormat(fmt, Double.MIN_NORMAL, "2.2250738585072014E-308");
+        checkFormat(fmt, Math.PI, "3.141592653589793");
+        checkFormat(fmt, Math.E, "2.718281828459045");
+    }
+
+    @Test
+    void testMixed_custom() {
+        // arrange
+        final DoubleFunction<String> fmt = DoubleFormat.MIXED.builder()
+                .maxPrecision(3)
+                .minDecimalExponent(-3)
+                .allowSignedZero(false)
+                .includeFractionPlaceholder(false)
+                .decimalSeparator(',')
+                .plainFormatMaxDecimalExponent(4)
+                .plainFormatMinDecimalExponent(-1)
+                .exponentSeparator("e")
+                .infinity("inf")
+                .nan("nan")
+                .minusSign('!')
+                .build();
+
+        // act/assert
+        checkFormat(fmt, Double.NaN, "nan");
+        checkFormat(fmt, Double.POSITIVE_INFINITY, "inf");
+        checkFormat(fmt, Double.NEGATIVE_INFINITY, "!inf");
+
+        checkFormat(fmt, 0.00001, "0");
+        checkFormat(fmt, -0.0001, "0");
+        checkFormat(fmt, 0.001, "1e!3");
+        checkFormat(fmt, -0.01, "!1e!2");
+        checkFormat(fmt, 0.1, "0,1");
+        checkFormat(fmt, -0.0, "0");
+        checkFormat(fmt, 0.0, "0");
+        checkFormat(fmt, -1.0, "!1");
+        checkFormat(fmt, 10.0, "10");
+        checkFormat(fmt, -100.0, "!100");
+        checkFormat(fmt, 1000.0, "1000");
+        checkFormat(fmt, -10000.0, "!10000");
+        checkFormat(fmt, 100000.0, "1e5");
+        checkFormat(fmt, -1000000.0, "!1e6");
+        checkFormat(fmt, 10000000.0, "1e7");
+        checkFormat(fmt, -100000000.0, "!1e8");
+
+        checkFormat(fmt, 1.25e-3, "1e!3");
+        checkFormat(fmt, -9.975e-4, "!1e!3");
+        checkFormat(fmt, 12345, "12300");
+        checkFormat(fmt, -9_999_999, "!1e7");
+        checkFormat(fmt, 1.00001e7, "1e7");
+
+        checkFormat(fmt, Double.MAX_VALUE, "1,8e308");
+        checkFormat(fmt, Double.MIN_VALUE, "0");
+        checkFormat(fmt, Double.MIN_NORMAL, "0");
+        checkFormat(fmt, Math.PI, "3,14");
+        checkFormat(fmt, Math.E, "2,72");
+    }
+
+    @Test
+    void testCustomDigitString() {
+        // arrange
+        final String digits = "abcdefghij";
+        final DoubleFunction<String> plain = DoubleFormat.PLAIN.builder().digits(digits).build();
+        final DoubleFunction<String> sci = DoubleFormat.SCIENTIFIC.builder().digits(digits).build();
+        final DoubleFunction<String> eng = DoubleFormat.ENGINEERING.builder().digits(digits).build();
+        final DoubleFunction<String> mixed = DoubleFormat.MIXED.builder().digits(digits).build();
+
+        // act/assert
+        checkFormat(plain, 9876543210.0, "jihgfedcba.a");
+        checkFormat(sci, 9876543210.0, "j.ihgfedcbEj");
+        checkFormat(eng, 9876543210.0, "j.ihgfedcbEj");
+        checkFormat(mixed, 9876543210.0, "j.ihgfedcbEj");
+    }
+
+    /** Check that the given format type correctly formats doubles when using the
+     * default configuration options. The format itself is not checked; only the
+     * fact that the input double can be successfully recovered using {@link Double#parseDouble(String)}
+     * is asserted.
+     * @param type

Review comment:
       ```suggestion
        * @param type format type
   ```
   
   To satisfy some IDE/linters that complain about empty parameters (e.g. IntelliJ) ?

##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;
+
+        /** Construct a new instance that delegates double function construction
+         * to the given factory object.
+         * @param factory factory function
+         */
+        private Builder(final Function<Builder, DoubleFunction<String>> factory) {
+            this.factory = factory;
+        }
+
+        /** Set the maximum number of significant decimal digits used in format
+         * results. A value of {@code 0} indicates no limit. The default value is {@code 0}.
+         * @param maxPrecision maximum precision
+         * @return this instance
+         */
+        public Builder maxPrecision(final int maxPrecision) {
+            this.maxPrecision = maxPrecision;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for formatted strings. No digits with an
+         * absolute value of less than <code>10<sup>minDecimalExponent</sup></code> will
+         * be included in format results. If the number being formatted does not contain
+         * any such digits, then zero is returned. For example, if {@code minDecimalExponent}
+         * is set to {@code -2} and the number {@code 3.14159} is formatted, the plain
+         * format result will be {@code "3.14"}. If {@code 0.001} is formatted, then the
+         * result is the zero string.
+         * @param minDecimalExponent minimum decimal exponent
+         * @return this instance
+         */
+        public Builder minDecimalExponent(final int minDecimalExponent) {
+            this.minDecimalExponent = minDecimalExponent;
+            return this;
+        }
+
+        /** Set the maximum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code 2}, the number {@code 999} will be formatted as {@code "999.0"}
+         * while {@code 1000} will be formatted as {@code "1.0E3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMaxDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMaxDecimalExponent(final int plainFormatMaxDecimalExponent) {
+            this.plainFormatMaxDecimalExponent = plainFormatMaxDecimalExponent;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code -2}, the number {@code 0.01} will be formatted as {@code "0.01"}
+         * while {@code 0.0099} will be formatted as {@code "9.9E-3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMinDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMinDecimalExponent(final int plainFormatMinDecimalExponent) {
+            this.plainFormatMinDecimalExponent = plainFormatMinDecimalExponent;
+            return this;
+        }
+
+        /** Set the flag determining whether or not the zero string may be returned with the minus
+         * sign or if it will always be returned in the positive form. For example, if set to true,
+         * the string {@code "-0.0"} may be returned for some input numbers. If false, only {@code "0.0"}
+         * will be returned, regardless of the sign of the input number. The default value is {@code true}.
+         * @param signedZero if true, the zero string may be returned with a preceding minus sign; if false,
+         *      the zero string will only be returned in its positive form
+         * @return this instance
+         */
+        public Builder allowSignedZero(final boolean signedZero) {
+            this.signedZero = signedZero;
+            return this;
+        }
+
+        /** Set the string containing the digit characters 0-9, in that order. The
+         * default value is the string {@code "0123456789"}.
+         * @param digits string containing the digit characters 0-9
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         * @throws IllegalArgumentException if the argument does not have a length of exactly 10
+         */
+        public Builder digits(final String digits) {
+            Objects.requireNonNull(digits, "Digits string cannot be null");
+            if (digits.length() != DEFAULT_DECIMAL_DIGITS.length()) {
+                throw new IllegalArgumentException("Digits string must contain exactly "
+                        + DEFAULT_DECIMAL_DIGITS.length() + " characters.");
+            }
+
+            this.digits = digits;
+            return this;
+        }
+
+        /** Set the flag determining whether or not a zero character is added in the fraction position
+         * when no fractional value is present. For example, if set to true, the number {@code 1} would
+         * be formatted as {@code "1.0"}. If false, it would be formatted as {@code "1"}. The default
+         * value is {@code true}.
+         * @param fractionPlaceholder if true, a zero character is placed in the fraction position when
+         *      no fractional value is present; if false, fractional digits are only included when needed
+         * @return this instance
+         */
+        public Builder includeFractionPlaceholder(final boolean fractionPlaceholder) {
+            this.fractionPlaceholder = fractionPlaceholder;
+            return this;
+        }
+
+        /** Set the character used as the minus sign.
+         * @param minusSign character to use as the minus sign
+         * @return this instance
+         */
+        public Builder minusSign(final char minusSign) {
+            this.minusSign = minusSign;
+            return this;
+        }
+
+        /** Set the decimal separator character, i.e., the character placed between the
+         * whole number and fractional portions of the formatted strings. The default value
+         * is {@code '.'}.
+         * @param decimalSeparator decimal separator character
+         * @return this instance
+         */
+        public Builder decimalSeparator(final char decimalSeparator) {
+            this.decimalSeparator = decimalSeparator;
+            return this;
+        }
+
+        /** Set the character used to separate groups of thousands. Default value is {@code ','}.
+         * @param groupingSeparator character used to separate groups of thousands
+         * @return this instance
+         * @see #groupThousands(boolean)
+         */
+        public Builder groupingSeparator(final char groupingSeparator) {
+            this.groupingSeparator = groupingSeparator;
+            return this;
+        }
+
+        /** If set to true, thousands will be grouped with the
+         * {@link #groupingSeparator(char) grouping separator}. For example, if set to true,
+         * the number {@code 1000} could be formatted as {@code "1,000"}. This property only applies
+         * to the {@link DoubleFormat#PLAIN PLAIN} format. Default value is {@code false}.
+         * @param groupThousands if true, thousands will be grouped
+         * @return this instance
+         * @see #groupingSeparator(char)
+         */
+        public Builder groupThousands(final boolean groupThousands) {
+            this.groupThousands = groupThousands;
+            return this;
+        }
+
+        /** Set the exponent separator character, i.e., the string placed between
+         * the mantissa and the exponent. The default value is {@code "E"}, as in
+         * {@code "1.2E6"}.
+         * @param exponentSeparator exponent separator string
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder exponentSeparator(final String exponentSeparator) {
+            Objects.requireNonNull(exponentSeparator, "Exponent separator cannot be null");
+
+            this.exponentSeparator = exponentSeparator;
+            return this;
+        }
+
+        /** Set the flag indicating if an exponent value should always be included in the
+         * formatted value, even if the exponent value is zero. This property only applies
+         * to formats that use scientific notation, namely
+         * {@link DoubleFormat#SCIENTIFIC SCIENTIFIC},
+         * {@link DoubleFormat#ENGINEERING ENGINEERING}, and
+         * {@link DoubleFormat#MIXED MIXED}. The default value is {@code false}.
+         * @param alwaysIncludeExponent if true, exponents will always be included in formatted
+         *      output even if the exponent value is zero
+         * @return this instance
+         */
+        public Builder alwaysIncludeExponent(final boolean alwaysIncludeExponent) {
+            this.alwaysIncludeExponent = alwaysIncludeExponent;
+            return this;
+        }
+
+        /** Set the string used to represent infinity. For negative infinity, this string
+         * is prefixed with the {@link #minusSign(char) minus sign}.
+         * @param infinity string used to represent infinity
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder infinity(final String infinity) {
+            Objects.requireNonNull(infinity, "Infinity string cannot be null");
+
+            this.infinity = infinity;
+            return this;
+        }
+
+        /** Set the string used to represent {@link Double#NaN}.
+         * @param nan string used to represent {@link Double#NaN}
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder nan(final String nan) {
+            Objects.requireNonNull(nan, "NaN string cannot be null");
+
+            this.nan = nan;
+            return this;
+        }
+
+        /** Configure this instance with the given format symbols. The following values
+         * are set:
+         * <ul>
+         *  <li>{@link #digits(String) digit characters}</li>
+         *  <li>{@link #decimalSeparator(char) decimal separator}</li>
+         *  <li>{@link #groupingSeparator(char) thousands grouping separator}</li>
+         *  <li>{@link #minusSign(char) minus sign}</li>
+         *  <li>{@link #exponentSeparator(String) exponent separator}</li>
+         *  <li>{@link #infinity(String) infinity}</li>
+         *  <li>{@link #nan(String) NaN}</li>
+         * </ul>
+         * The digit character string is constructed by starting at the configured
+         * {@link DecimalFormatSymbols#getZeroDigit() zero digit} and adding the next
+         * 9 consecutive characters.
+         * @param symbols format symbols
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder formatSymbols(final DecimalFormatSymbols symbols) {
+            Objects.requireNonNull(symbols, "Decimal format symbols cannot be null");
+
+            return digits(getDigitString(symbols))
+                    .decimalSeparator(symbols.getDecimalSeparator())
+                    .groupingSeparator(symbols.getGroupingSeparator())
+                    .minusSign(symbols.getMinusSign())
+                    .exponentSeparator(symbols.getExponentSeparator())
+                    .infinity(symbols.getInfinity())
+                    .nan(symbols.getNaN());
+        }
+
+        /** Get a string containing the localized digits 0-9 for the given symbols object. The
+         * string is constructed by starting at the {@link DecimalFormatSymbols#getZeroDigit() zero digit}
+         * and adding the next 9 consecutive characters.
+         * @param symbols symbols object
+         * @return string containing the localized digits 0-9
+         */
+        private String getDigitString(final DecimalFormatSymbols symbols) {
+            final int zeroDelta = symbols.getZeroDigit() - DEFAULT_DECIMAL_DIGITS.charAt(0);
+
+            final char[] digitChars = new char[DEFAULT_DECIMAL_DIGITS.length()];
+            for (int i = 0; i < DEFAULT_DECIMAL_DIGITS.length(); ++i) {
+                digitChars[i] = (char) (DEFAULT_DECIMAL_DIGITS.charAt(i) + zeroDelta);
+            }
+
+            return String.valueOf(digitChars);
+        }
+
+        /** Construct a new double format function.
+         * @return format function
+         */
+        public DoubleFunction<String> build() {
+            return factory.apply(this);
+        }
+    }
+
+    /** Base class for standard double formatting classes.
+     */
+    private abstract static class AbstractDoubleFormat
+        implements DoubleFunction<String>, ParsedDecimal.FormatOptions {
+
+        /** Maximum precision; 0 indicates no limit. */
+        private final int maxPrecision;
+
+        /** Minimum decimal exponent. */
+        private final int minDecimalExponent;
+
+        /** String representing positive infinity. */
+        private final String postiveInfinity;
+
+        /** String representing negative infinity. */
+        private final String negativeInfinity;
+
+        /** String representing NaN. */
+        private final String nan;
+
+        /** Flag determining if fraction placeholders should be used. */
+        private final boolean fractionPlaceholder;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private final boolean signedZero;
+
+        /** String containing the digits 0-9. */
+        private final char[] digits;
+
+        /** Decimal separator character. */
+        private final char decimalSeparator;
+
+        /** Thousands grouping separator. */
+        private final char groupingSeparator;
+
+        /** Flag indicating if thousands should be grouped. */
+        private final boolean groupThousands;
+
+        /** Minus sign character. */
+        private final char minusSign;

Review comment:
       @darkma773r for no special reason, other than curiosity, I decided to focus my review using an example with negative numbers.
   
   Found one typo, but rest of the code looks really good! I left a question somewhere in this revuiew about negative numbers I think, but here's another one:
   
   * What should we do about using a digit for the minus sign? Is that valid in other Java classes?
   
   e.g.
   
   ```java
           DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder()
                   .allowSignedZero(true)
                   .minusSign('1')
                   .build();
           String s = fmt.apply(-1.3);
           System.out.println(s); // gives 11.3
   ```
   
   In this case, I think the user got what s/he asked for. Since `1` is the minus sign, well, we just return the value `-1.3` formatted as `11.3`. However, since it's a valid decimal, I wonder if that wouldn't cause problems to users, and problems that could be hard to troubleshoot/debug.
   
   One option would be to throw an error for digits… but I am not sure if that's a good idea. After all, there could be other characters that would cause confusion, like using `+` for `minusSign`, or `E`. Perhaps just add a note in the docs for users? WDYT?

##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;

Review comment:
       Ah, I had used
   
   ```java
           DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder()
                   .maxPrecision(3)
                   .alwaysIncludeExponent(true)
                   .build();
           String s = fmt.apply((1./1));
           System.out.println(s);
   ```
   
   Expecting it to be something like `1.0E0`, but instead it was `1.0`. Then I saw a comment in the builder's `alwaysIncludeExponent` method, that only SCIENTIFIC formats (including mixed/engineering I think) supported this.
   
   I don't think we could throw an error for cases where we used the `Builder` to construct a formatter and received no errors. In this case, even though I used `.alwaysIncludeExponent(true)` and the builder did not throw any errors, whoever read the docs knows that this won't be used… but unless the user reads the documentation, I guess the final result could be not necessarily what the user expected…. not sure if there's any simple way to prevent cases like this.

##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;
+
+        /** Construct a new instance that delegates double function construction
+         * to the given factory object.
+         * @param factory factory function
+         */
+        private Builder(final Function<Builder, DoubleFunction<String>> factory) {
+            this.factory = factory;
+        }
+
+        /** Set the maximum number of significant decimal digits used in format
+         * results. A value of {@code 0} indicates no limit. The default value is {@code 0}.
+         * @param maxPrecision maximum precision
+         * @return this instance
+         */
+        public Builder maxPrecision(final int maxPrecision) {
+            this.maxPrecision = maxPrecision;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for formatted strings. No digits with an
+         * absolute value of less than <code>10<sup>minDecimalExponent</sup></code> will
+         * be included in format results. If the number being formatted does not contain
+         * any such digits, then zero is returned. For example, if {@code minDecimalExponent}
+         * is set to {@code -2} and the number {@code 3.14159} is formatted, the plain
+         * format result will be {@code "3.14"}. If {@code 0.001} is formatted, then the
+         * result is the zero string.
+         * @param minDecimalExponent minimum decimal exponent
+         * @return this instance
+         */
+        public Builder minDecimalExponent(final int minDecimalExponent) {
+            this.minDecimalExponent = minDecimalExponent;
+            return this;
+        }
+
+        /** Set the maximum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code 2}, the number {@code 999} will be formatted as {@code "999.0"}
+         * while {@code 1000} will be formatted as {@code "1.0E3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMaxDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMaxDecimalExponent(final int plainFormatMaxDecimalExponent) {
+            this.plainFormatMaxDecimalExponent = plainFormatMaxDecimalExponent;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code -2}, the number {@code 0.01} will be formatted as {@code "0.01"}
+         * while {@code 0.0099} will be formatted as {@code "9.9E-3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMinDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMinDecimalExponent(final int plainFormatMinDecimalExponent) {
+            this.plainFormatMinDecimalExponent = plainFormatMinDecimalExponent;
+            return this;
+        }
+
+        /** Set the flag determining whether or not the zero string may be returned with the minus
+         * sign or if it will always be returned in the positive form. For example, if set to true,
+         * the string {@code "-0.0"} may be returned for some input numbers. If false, only {@code "0.0"}
+         * will be returned, regardless of the sign of the input number. The default value is {@code true}.
+         * @param signedZero if true, the zero string may be returned with a preceding minus sign; if false,
+         *      the zero string will only be returned in its positive form
+         * @return this instance
+         */
+        public Builder allowSignedZero(final boolean signedZero) {
+            this.signedZero = signedZero;
+            return this;
+        }
+
+        /** Set the string containing the digit characters 0-9, in that order. The
+         * default value is the string {@code "0123456789"}.
+         * @param digits string containing the digit characters 0-9
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         * @throws IllegalArgumentException if the argument does not have a length of exactly 10
+         */
+        public Builder digits(final String digits) {
+            Objects.requireNonNull(digits, "Digits string cannot be null");
+            if (digits.length() != DEFAULT_DECIMAL_DIGITS.length()) {
+                throw new IllegalArgumentException("Digits string must contain exactly "
+                        + DEFAULT_DECIMAL_DIGITS.length() + " characters.");
+            }
+
+            this.digits = digits;
+            return this;
+        }
+
+        /** Set the flag determining whether or not a zero character is added in the fraction position
+         * when no fractional value is present. For example, if set to true, the number {@code 1} would
+         * be formatted as {@code "1.0"}. If false, it would be formatted as {@code "1"}. The default
+         * value is {@code true}.
+         * @param fractionPlaceholder if true, a zero character is placed in the fraction position when
+         *      no fractional value is present; if false, fractional digits are only included when needed
+         * @return this instance
+         */
+        public Builder includeFractionPlaceholder(final boolean fractionPlaceholder) {
+            this.fractionPlaceholder = fractionPlaceholder;
+            return this;
+        }
+
+        /** Set the character used as the minus sign.
+         * @param minusSign character to use as the minus sign
+         * @return this instance
+         */
+        public Builder minusSign(final char minusSign) {
+            this.minusSign = minusSign;
+            return this;
+        }
+
+        /** Set the decimal separator character, i.e., the character placed between the
+         * whole number and fractional portions of the formatted strings. The default value
+         * is {@code '.'}.
+         * @param decimalSeparator decimal separator character
+         * @return this instance
+         */
+        public Builder decimalSeparator(final char decimalSeparator) {
+            this.decimalSeparator = decimalSeparator;
+            return this;
+        }
+
+        /** Set the character used to separate groups of thousands. Default value is {@code ','}.
+         * @param groupingSeparator character used to separate groups of thousands
+         * @return this instance
+         * @see #groupThousands(boolean)
+         */
+        public Builder groupingSeparator(final char groupingSeparator) {
+            this.groupingSeparator = groupingSeparator;
+            return this;
+        }
+
+        /** If set to true, thousands will be grouped with the
+         * {@link #groupingSeparator(char) grouping separator}. For example, if set to true,
+         * the number {@code 1000} could be formatted as {@code "1,000"}. This property only applies
+         * to the {@link DoubleFormat#PLAIN PLAIN} format. Default value is {@code false}.
+         * @param groupThousands if true, thousands will be grouped
+         * @return this instance
+         * @see #groupingSeparator(char)
+         */
+        public Builder groupThousands(final boolean groupThousands) {
+            this.groupThousands = groupThousands;
+            return this;
+        }
+
+        /** Set the exponent separator character, i.e., the string placed between
+         * the mantissa and the exponent. The default value is {@code "E"}, as in
+         * {@code "1.2E6"}.
+         * @param exponentSeparator exponent separator string
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder exponentSeparator(final String exponentSeparator) {
+            Objects.requireNonNull(exponentSeparator, "Exponent separator cannot be null");
+
+            this.exponentSeparator = exponentSeparator;
+            return this;
+        }
+
+        /** Set the flag indicating if an exponent value should always be included in the
+         * formatted value, even if the exponent value is zero. This property only applies
+         * to formats that use scientific notation, namely
+         * {@link DoubleFormat#SCIENTIFIC SCIENTIFIC},
+         * {@link DoubleFormat#ENGINEERING ENGINEERING}, and
+         * {@link DoubleFormat#MIXED MIXED}. The default value is {@code false}.
+         * @param alwaysIncludeExponent if true, exponents will always be included in formatted
+         *      output even if the exponent value is zero
+         * @return this instance
+         */
+        public Builder alwaysIncludeExponent(final boolean alwaysIncludeExponent) {
+            this.alwaysIncludeExponent = alwaysIncludeExponent;
+            return this;
+        }
+
+        /** Set the string used to represent infinity. For negative infinity, this string
+         * is prefixed with the {@link #minusSign(char) minus sign}.
+         * @param infinity string used to represent infinity
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder infinity(final String infinity) {
+            Objects.requireNonNull(infinity, "Infinity string cannot be null");
+
+            this.infinity = infinity;
+            return this;
+        }
+
+        /** Set the string used to represent {@link Double#NaN}.
+         * @param nan string used to represent {@link Double#NaN}
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder nan(final String nan) {
+            Objects.requireNonNull(nan, "NaN string cannot be null");
+
+            this.nan = nan;
+            return this;
+        }
+
+        /** Configure this instance with the given format symbols. The following values
+         * are set:
+         * <ul>
+         *  <li>{@link #digits(String) digit characters}</li>
+         *  <li>{@link #decimalSeparator(char) decimal separator}</li>
+         *  <li>{@link #groupingSeparator(char) thousands grouping separator}</li>
+         *  <li>{@link #minusSign(char) minus sign}</li>
+         *  <li>{@link #exponentSeparator(String) exponent separator}</li>
+         *  <li>{@link #infinity(String) infinity}</li>
+         *  <li>{@link #nan(String) NaN}</li>
+         * </ul>
+         * The digit character string is constructed by starting at the configured
+         * {@link DecimalFormatSymbols#getZeroDigit() zero digit} and adding the next
+         * 9 consecutive characters.
+         * @param symbols format symbols
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder formatSymbols(final DecimalFormatSymbols symbols) {
+            Objects.requireNonNull(symbols, "Decimal format symbols cannot be null");
+
+            return digits(getDigitString(symbols))
+                    .decimalSeparator(symbols.getDecimalSeparator())
+                    .groupingSeparator(symbols.getGroupingSeparator())
+                    .minusSign(symbols.getMinusSign())
+                    .exponentSeparator(symbols.getExponentSeparator())
+                    .infinity(symbols.getInfinity())
+                    .nan(symbols.getNaN());
+        }
+
+        /** Get a string containing the localized digits 0-9 for the given symbols object. The
+         * string is constructed by starting at the {@link DecimalFormatSymbols#getZeroDigit() zero digit}
+         * and adding the next 9 consecutive characters.
+         * @param symbols symbols object
+         * @return string containing the localized digits 0-9
+         */
+        private String getDigitString(final DecimalFormatSymbols symbols) {
+            final int zeroDelta = symbols.getZeroDigit() - DEFAULT_DECIMAL_DIGITS.charAt(0);
+
+            final char[] digitChars = new char[DEFAULT_DECIMAL_DIGITS.length()];
+            for (int i = 0; i < DEFAULT_DECIMAL_DIGITS.length(); ++i) {
+                digitChars[i] = (char) (DEFAULT_DECIMAL_DIGITS.charAt(i) + zeroDelta);
+            }
+
+            return String.valueOf(digitChars);
+        }
+
+        /** Construct a new double format function.
+         * @return format function
+         */
+        public DoubleFunction<String> build() {
+            return factory.apply(this);
+        }
+    }
+
+    /** Base class for standard double formatting classes.
+     */
+    private abstract static class AbstractDoubleFormat
+        implements DoubleFunction<String>, ParsedDecimal.FormatOptions {
+
+        /** Maximum precision; 0 indicates no limit. */
+        private final int maxPrecision;
+
+        /** Minimum decimal exponent. */
+        private final int minDecimalExponent;
+
+        /** String representing positive infinity. */
+        private final String postiveInfinity;

Review comment:
       By chance I was debugging an example experimenting with a builder and a custom character (`~`) for minus sign, when I spotted this typo (`s/postiveInfinity/positiveInfinity`)

##########
File path: src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.java
##########
@@ -0,0 +1,764 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+import java.util.function.BiFunction;
+
+import org.apache.commons.rng.UniformRandomProvider;
+import org.apache.commons.rng.simple.RandomSource;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+class ParsedDecimalTest {
+
+    @Test
+    void testFrom() {
+        // act/assert
+        checkFrom(0.0, "0", 0);
+
+        checkFrom(1.0, "1", 0);
+        checkFrom(10.0, "1", 1);
+        checkFrom(100.0, "1", 2);
+        checkFrom(1000.0, "1", 3);
+        checkFrom(10000.0, "1", 4);
+
+        checkFrom(0.1, "1", -1);
+        checkFrom(0.01, "1", -2);
+        checkFrom(0.001, "1", -3);
+        checkFrom(0.0001, "1", -4);
+        checkFrom(0.00001, "1", -5);
+
+        checkFrom(1.2, "12", -1);
+        checkFrom(0.00971, "971", -5);
+        checkFrom(56300, "563", 2);
+
+        checkFrom(123.0, "123", 0);
+        checkFrom(1230.0, "123", 1);
+        checkFrom(12300.0, "123", 2);
+        checkFrom(123000.0, "123", 3);
+
+        checkFrom(12.3, "123", -1);
+        checkFrom(1.23, "123", -2);
+        checkFrom(0.123, "123", -3);
+        checkFrom(0.0123, "123", -4);
+
+        checkFrom(1.987654321e270, "1987654321", 261);
+        checkFrom(1.987654321e-270, "1987654321", -279);
+
+        checkFrom(Math.PI, "3141592653589793", -15);
+        checkFrom(Math.E, "2718281828459045", -15);
+
+        checkFrom(Double.MAX_VALUE, "17976931348623157", 292);
+        checkFrom(Double.MIN_VALUE, "49", -325);
+        checkFrom(Double.MIN_NORMAL, "22250738585072014", -324);
+    }
+
+    @Test
+    void testFrom_notFinite() {
+        // arrange
+        final String msg = "Double is not finite";
+
+        // act/assert
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NaN),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NEGATIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.POSITIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+    }
+
+    @Test
+    void testIsZero() {
+        // act/assert
+        Assertions.assertTrue(ParsedDecimal.from(0.0).isZero());
+        Assertions.assertTrue(ParsedDecimal.from(-0.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(1.0).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-1.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MIN_NORMAL).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_NORMAL).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MAX_VALUE).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_VALUE).isZero());
+    }
+
+    @Test
+    void testRound_one() {
+        // arrange
+        final double a = 1e-10;
+        final double b = -1;
+        final double c = 1e10;
+
+        // act/assert
+        assertRound(a, -11, false, "1", -10);
+        assertRound(a, -10, false, "1", -10);
+        assertRound(a, -9, false, "0", 0);
+
+        assertRound(b, -1, true, "1", 0);
+        assertRound(b, 0, true, "1", 0);
+        assertRound(b, 1, true, "0", 0);
+
+        assertRound(c, 9, false, "1", 10);
+        assertRound(c, 10, false, "1", 10);
+        assertRound(c, 11, false, "0", 0);
+    }
+
+    @Test
+    void testRound_nine() {
+        // arrange
+        final double a = 9e-10;
+        final double b = -9;
+        final double c = 9e10;
+
+        // act/assert
+        assertRound(a, -11, false, "9", -10);
+        assertRound(a, -10, false, "9", -10);
+        assertRound(a, -9, false, "1", -9);
+
+        assertRound(b, -1, true, "9", 0);
+        assertRound(b, 0, true, "9", 0);
+        assertRound(b, 1, true, "1", 1);
+
+        assertRound(c, 9, false, "9", 10);
+        assertRound(c, 10, false, "9", 10);
+        assertRound(c, 11, false, "1", 11);
+    }
+
+    @Test
+    void testRound_mixed() {
+        // arrange
+        final double a = 9.94e-10;
+        final double b = -3.1415;
+        final double c = 5.55e10;
+
+        // act/assert
+        assertRound(a, -13, false, "994", -12);
+        assertRound(a, -12, false, "994", -12);
+        assertRound(a, -11, false, "99", -11);
+        assertRound(a, -10, false, "1", -9);
+        assertRound(a, -9, false, "1", -9);
+        assertRound(a, -8, false, "0", 0);
+
+        assertRound(b, -5, true, "31415", -4);
+        assertRound(b, -4, true, "31415", -4);
+        assertRound(b, -3, true, "3142", -3);
+        assertRound(b, -2, true, "314", -2);
+        assertRound(b, -1, true, "31", -1);
+        assertRound(b, 0, true, "3", 0);
+        assertRound(b, 1, true, "0", 0);
+        assertRound(b, 2, true, "0", 0);
+
+        assertRound(c, 7, false, "555", 8);
+        assertRound(c, 8, false, "555", 8);
+        assertRound(c, 9, false, "56", 9);
+        assertRound(c, 10, false, "6", 10);
+        assertRound(c, 11, false, "1", 11);
+        assertRound(c, 12, false, "0", 0);
+    }
+
+    @Test
+    void testMaxPrecision() {
+        // arrange
+        final double d = 1.02576552;
+
+        // act
+        assertMaxPrecision(d, 10, false, "102576552", -8);
+        assertMaxPrecision(d, 9, false, "102576552", -8);
+        assertMaxPrecision(d, 8, false, "10257655", -7);
+        assertMaxPrecision(d, 7, false, "1025766", -6);
+        assertMaxPrecision(d, 6, false, "102577", -5);
+        assertMaxPrecision(d, 5, false, "10258", -4);
+        assertMaxPrecision(d, 4, false, "1026", -3);
+        assertMaxPrecision(d, 3, false, "103", -2);
+        assertMaxPrecision(d, 2, false, "1", 0);
+        assertMaxPrecision(d, 1, false, "1", 0);
+
+        assertMaxPrecision(d, 0, false, "102576552", -8);
+    }
+
+    @Test
+    void testMaxPrecision_carry() {
+        // arrange
+        final double d = -999.0999e50;
+
+        // act
+        assertMaxPrecision(d, 8, true, "9990999", 46);
+        assertMaxPrecision(d, 7, true, "9990999", 46);
+        assertMaxPrecision(d, 6, true, "9991", 49);
+        assertMaxPrecision(d, 5, true, "9991", 49);
+        assertMaxPrecision(d, 4, true, "9991", 49);
+        assertMaxPrecision(d, 3, true, "999", 50);
+        assertMaxPrecision(d, 2, true, "1", 53);
+        assertMaxPrecision(d, 1, true, "1", 53);
+
+        assertMaxPrecision(d, 0, true, "9990999", 46);
+    }
+
+    @Test
+    void testMaxPrecision_halfEvenRounding() {
+        // act/assert
+        // Test values taken from RoundingMode.HALF_EVEN javadocs
+        assertMaxPrecision(5.5, 1, false, "6", 0);
+        assertMaxPrecision(2.5, 1, false, "2", 0);
+        assertMaxPrecision(1.6, 1, false, "2", 0);
+        assertMaxPrecision(1.1, 1, false, "1", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-1.1, 1, true, "1", 0);
+        assertMaxPrecision(-1.6, 1, true, "2", 0);
+        assertMaxPrecision(-2.5, 1, true, "2", 0);
+        assertMaxPrecision(-5.5, 1, true, "6", 0);
+    }
+
+    @Test
+    void testMaxPrecision_singleDigits() {
+        // act
+        assertMaxPrecision(9.0, 1, false, "9", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+        assertMaxPrecision(0.0, 1, false, "0", 0);
+        assertMaxPrecision(-0.0, 1, true, "0", 0);
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-9.0, 1, true, "9", 0);
+    }
+
+    @Test
+    void testMaxPrecision_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+        final ParsedDecimal.FormatOptions opts = new FormatOptionsImpl();
+
+        for (int i = 0; i < 10_000; ++i) {
+            final double d = createRandomDouble(rand);
+            final int precision = rand.nextInt(20) + 1;
+            final MathContext ctx = new MathContext(precision, RoundingMode.HALF_EVEN);
+
+            final ParsedDecimal dec = ParsedDecimal.from(d);
+
+            // act
+            dec.maxPrecision(precision);
+
+            // assert
+            Assertions.assertEquals(new BigDecimal(Double.toString(d), ctx).doubleValue(),
+                    Double.parseDouble(scientificString(dec, opts)));
+        }
+    }
+
+    @Test
+    void testToPlainString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToPlainString(0.0, "0.0", opts);
+        checkToPlainString(-0.0, "-0.0", opts);
+        checkToPlainString(1.0, "1.0", opts);
+        checkToPlainString(1.5, "1.5", opts);
+
+        checkToPlainString(12, "12.0", opts);
+        checkToPlainString(123, "123.0", opts);
+        checkToPlainString(1234, "1234.0", opts);
+        checkToPlainString(12345, "12345.0", opts);
+        checkToPlainString(123456, "123456.0", opts);
+        checkToPlainString(1234567, "1234567.0", opts);
+        checkToPlainString(12345678, "12345678.0", opts);
+        checkToPlainString(123456789, "123456789.0", opts);
+        checkToPlainString(1234567890, "1234567890.0", opts);
+
+        checkToPlainString(-0.000123, "-0.000123", opts);
+        checkToPlainString(12301, "12301.0", opts);
+
+        checkToPlainString(Math.PI, "3.141592653589793", opts);
+        checkToPlainString(Math.E, "2.718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "-12345.6789", opts);
+        checkToPlainString(1.23e12, "1230000000000.0", opts);
+        checkToPlainString(1.23e-12, "0.00000000000123", opts);
+    }
+
+    @Test
+    void testToPlainString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setThousandsGroupingSeparator('_');
+        opts.setGroupThousands(true);
+
+        // act/assert
+        checkToPlainString(0.0, "0", opts);
+        checkToPlainString(-0.0, "0", opts);
+        checkToPlainString(1.0, "1", opts);
+        checkToPlainString(1.5, "1,5", opts);
+
+        checkToPlainString(12, "12", opts);
+        checkToPlainString(123, "123", opts);
+        checkToPlainString(1234, "1_234", opts);
+        checkToPlainString(12345, "12_345", opts);
+        checkToPlainString(123456, "123_456", opts);
+        checkToPlainString(1234567, "1_234_567", opts);
+        checkToPlainString(12345678, "12_345_678", opts);
+        checkToPlainString(123456789, "123_456_789", opts);
+        checkToPlainString(1234567890, "1_234_567_890", opts);
+
+        checkToPlainString(-0.000123, "!0,000123", opts);
+        checkToPlainString(12301, "12_301", opts);
+
+        checkToPlainString(Math.PI, "3,141592653589793", opts);
+        checkToPlainString(Math.E, "2,718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "!12_345,6789", opts);
+        checkToPlainString(1.23e12, "1_230_000_000_000", opts);
+        checkToPlainString(1.23e-12, "0,00000000000123", opts);
+    }
+
+    @Test
+    void testToScientificString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToScientificString(0.0, "0.0", opts);
+        checkToScientificString(-0.0, "-0.0", opts);
+        checkToScientificString(1.0, "1.0", opts);
+        checkToScientificString(1.5, "1.5", opts);
+
+        checkToScientificString(-0.000123, "-1.23E-4", opts);
+        checkToScientificString(12301, "1.2301E4", opts);
+
+        checkToScientificString(Math.PI, "3.141592653589793", opts);
+        checkToScientificString(Math.E, "2.718281828459045", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "-1.7976931348623157E308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2.2250738585072014E-308", opts);
+    }
+
+    @Test
+    void testToScientificString_altFormats() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToScientificString(0.0, "0x10^0", opts);
+        checkToScientificString(-0.0, "0x10^0", opts);
+        checkToScientificString(1.0, "1x10^0", opts);
+        checkToScientificString(1.5, "1,5x10^0", opts);
+
+        checkToScientificString(-0.000123, "!1,23x10^!4", opts);
+        checkToScientificString(12301, "1,2301x10^4", opts);
+
+        checkToScientificString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToScientificString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "!1,7976931348623157x10^308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2,2250738585072014x10^!308", opts);
+    }
+
+    @Test
+    void testToEngineeringString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToEngineeringString(0.0, "0.0", opts);
+        checkToEngineeringString(-0.0, "-0.0", opts);
+        checkToEngineeringString(1.0, "1.0", opts);
+        checkToEngineeringString(1.5, "1.5", opts);
+
+        checkToEngineeringString(10, "10.0", opts);
+
+        checkToEngineeringString(-0.000000123, "-123.0E-9", opts);
+        checkToEngineeringString(12300000, "12.3E6", opts);
+
+        checkToEngineeringString(Math.PI, "3.141592653589793", opts);
+        checkToEngineeringString(Math.E, "2.718281828459045", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "-179.76931348623157E306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22.250738585072014E-309", opts);
+    }
+
+    @Test
+    void testToEngineeringString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToEngineeringString(0.0, "0x10^0", opts);
+        checkToEngineeringString(-0.0, "0x10^0", opts);
+        checkToEngineeringString(1.0, "1x10^0", opts);
+        checkToEngineeringString(1.5, "1,5x10^0", opts);
+
+        checkToEngineeringString(10, "10x10^0", opts);
+
+        checkToEngineeringString(-0.000000123, "!123x10^!9", opts);
+        checkToEngineeringString(12300000, "12,3x10^6", opts);
+
+        checkToEngineeringString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToEngineeringString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "!179,76931348623157x10^306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22,250738585072014x10^!309", opts);
+    }
+
+    @Test
+    void testStringMethods_customDigits() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setDigitsFromString("abcdefghij");
+
+        // act/assert
+        Assertions.assertEquals("b.a", plainString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-a.abcd", plainString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", plainString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("baaaa.a", plainString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("jihgfedcba.a", plainString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", scientificString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-b.cdE-c", scientificString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("b.cdeEb", scientificString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("b.aEe", scientificString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", scientificString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", engineeringString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-bc.dE-d", engineeringString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", engineeringString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("ba.aEd", engineeringString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", engineeringString(ParsedDecimal.from(9876543210d), opts));
+    }
+
+    @Test
+    void testStringMethodAccuracy_sequence() {
+        // arrange
+        final double min = -1000;
+        final double max = 1000;
+        final double delta = 0.1;
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        Assertions.assertEquals(10.0, Double.parseDouble(scientificString(ParsedDecimal.from(10.0), stdOpts)));
+
+        for (double d = min; d <= max; d += delta) {
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    @Test
+    void testStringMethodAccuracy_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        double d;
+        for (int i = 0; i < 10_000; ++i) {
+            d = createRandomDouble(rand);
+
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    private static void checkFrom(final double d, final String digits, final int exponent) {
+        final boolean negative = Math.signum(d) < 0;
+
+        assertSimpleDecimal(ParsedDecimal.from(d), negative, digits, exponent);
+        assertSimpleDecimal(ParsedDecimal.from(-d), !negative, digits, exponent);
+    }
+
+    private static void checkToPlainString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::plainString, opts);
+    }
+
+    private static void checkToScientificString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::scientificString, opts);
+    }
+
+    private static void checkToEngineeringString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::engineeringString, opts);
+
+        // check the exponent value to make sure it is a multiple of 3
+        final String pos = engineeringString(ParsedDecimal.from(d), opts);
+        Assertions.assertEquals(0, parseExponent(pos, opts) % 3);
+
+        final String neg = engineeringString(ParsedDecimal.from(-d), opts);
+        Assertions.assertEquals(0, parseExponent(neg, opts) % 3);
+    }
+
+    private static int parseExponent(final String str, final ParsedDecimal.FormatOptions opts) {
+        final char[] expSep = opts.getExponentSeparatorChars();
+
+        final int expStartIdx = str.indexOf(String.valueOf(expSep));
+        if (expStartIdx > -1) {
+            int expIdx = expStartIdx + expSep.length;
+
+            boolean neg = false;
+            if (str.charAt(expIdx) == opts.getMinusSign()) {
+                ++expIdx;
+            }
+
+            final String expStr = str.substring(expIdx);
+            final int val = Integer.parseInt(expStr);
+            return neg
+                    ? -val
+                    : val;
+        }
+
+        return 0;
+    }
+
+    private static void checkToStringMethod(final double d, final String expected,
+            final BiFunction<ParsedDecimal, ParsedDecimal.FormatOptions, String> fn,
+            final ParsedDecimal.FormatOptions opts) {
+
+        final ParsedDecimal pos = ParsedDecimal.from(d);
+        final String actual = fn.apply(pos, opts);
+
+        Assertions.assertEquals(expected, actual);
+    }
+
+    private static void assertRound(final double d, final int roundExponent,
+            final boolean negative, final String digits, final int exponent) {
+        final ParsedDecimal dec = ParsedDecimal.from(d);
+        dec.round(roundExponent);
+
+        assertSimpleDecimal(dec, negative, digits, exponent);
+    }
+
+    private static void assertMaxPrecision(final double d, final int maxPrecision,
+            final boolean negative, final String digits, final int exponent) {
+        final ParsedDecimal dec = ParsedDecimal.from(d);
+        dec.maxPrecision(maxPrecision);
+
+        assertSimpleDecimal(dec, negative, digits, exponent);
+    }
+
+    private static void assertSimpleDecimal(final ParsedDecimal parsed, final boolean negative, final String digits,
+            final int exponent) {
+        Assertions.assertEquals(negative, parsed.negative);
+        Assertions.assertEquals(digits, digitString(parsed));
+        Assertions.assertEquals(exponent, parsed.getExponent());
+        Assertions.assertEquals(digits.length(), parsed.digitCount);
+        Assertions.assertEquals(exponent, parsed.getScientificExponent() - digits.length() + 1);
+    }
+
+    private static void assertThrowsWithMessage(final Executable fn, final Class<? extends Throwable> type,
+            final String msg) {
+        Throwable exc = Assertions.assertThrows(type, fn);
+        Assertions.assertEquals(msg, exc.getMessage());
+    }
+
+    private static double createRandomDouble(final UniformRandomProvider rng) {
+        final long mask = ((1L << 52) - 1) | 1L << 63;
+        final long bits = rng.nextLong() & mask;
+        final long exp = rng.nextInt(2045) + 1;
+        return Double.longBitsToDouble(bits | (exp << 52));
+    }
+
+    /** Get the raw digits in the given decimal as a string.
+     * @param dec decimal instancE
+     * @return decimal digits as a string
+     */
+    private static String digitString(final ParsedDecimal dec) {
+        final StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < dec.digitCount; ++i) {
+            sb.append(dec.digits[i]);
+        }
+        return sb.toString();
+    }
+
+    private static String plainString(final ParsedDecimal dec, final ParsedDecimal.FormatOptions opts) {
+//        try {
+//            StringBuilder sb = new StringBuilder();
+//            dec.toPlainString(sb, opts);
+//            return sb.toString();
+//        } catch (IOException exc) {
+//            throw new UncheckedIOException(exc);
+//        }
+        return dec.toPlainString(opts);
+    }
+
+    private static String scientificString(final ParsedDecimal dec, final ParsedDecimal.FormatOptions opts) {
+//        try {
+//            StringBuilder sb = new StringBuilder();
+//            dec.toScientificString(sb, opts);
+//            return sb.toString();
+//        } catch (IOException exc) {
+//            throw new UncheckedIOException(exc);
+//        }
+        return dec.toScientificString(opts);
+    }
+
+    private static String engineeringString(final ParsedDecimal dec, final ParsedDecimal.FormatOptions opts) {
+//        try {
+//            StringBuilder sb = new StringBuilder();
+//            dec.toEngineeringString(sb, opts);
+//            return sb.toString();
+//        } catch (IOException exc) {
+//            throw new UncheckedIOException(exc);
+//        }
+        return dec.toEngineeringString(opts);
+    }
+
+    private static final class FormatOptionsImpl implements ParsedDecimal.FormatOptions {
+
+        private boolean includeFractionPlaceholder = true;
+
+        private boolean signedZero = true;
+
+        private char[] digits = "0123456789".toCharArray();
+
+        private char decimalSeparator = '.';
+
+        private char thousandsGroupingSeparator = ',';
+
+        private boolean groupThousands = false;
+
+        private char minusSign = '-';

Review comment:
       Really interesting! I saw this first in a test. Hadn't noticed before that we could even customize the minus sign. Out of curiosity, is there a use case for this? (background in information systems, knowledge/data, etc., I think that's useful, just curiosity really :)

##########
File path: src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.java
##########
@@ -0,0 +1,764 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+import java.util.function.BiFunction;
+
+import org.apache.commons.rng.UniformRandomProvider;
+import org.apache.commons.rng.simple.RandomSource;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+class ParsedDecimalTest {
+
+    @Test
+    void testFrom() {
+        // act/assert
+        checkFrom(0.0, "0", 0);
+
+        checkFrom(1.0, "1", 0);
+        checkFrom(10.0, "1", 1);
+        checkFrom(100.0, "1", 2);
+        checkFrom(1000.0, "1", 3);
+        checkFrom(10000.0, "1", 4);
+
+        checkFrom(0.1, "1", -1);
+        checkFrom(0.01, "1", -2);
+        checkFrom(0.001, "1", -3);
+        checkFrom(0.0001, "1", -4);
+        checkFrom(0.00001, "1", -5);
+
+        checkFrom(1.2, "12", -1);
+        checkFrom(0.00971, "971", -5);
+        checkFrom(56300, "563", 2);
+
+        checkFrom(123.0, "123", 0);
+        checkFrom(1230.0, "123", 1);
+        checkFrom(12300.0, "123", 2);
+        checkFrom(123000.0, "123", 3);
+
+        checkFrom(12.3, "123", -1);
+        checkFrom(1.23, "123", -2);
+        checkFrom(0.123, "123", -3);
+        checkFrom(0.0123, "123", -4);
+
+        checkFrom(1.987654321e270, "1987654321", 261);
+        checkFrom(1.987654321e-270, "1987654321", -279);
+
+        checkFrom(Math.PI, "3141592653589793", -15);
+        checkFrom(Math.E, "2718281828459045", -15);
+
+        checkFrom(Double.MAX_VALUE, "17976931348623157", 292);
+        checkFrom(Double.MIN_VALUE, "49", -325);
+        checkFrom(Double.MIN_NORMAL, "22250738585072014", -324);
+    }
+
+    @Test
+    void testFrom_notFinite() {
+        // arrange
+        final String msg = "Double is not finite";
+
+        // act/assert
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NaN),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NEGATIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.POSITIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+    }
+
+    @Test
+    void testIsZero() {
+        // act/assert
+        Assertions.assertTrue(ParsedDecimal.from(0.0).isZero());
+        Assertions.assertTrue(ParsedDecimal.from(-0.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(1.0).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-1.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MIN_NORMAL).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_NORMAL).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MAX_VALUE).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_VALUE).isZero());
+    }
+
+    @Test
+    void testRound_one() {
+        // arrange
+        final double a = 1e-10;
+        final double b = -1;
+        final double c = 1e10;
+
+        // act/assert
+        assertRound(a, -11, false, "1", -10);
+        assertRound(a, -10, false, "1", -10);
+        assertRound(a, -9, false, "0", 0);
+
+        assertRound(b, -1, true, "1", 0);
+        assertRound(b, 0, true, "1", 0);
+        assertRound(b, 1, true, "0", 0);
+
+        assertRound(c, 9, false, "1", 10);
+        assertRound(c, 10, false, "1", 10);
+        assertRound(c, 11, false, "0", 0);
+    }
+
+    @Test
+    void testRound_nine() {
+        // arrange
+        final double a = 9e-10;
+        final double b = -9;
+        final double c = 9e10;
+
+        // act/assert
+        assertRound(a, -11, false, "9", -10);
+        assertRound(a, -10, false, "9", -10);
+        assertRound(a, -9, false, "1", -9);
+
+        assertRound(b, -1, true, "9", 0);
+        assertRound(b, 0, true, "9", 0);
+        assertRound(b, 1, true, "1", 1);
+
+        assertRound(c, 9, false, "9", 10);
+        assertRound(c, 10, false, "9", 10);
+        assertRound(c, 11, false, "1", 11);
+    }
+
+    @Test
+    void testRound_mixed() {
+        // arrange
+        final double a = 9.94e-10;
+        final double b = -3.1415;
+        final double c = 5.55e10;
+
+        // act/assert
+        assertRound(a, -13, false, "994", -12);
+        assertRound(a, -12, false, "994", -12);
+        assertRound(a, -11, false, "99", -11);
+        assertRound(a, -10, false, "1", -9);
+        assertRound(a, -9, false, "1", -9);
+        assertRound(a, -8, false, "0", 0);
+
+        assertRound(b, -5, true, "31415", -4);
+        assertRound(b, -4, true, "31415", -4);
+        assertRound(b, -3, true, "3142", -3);
+        assertRound(b, -2, true, "314", -2);
+        assertRound(b, -1, true, "31", -1);
+        assertRound(b, 0, true, "3", 0);
+        assertRound(b, 1, true, "0", 0);
+        assertRound(b, 2, true, "0", 0);
+
+        assertRound(c, 7, false, "555", 8);
+        assertRound(c, 8, false, "555", 8);
+        assertRound(c, 9, false, "56", 9);
+        assertRound(c, 10, false, "6", 10);
+        assertRound(c, 11, false, "1", 11);
+        assertRound(c, 12, false, "0", 0);
+    }
+
+    @Test
+    void testMaxPrecision() {
+        // arrange
+        final double d = 1.02576552;
+
+        // act
+        assertMaxPrecision(d, 10, false, "102576552", -8);
+        assertMaxPrecision(d, 9, false, "102576552", -8);
+        assertMaxPrecision(d, 8, false, "10257655", -7);
+        assertMaxPrecision(d, 7, false, "1025766", -6);
+        assertMaxPrecision(d, 6, false, "102577", -5);
+        assertMaxPrecision(d, 5, false, "10258", -4);
+        assertMaxPrecision(d, 4, false, "1026", -3);
+        assertMaxPrecision(d, 3, false, "103", -2);
+        assertMaxPrecision(d, 2, false, "1", 0);
+        assertMaxPrecision(d, 1, false, "1", 0);
+
+        assertMaxPrecision(d, 0, false, "102576552", -8);
+    }
+
+    @Test
+    void testMaxPrecision_carry() {
+        // arrange
+        final double d = -999.0999e50;
+
+        // act
+        assertMaxPrecision(d, 8, true, "9990999", 46);
+        assertMaxPrecision(d, 7, true, "9990999", 46);
+        assertMaxPrecision(d, 6, true, "9991", 49);
+        assertMaxPrecision(d, 5, true, "9991", 49);
+        assertMaxPrecision(d, 4, true, "9991", 49);
+        assertMaxPrecision(d, 3, true, "999", 50);
+        assertMaxPrecision(d, 2, true, "1", 53);
+        assertMaxPrecision(d, 1, true, "1", 53);
+
+        assertMaxPrecision(d, 0, true, "9990999", 46);
+    }
+
+    @Test
+    void testMaxPrecision_halfEvenRounding() {
+        // act/assert
+        // Test values taken from RoundingMode.HALF_EVEN javadocs
+        assertMaxPrecision(5.5, 1, false, "6", 0);
+        assertMaxPrecision(2.5, 1, false, "2", 0);
+        assertMaxPrecision(1.6, 1, false, "2", 0);
+        assertMaxPrecision(1.1, 1, false, "1", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-1.1, 1, true, "1", 0);
+        assertMaxPrecision(-1.6, 1, true, "2", 0);
+        assertMaxPrecision(-2.5, 1, true, "2", 0);
+        assertMaxPrecision(-5.5, 1, true, "6", 0);
+    }
+
+    @Test
+    void testMaxPrecision_singleDigits() {
+        // act
+        assertMaxPrecision(9.0, 1, false, "9", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+        assertMaxPrecision(0.0, 1, false, "0", 0);
+        assertMaxPrecision(-0.0, 1, true, "0", 0);
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-9.0, 1, true, "9", 0);
+    }
+
+    @Test
+    void testMaxPrecision_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+        final ParsedDecimal.FormatOptions opts = new FormatOptionsImpl();
+
+        for (int i = 0; i < 10_000; ++i) {
+            final double d = createRandomDouble(rand);
+            final int precision = rand.nextInt(20) + 1;
+            final MathContext ctx = new MathContext(precision, RoundingMode.HALF_EVEN);
+
+            final ParsedDecimal dec = ParsedDecimal.from(d);
+
+            // act
+            dec.maxPrecision(precision);
+
+            // assert
+            Assertions.assertEquals(new BigDecimal(Double.toString(d), ctx).doubleValue(),
+                    Double.parseDouble(scientificString(dec, opts)));
+        }
+    }
+
+    @Test
+    void testToPlainString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToPlainString(0.0, "0.0", opts);
+        checkToPlainString(-0.0, "-0.0", opts);
+        checkToPlainString(1.0, "1.0", opts);
+        checkToPlainString(1.5, "1.5", opts);
+
+        checkToPlainString(12, "12.0", opts);
+        checkToPlainString(123, "123.0", opts);
+        checkToPlainString(1234, "1234.0", opts);
+        checkToPlainString(12345, "12345.0", opts);
+        checkToPlainString(123456, "123456.0", opts);
+        checkToPlainString(1234567, "1234567.0", opts);
+        checkToPlainString(12345678, "12345678.0", opts);
+        checkToPlainString(123456789, "123456789.0", opts);
+        checkToPlainString(1234567890, "1234567890.0", opts);
+
+        checkToPlainString(-0.000123, "-0.000123", opts);
+        checkToPlainString(12301, "12301.0", opts);
+
+        checkToPlainString(Math.PI, "3.141592653589793", opts);
+        checkToPlainString(Math.E, "2.718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "-12345.6789", opts);
+        checkToPlainString(1.23e12, "1230000000000.0", opts);
+        checkToPlainString(1.23e-12, "0.00000000000123", opts);
+    }
+
+    @Test
+    void testToPlainString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setThousandsGroupingSeparator('_');
+        opts.setGroupThousands(true);
+
+        // act/assert
+        checkToPlainString(0.0, "0", opts);
+        checkToPlainString(-0.0, "0", opts);
+        checkToPlainString(1.0, "1", opts);
+        checkToPlainString(1.5, "1,5", opts);
+
+        checkToPlainString(12, "12", opts);
+        checkToPlainString(123, "123", opts);
+        checkToPlainString(1234, "1_234", opts);
+        checkToPlainString(12345, "12_345", opts);
+        checkToPlainString(123456, "123_456", opts);
+        checkToPlainString(1234567, "1_234_567", opts);
+        checkToPlainString(12345678, "12_345_678", opts);
+        checkToPlainString(123456789, "123_456_789", opts);
+        checkToPlainString(1234567890, "1_234_567_890", opts);
+
+        checkToPlainString(-0.000123, "!0,000123", opts);
+        checkToPlainString(12301, "12_301", opts);
+
+        checkToPlainString(Math.PI, "3,141592653589793", opts);
+        checkToPlainString(Math.E, "2,718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "!12_345,6789", opts);
+        checkToPlainString(1.23e12, "1_230_000_000_000", opts);
+        checkToPlainString(1.23e-12, "0,00000000000123", opts);
+    }
+
+    @Test
+    void testToScientificString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToScientificString(0.0, "0.0", opts);
+        checkToScientificString(-0.0, "-0.0", opts);
+        checkToScientificString(1.0, "1.0", opts);
+        checkToScientificString(1.5, "1.5", opts);
+
+        checkToScientificString(-0.000123, "-1.23E-4", opts);
+        checkToScientificString(12301, "1.2301E4", opts);
+
+        checkToScientificString(Math.PI, "3.141592653589793", opts);
+        checkToScientificString(Math.E, "2.718281828459045", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "-1.7976931348623157E308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2.2250738585072014E-308", opts);
+    }
+
+    @Test
+    void testToScientificString_altFormats() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToScientificString(0.0, "0x10^0", opts);
+        checkToScientificString(-0.0, "0x10^0", opts);
+        checkToScientificString(1.0, "1x10^0", opts);
+        checkToScientificString(1.5, "1,5x10^0", opts);
+
+        checkToScientificString(-0.000123, "!1,23x10^!4", opts);
+        checkToScientificString(12301, "1,2301x10^4", opts);
+
+        checkToScientificString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToScientificString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "!1,7976931348623157x10^308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2,2250738585072014x10^!308", opts);
+    }
+
+    @Test
+    void testToEngineeringString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToEngineeringString(0.0, "0.0", opts);
+        checkToEngineeringString(-0.0, "-0.0", opts);
+        checkToEngineeringString(1.0, "1.0", opts);
+        checkToEngineeringString(1.5, "1.5", opts);
+
+        checkToEngineeringString(10, "10.0", opts);
+
+        checkToEngineeringString(-0.000000123, "-123.0E-9", opts);
+        checkToEngineeringString(12300000, "12.3E6", opts);
+
+        checkToEngineeringString(Math.PI, "3.141592653589793", opts);
+        checkToEngineeringString(Math.E, "2.718281828459045", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "-179.76931348623157E306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22.250738585072014E-309", opts);
+    }
+
+    @Test
+    void testToEngineeringString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToEngineeringString(0.0, "0x10^0", opts);
+        checkToEngineeringString(-0.0, "0x10^0", opts);
+        checkToEngineeringString(1.0, "1x10^0", opts);
+        checkToEngineeringString(1.5, "1,5x10^0", opts);
+
+        checkToEngineeringString(10, "10x10^0", opts);
+
+        checkToEngineeringString(-0.000000123, "!123x10^!9", opts);
+        checkToEngineeringString(12300000, "12,3x10^6", opts);
+
+        checkToEngineeringString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToEngineeringString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "!179,76931348623157x10^306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22,250738585072014x10^!309", opts);
+    }
+
+    @Test
+    void testStringMethods_customDigits() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setDigitsFromString("abcdefghij");
+
+        // act/assert
+        Assertions.assertEquals("b.a", plainString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-a.abcd", plainString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", plainString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("baaaa.a", plainString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("jihgfedcba.a", plainString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", scientificString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-b.cdE-c", scientificString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("b.cdeEb", scientificString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("b.aEe", scientificString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", scientificString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", engineeringString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-bc.dE-d", engineeringString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", engineeringString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("ba.aEd", engineeringString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", engineeringString(ParsedDecimal.from(9876543210d), opts));
+    }
+
+    @Test
+    void testStringMethodAccuracy_sequence() {
+        // arrange
+        final double min = -1000;
+        final double max = 1000;
+        final double delta = 0.1;
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        Assertions.assertEquals(10.0, Double.parseDouble(scientificString(ParsedDecimal.from(10.0), stdOpts)));
+
+        for (double d = min; d <= max; d += delta) {
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    @Test
+    void testStringMethodAccuracy_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        double d;
+        for (int i = 0; i < 10_000; ++i) {
+            d = createRandomDouble(rand);
+
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    private static void checkFrom(final double d, final String digits, final int exponent) {
+        final boolean negative = Math.signum(d) < 0;
+
+        assertSimpleDecimal(ParsedDecimal.from(d), negative, digits, exponent);
+        assertSimpleDecimal(ParsedDecimal.from(-d), !negative, digits, exponent);
+    }
+
+    private static void checkToPlainString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::plainString, opts);
+    }
+
+    private static void checkToScientificString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::scientificString, opts);
+    }
+
+    private static void checkToEngineeringString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::engineeringString, opts);
+
+        // check the exponent value to make sure it is a multiple of 3
+        final String pos = engineeringString(ParsedDecimal.from(d), opts);
+        Assertions.assertEquals(0, parseExponent(pos, opts) % 3);
+
+        final String neg = engineeringString(ParsedDecimal.from(-d), opts);
+        Assertions.assertEquals(0, parseExponent(neg, opts) % 3);
+    }
+
+    private static int parseExponent(final String str, final ParsedDecimal.FormatOptions opts) {
+        final char[] expSep = opts.getExponentSeparatorChars();
+
+        final int expStartIdx = str.indexOf(String.valueOf(expSep));
+        if (expStartIdx > -1) {
+            int expIdx = expStartIdx + expSep.length;
+
+            boolean neg = false;
+            if (str.charAt(expIdx) == opts.getMinusSign()) {
+                ++expIdx;
+            }
+
+            final String expStr = str.substring(expIdx);
+            final int val = Integer.parseInt(expStr);
+            return neg

Review comment:
       Were were supposed to set `neg = true` inside the `if` block above, where it checks if a character at a certain index was equal to `opts.getMinusSign()`?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] coveralls edited a comment on pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
coveralls edited a comment on pull request #248:
URL: https://github.com/apache/commons-text/pull/248#issuecomment-875718096


   
   [![Coverage Status](https://coveralls.io/builds/41445875/badge)](https://coveralls.io/builds/41445875)
   
   Coverage increased (+0.1%) to 98.077% when pulling **a1ca0eb84164db4836d9975ba46c4668de6dd289 on darkma773r:text-207-double-format** into **23a17acd1fa408b41988866e3df480e9c89b4903 on apache:master**.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r671600505



##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;
+
+        /** Construct a new instance that delegates double function construction
+         * to the given factory object.
+         * @param factory factory function
+         */
+        private Builder(final Function<Builder, DoubleFunction<String>> factory) {
+            this.factory = factory;
+        }
+
+        /** Set the maximum number of significant decimal digits used in format
+         * results. A value of {@code 0} indicates no limit. The default value is {@code 0}.
+         * @param maxPrecision maximum precision
+         * @return this instance
+         */
+        public Builder maxPrecision(final int maxPrecision) {
+            this.maxPrecision = maxPrecision;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for formatted strings. No digits with an
+         * absolute value of less than <code>10<sup>minDecimalExponent</sup></code> will
+         * be included in format results. If the number being formatted does not contain
+         * any such digits, then zero is returned. For example, if {@code minDecimalExponent}
+         * is set to {@code -2} and the number {@code 3.14159} is formatted, the plain
+         * format result will be {@code "3.14"}. If {@code 0.001} is formatted, then the
+         * result is the zero string.
+         * @param minDecimalExponent minimum decimal exponent
+         * @return this instance
+         */
+        public Builder minDecimalExponent(final int minDecimalExponent) {
+            this.minDecimalExponent = minDecimalExponent;
+            return this;
+        }
+
+        /** Set the maximum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code 2}, the number {@code 999} will be formatted as {@code "999.0"}
+         * while {@code 1000} will be formatted as {@code "1.0E3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMaxDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMaxDecimalExponent(final int plainFormatMaxDecimalExponent) {
+            this.plainFormatMaxDecimalExponent = plainFormatMaxDecimalExponent;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code -2}, the number {@code 0.01} will be formatted as {@code "0.01"}
+         * while {@code 0.0099} will be formatted as {@code "9.9E-3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMinDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMinDecimalExponent(final int plainFormatMinDecimalExponent) {
+            this.plainFormatMinDecimalExponent = plainFormatMinDecimalExponent;
+            return this;
+        }
+
+        /** Set the flag determining whether or not the zero string may be returned with the minus
+         * sign or if it will always be returned in the positive form. For example, if set to true,
+         * the string {@code "-0.0"} may be returned for some input numbers. If false, only {@code "0.0"}
+         * will be returned, regardless of the sign of the input number. The default value is {@code true}.
+         * @param signedZero if true, the zero string may be returned with a preceding minus sign; if false,
+         *      the zero string will only be returned in its positive form
+         * @return this instance
+         */
+        public Builder allowSignedZero(final boolean signedZero) {
+            this.signedZero = signedZero;
+            return this;
+        }
+
+        /** Set the string containing the digit characters 0-9, in that order. The
+         * default value is the string {@code "0123456789"}.
+         * @param digits string containing the digit characters 0-9
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         * @throws IllegalArgumentException if the argument does not have a length of exactly 10
+         */
+        public Builder digits(final String digits) {
+            Objects.requireNonNull(digits, "Digits string cannot be null");
+            if (digits.length() != DEFAULT_DECIMAL_DIGITS.length()) {
+                throw new IllegalArgumentException("Digits string must contain exactly "
+                        + DEFAULT_DECIMAL_DIGITS.length() + " characters.");
+            }
+
+            this.digits = digits;
+            return this;
+        }
+
+        /** Set the flag determining whether or not a zero character is added in the fraction position
+         * when no fractional value is present. For example, if set to true, the number {@code 1} would
+         * be formatted as {@code "1.0"}. If false, it would be formatted as {@code "1"}. The default
+         * value is {@code true}.
+         * @param fractionPlaceholder if true, a zero character is placed in the fraction position when
+         *      no fractional value is present; if false, fractional digits are only included when needed
+         * @return this instance
+         */
+        public Builder includeFractionPlaceholder(final boolean fractionPlaceholder) {
+            this.fractionPlaceholder = fractionPlaceholder;
+            return this;
+        }
+
+        /** Set the character used as the minus sign.
+         * @param minusSign character to use as the minus sign
+         * @return this instance
+         */
+        public Builder minusSign(final char minusSign) {
+            this.minusSign = minusSign;
+            return this;
+        }
+
+        /** Set the decimal separator character, i.e., the character placed between the
+         * whole number and fractional portions of the formatted strings. The default value
+         * is {@code '.'}.
+         * @param decimalSeparator decimal separator character
+         * @return this instance
+         */
+        public Builder decimalSeparator(final char decimalSeparator) {
+            this.decimalSeparator = decimalSeparator;
+            return this;
+        }
+
+        /** Set the character used to separate groups of thousands. Default value is {@code ','}.
+         * @param groupingSeparator character used to separate groups of thousands
+         * @return this instance
+         * @see #groupThousands(boolean)
+         */
+        public Builder groupingSeparator(final char groupingSeparator) {
+            this.groupingSeparator = groupingSeparator;
+            return this;
+        }
+
+        /** If set to true, thousands will be grouped with the

Review comment:
       I think I've got them all now.

##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;
+
+        /** Construct a new instance that delegates double function construction
+         * to the given factory object.
+         * @param factory factory function
+         */
+        private Builder(final Function<Builder, DoubleFunction<String>> factory) {
+            this.factory = factory;
+        }
+
+        /** Set the maximum number of significant decimal digits used in format
+         * results. A value of {@code 0} indicates no limit. The default value is {@code 0}.
+         * @param maxPrecision maximum precision
+         * @return this instance
+         */
+        public Builder maxPrecision(final int maxPrecision) {
+            this.maxPrecision = maxPrecision;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for formatted strings. No digits with an
+         * absolute value of less than <code>10<sup>minDecimalExponent</sup></code> will
+         * be included in format results. If the number being formatted does not contain
+         * any such digits, then zero is returned. For example, if {@code minDecimalExponent}
+         * is set to {@code -2} and the number {@code 3.14159} is formatted, the plain
+         * format result will be {@code "3.14"}. If {@code 0.001} is formatted, then the
+         * result is the zero string.
+         * @param minDecimalExponent minimum decimal exponent
+         * @return this instance
+         */
+        public Builder minDecimalExponent(final int minDecimalExponent) {
+            this.minDecimalExponent = minDecimalExponent;
+            return this;
+        }
+
+        /** Set the maximum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code 2}, the number {@code 999} will be formatted as {@code "999.0"}
+         * while {@code 1000} will be formatted as {@code "1.0E3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMaxDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMaxDecimalExponent(final int plainFormatMaxDecimalExponent) {
+            this.plainFormatMaxDecimalExponent = plainFormatMaxDecimalExponent;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code -2}, the number {@code 0.01} will be formatted as {@code "0.01"}
+         * while {@code 0.0099} will be formatted as {@code "9.9E-3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMinDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMinDecimalExponent(final int plainFormatMinDecimalExponent) {
+            this.plainFormatMinDecimalExponent = plainFormatMinDecimalExponent;
+            return this;
+        }
+
+        /** Set the flag determining whether or not the zero string may be returned with the minus
+         * sign or if it will always be returned in the positive form. For example, if set to true,
+         * the string {@code "-0.0"} may be returned for some input numbers. If false, only {@code "0.0"}
+         * will be returned, regardless of the sign of the input number. The default value is {@code true}.
+         * @param signedZero if true, the zero string may be returned with a preceding minus sign; if false,
+         *      the zero string will only be returned in its positive form
+         * @return this instance
+         */
+        public Builder allowSignedZero(final boolean signedZero) {
+            this.signedZero = signedZero;
+            return this;
+        }
+
+        /** Set the string containing the digit characters 0-9, in that order. The
+         * default value is the string {@code "0123456789"}.
+         * @param digits string containing the digit characters 0-9
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         * @throws IllegalArgumentException if the argument does not have a length of exactly 10
+         */
+        public Builder digits(final String digits) {
+            Objects.requireNonNull(digits, "Digits string cannot be null");
+            if (digits.length() != DEFAULT_DECIMAL_DIGITS.length()) {
+                throw new IllegalArgumentException("Digits string must contain exactly "
+                        + DEFAULT_DECIMAL_DIGITS.length() + " characters.");
+            }
+
+            this.digits = digits;
+            return this;
+        }
+
+        /** Set the flag determining whether or not a zero character is added in the fraction position
+         * when no fractional value is present. For example, if set to true, the number {@code 1} would
+         * be formatted as {@code "1.0"}. If false, it would be formatted as {@code "1"}. The default
+         * value is {@code true}.
+         * @param fractionPlaceholder if true, a zero character is placed in the fraction position when
+         *      no fractional value is present; if false, fractional digits are only included when needed
+         * @return this instance
+         */
+        public Builder includeFractionPlaceholder(final boolean fractionPlaceholder) {
+            this.fractionPlaceholder = fractionPlaceholder;
+            return this;
+        }
+
+        /** Set the character used as the minus sign.
+         * @param minusSign character to use as the minus sign
+         * @return this instance
+         */
+        public Builder minusSign(final char minusSign) {
+            this.minusSign = minusSign;
+            return this;
+        }
+
+        /** Set the decimal separator character, i.e., the character placed between the
+         * whole number and fractional portions of the formatted strings. The default value
+         * is {@code '.'}.
+         * @param decimalSeparator decimal separator character
+         * @return this instance
+         */
+        public Builder decimalSeparator(final char decimalSeparator) {
+            this.decimalSeparator = decimalSeparator;
+            return this;
+        }
+
+        /** Set the character used to separate groups of thousands. Default value is {@code ','}.
+         * @param groupingSeparator character used to separate groups of thousands
+         * @return this instance
+         * @see #groupThousands(boolean)
+         */
+        public Builder groupingSeparator(final char groupingSeparator) {
+            this.groupingSeparator = groupingSeparator;
+            return this;
+        }
+
+        /** If set to true, thousands will be grouped with the
+         * {@link #groupingSeparator(char) grouping separator}. For example, if set to true,
+         * the number {@code 1000} could be formatted as {@code "1,000"}. This property only applies
+         * to the {@link DoubleFormat#PLAIN PLAIN} format. Default value is {@code false}.
+         * @param groupThousands if true, thousands will be grouped
+         * @return this instance
+         * @see #groupingSeparator(char)
+         */
+        public Builder groupThousands(final boolean groupThousands) {
+            this.groupThousands = groupThousands;
+            return this;
+        }
+
+        /** Set the exponent separator character, i.e., the string placed between
+         * the mantissa and the exponent. The default value is {@code "E"}, as in
+         * {@code "1.2E6"}.
+         * @param exponentSeparator exponent separator string
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder exponentSeparator(final String exponentSeparator) {
+            Objects.requireNonNull(exponentSeparator, "Exponent separator cannot be null");
+
+            this.exponentSeparator = exponentSeparator;
+            return this;
+        }
+
+        /** Set the flag indicating if an exponent value should always be included in the
+         * formatted value, even if the exponent value is zero. This property only applies
+         * to formats that use scientific notation, namely
+         * {@link DoubleFormat#SCIENTIFIC SCIENTIFIC},
+         * {@link DoubleFormat#ENGINEERING ENGINEERING}, and
+         * {@link DoubleFormat#MIXED MIXED}. The default value is {@code false}.
+         * @param alwaysIncludeExponent if true, exponents will always be included in formatted
+         *      output even if the exponent value is zero
+         * @return this instance
+         */
+        public Builder alwaysIncludeExponent(final boolean alwaysIncludeExponent) {
+            this.alwaysIncludeExponent = alwaysIncludeExponent;
+            return this;
+        }
+
+        /** Set the string used to represent infinity. For negative infinity, this string
+         * is prefixed with the {@link #minusSign(char) minus sign}.
+         * @param infinity string used to represent infinity
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder infinity(final String infinity) {
+            Objects.requireNonNull(infinity, "Infinity string cannot be null");
+
+            this.infinity = infinity;
+            return this;
+        }
+
+        /** Set the string used to represent {@link Double#NaN}.
+         * @param nan string used to represent {@link Double#NaN}
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder nan(final String nan) {
+            Objects.requireNonNull(nan, "NaN string cannot be null");
+
+            this.nan = nan;
+            return this;
+        }
+
+        /** Configure this instance with the given format symbols. The following values
+         * are set:
+         * <ul>
+         *  <li>{@link #digits(String) digit characters}</li>
+         *  <li>{@link #decimalSeparator(char) decimal separator}</li>
+         *  <li>{@link #groupingSeparator(char) thousands grouping separator}</li>
+         *  <li>{@link #minusSign(char) minus sign}</li>
+         *  <li>{@link #exponentSeparator(String) exponent separator}</li>
+         *  <li>{@link #infinity(String) infinity}</li>
+         *  <li>{@link #nan(String) NaN}</li>
+         * </ul>
+         * The digit character string is constructed by starting at the configured
+         * {@link DecimalFormatSymbols#getZeroDigit() zero digit} and adding the next
+         * 9 consecutive characters.
+         * @param symbols format symbols
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder formatSymbols(final DecimalFormatSymbols symbols) {
+            Objects.requireNonNull(symbols, "Decimal format symbols cannot be null");
+
+            return digits(getDigitString(symbols))
+                    .decimalSeparator(symbols.getDecimalSeparator())
+                    .groupingSeparator(symbols.getGroupingSeparator())
+                    .minusSign(symbols.getMinusSign())
+                    .exponentSeparator(symbols.getExponentSeparator())
+                    .infinity(symbols.getInfinity())
+                    .nan(symbols.getNaN());
+        }
+
+        /** Get a string containing the localized digits 0-9 for the given symbols object. The
+         * string is constructed by starting at the {@link DecimalFormatSymbols#getZeroDigit() zero digit}
+         * and adding the next 9 consecutive characters.
+         * @param symbols symbols object
+         * @return string containing the localized digits 0-9
+         */
+        private String getDigitString(final DecimalFormatSymbols symbols) {
+            final int zeroDelta = symbols.getZeroDigit() - DEFAULT_DECIMAL_DIGITS.charAt(0);
+
+            final char[] digitChars = new char[DEFAULT_DECIMAL_DIGITS.length()];
+            for (int i = 0; i < DEFAULT_DECIMAL_DIGITS.length(); ++i) {
+                digitChars[i] = (char) (DEFAULT_DECIMAL_DIGITS.charAt(i) + zeroDelta);
+            }
+
+            return String.valueOf(digitChars);
+        }
+
+        /** Construct a new double format function.
+         * @return format function
+         */
+        public DoubleFunction<String> build() {
+            return factory.apply(this);
+        }
+    }
+
+    /** Base class for standard double formatting classes.
+     */
+    private abstract static class AbstractDoubleFormat
+        implements DoubleFunction<String>, ParsedDecimal.FormatOptions {
+
+        /** Maximum precision; 0 indicates no limit. */
+        private final int maxPrecision;
+
+        /** Minimum decimal exponent. */
+        private final int minDecimalExponent;
+
+        /** String representing positive infinity. */
+        private final String postiveInfinity;

Review comment:
       Good catch.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] kinow commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
kinow commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r671751769



##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;

Review comment:
       Fair enough @darkma773r. The API is easy to understand, and the documentation is great too. So let's go with the current design and see if we get feedback from other devs and/or users.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] aherbert commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
aherbert commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r664657149



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,715 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int resultExponent = digitCount + exponent - decimalPos;
+
+        // only include the exponent string if it is non-zero or it is requested to be present
+        final char[] resultExponentChars = resultExponent != 0 || opts.getAlwaysIncludeExponent()
+                ? Integer.toString(Math.abs(resultExponent)).toCharArray()
+                : null;
+
+        prepareOutput(getScientificStringSize(decimalPos, resultExponent, resultExponentChars, opts));
+
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (resultExponentChars != null) {
+            append(opts.getExponentSeparatorChars());
+
+            if (resultExponent < 0) {
+                append(opts.getMinusSign());
+            }
+
+            final char[] localizedDigits = opts.getDigits();
+            for (int i = 0; i < resultExponentChars.length; ++i) {
+                final int val = digitValue(resultExponentChars[i]);
+                appendLocalizedDigit(val, localizedDigits);
+            }
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return true if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index
+     * @param opts format options
+     */
+    private void appendFraction(final int zeroCount, final int startIdx, final FormatOptions opts) {
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        if (startIdx < digitCount) {
+            append(opts.getDecimalSeparator());
+
+            // add the zero prefix
+            for (int i = 0; i < zeroCount; ++i) {
+                append(localizedZero);
+            }
+
+            // add the fraction digits
+            for (int i = startIdx; i < digitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+        } else if (opts.getIncludeFractionPlaceholder()) {
+            append(opts.getDecimalSeparator());
+            append(localizedZero);
+        }
+    }
+
+    /** Get the number of characters required to create a plain format representation
+     * of this value.
+     * @param decimalPos decimal position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters in the plain string representation of this value,
+     *      created using the given parameters
+     */
+    private int getPlainStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+
+        // adjust for groupings if needed
+        if (opts.getGroupThousands() && decimalPos > 0) {
+            size += (decimalPos - 1) / THOUSANDS_GROUP_SIZE;
+        }
+
+        return size;
+    }
+
+    /** Get the number of characters required to create a scientific format representation
+     * of this value.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param resultExponent target exponent
+     * @param resultExponentChars target exponent characters; if null, no explicit exponent is to be included
+     * @param opts format options
+     * @return number of characters in the scientific representation of this string, using the
+     *      given parameters
+     */
+    private int getScientificStringSize(final int decimalPos, final int resultExponent,
+            final char[] resultExponentChars, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+        if (resultExponentChars != null) {
+            size += opts.getExponentSeparatorChars().length + resultExponentChars.length;
+            if (resultExponent < 0) {
+                // adjust for the exponent minus sign
+                ++size;
+            }
+        }
+        return size;
+    }
+
+    /** Get the number of characters required for the digit portion of a string representation of
+     * this value. This excludes any exponent or thousands groupings characters.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters required for the digit portion of a string representation of
+     *      this value
+     */
+    private int getDigitStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = digitCount;
+        if (shouldIncludeMinus(opts)) {
+            ++size;
+        }
+        if (decimalPos < 1) {
+            // no whole component;
+            // add decimal point and leading zeros
+            size += 2 + Math.abs(decimalPos);
+        } else if (decimalPos >= digitCount) {
+            // no fraction component;
+            // add trailing zeros
+            size += decimalPos - digitCount;
+            if (opts.getIncludeFractionPlaceholder()) {
+                size += 2;
+            }
+        } else {
+            // whole and fraction components;
+            // add decimal point
+            size += 1;
+        }
+
+        return size;
+    }
+
+
+    /** Return true if formatted strings should include the minus sign, considering
+     * the value of this instance and the given format options.
+     * @param opts format options
+     * @return true if a minus sign should be included in the output
+     */
+    private boolean shouldIncludeMinus(final FormatOptions opts) {
+        return negative && (opts.getSignedZero() || !isZero());
+    }
+
+    /** Return true if a rounding operation for the given number of digits should
+     * round up.
+     * @param count number of digits to round to; must be greater than zero and less
+     *      than the current number of digits
+     * @return true if a rounding operation for the given number of digits should
+     *      round up
+     */
+    private boolean shouldRoundUp(final int count) {
+        // Round up in the following cases:
+        // 1. The digit after the last digit is greater than 5.
+        // 2. The digit after the last digit is 5 and there are additional (non-zero)
+        //      digits after it.
+        // 3. The digit after the last digit is 5, there are no additional digits afterward,
+        //      and the last digit is odd (half-even rounding).
+        final int digitAfterLast = digits[count];
+
+        return digitAfterLast > ROUND_CENTER || (digitAfterLast == ROUND_CENTER
+                && (count < digitCount - 1 || (digits[count - 1] % 2) != 0));
+    }
+
+    /** Round the value up to the given number of digits.
+     * @param count target number of digits; must be greater than zero and
+     *      less than the current number of digits
+     */
+    private void roundUp(final int count) {
+        int removedDigits = digitCount - count;
+        int i;
+        for (i = count - 1; i >= 0; --i) {
+            final int d = digits[i] + 1;
+
+            if (d < DECIMAL_RADIX) {
+                // value did not carry over; done adding
+                digits[i] = d;
+                break;
+            } else {
+                // value carried over; the current position is 0
+                // which we will ignore by shortening the digit count
+                ++removedDigits;
+            }
+        }
+
+        if (i < 0) {
+            // all values carried over
+            setSingleDigitValue(1, exponent + removedDigits);
+        } else {
+            // values were updated in-place; just need to update the length
+            truncate(digitCount - removedDigits);
+        }
+    }
+
+    /** Return true if this value is equal to zero. The sign field is ignored,
+     * meaning that this method will return true for both {@code +0} and {@code -0}.
+     * @return true if the value is equal to zero
+     */
+    boolean isZero() {
+        return digits[0] == 0;
+    }
+
+    /** Set the value of this instance to a single digit with the given exponent.
+     * The sign of the value is retained.
+     * @param digit digit value
+     * @param newExponent new exponent value
+     */
+    private void setSingleDigitValue(final int digit, final int newExponent) {
+        digits[0] = digit;
+        digitCount = 1;
+        exponent = newExponent;
+    }
+
+    /** Truncate the value to the given number of digits.
+     * @param count number of digits; must be greater than zero and less than
+     *      the current number of digits
+     */
+    private void truncate(final int count) {
+        int nonZeroCount = count;
+        for (int i = count - 1;
+                i >= 0 && digits[i] == 0;
+                --i) {
+            --nonZeroCount;
+        }
+        exponent += digitCount - nonZeroCount;
+        digitCount = nonZeroCount;
+    }
+
+    /** Construct a new instance from the given double value.
+     * @param d double value
+     * @return a new instance containing the parsed components of the given double value
+     * @throws IllegalArgumentException if {@code d} is {@code NaN} or infinite
+     */
+    public static ParsedDecimal from(final double d) {
+        if (!Double.isFinite(d)) {
+            throw new IllegalArgumentException("Double is not finite");
+        }
+
+        // Get the canonical string representation of the double value and parse
+        // it to extract the components of the decimal value. From the documentation
+        // of Double.toString() and the fact that d is finite, we are guaranteed the
+        // following:
+        // - the string will not be empty
+        // - it will contain exactly one decimal point character
+        // - all digit characters are in the ASCII range
+        final char[] strChars = Double.toString(d).toCharArray();

Review comment:
       OK. I did wonder about it as my JDK source code has an index comparison check:
   ```java
       public char charAt(int index) {
           if ((index < 0) || (index >= value.length)) {
               throw new StringIndexOutOfBoundsException(index);
           }
           return value[index];
       }
   ```
   It would appear that the method call here is significant to performance.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on pull request #248:
URL: https://github.com/apache/commons-text/pull/248#issuecomment-877853071


   Thanks, @kinow! I've made some updates based on your feedback so far.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r664636511



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,715 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int resultExponent = digitCount + exponent - decimalPos;
+
+        // only include the exponent string if it is non-zero or it is requested to be present
+        final char[] resultExponentChars = resultExponent != 0 || opts.getAlwaysIncludeExponent()
+                ? Integer.toString(Math.abs(resultExponent)).toCharArray()
+                : null;
+
+        prepareOutput(getScientificStringSize(decimalPos, resultExponent, resultExponentChars, opts));
+
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (resultExponentChars != null) {
+            append(opts.getExponentSeparatorChars());
+
+            if (resultExponent < 0) {
+                append(opts.getMinusSign());
+            }
+
+            final char[] localizedDigits = opts.getDigits();
+            for (int i = 0; i < resultExponentChars.length; ++i) {
+                final int val = digitValue(resultExponentChars[i]);
+                appendLocalizedDigit(val, localizedDigits);
+            }
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return true if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index
+     * @param opts format options
+     */
+    private void appendFraction(final int zeroCount, final int startIdx, final FormatOptions opts) {
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        if (startIdx < digitCount) {
+            append(opts.getDecimalSeparator());
+
+            // add the zero prefix
+            for (int i = 0; i < zeroCount; ++i) {
+                append(localizedZero);
+            }
+
+            // add the fraction digits
+            for (int i = startIdx; i < digitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+        } else if (opts.getIncludeFractionPlaceholder()) {
+            append(opts.getDecimalSeparator());
+            append(localizedZero);
+        }
+    }
+
+    /** Get the number of characters required to create a plain format representation
+     * of this value.
+     * @param decimalPos decimal position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters in the plain string representation of this value,
+     *      created using the given parameters
+     */
+    private int getPlainStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+
+        // adjust for groupings if needed
+        if (opts.getGroupThousands() && decimalPos > 0) {
+            size += (decimalPos - 1) / THOUSANDS_GROUP_SIZE;
+        }
+
+        return size;
+    }
+
+    /** Get the number of characters required to create a scientific format representation
+     * of this value.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param resultExponent target exponent
+     * @param resultExponentChars target exponent characters; if null, no explicit exponent is to be included
+     * @param opts format options
+     * @return number of characters in the scientific representation of this string, using the
+     *      given parameters
+     */
+    private int getScientificStringSize(final int decimalPos, final int resultExponent,
+            final char[] resultExponentChars, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+        if (resultExponentChars != null) {
+            size += opts.getExponentSeparatorChars().length + resultExponentChars.length;
+            if (resultExponent < 0) {
+                // adjust for the exponent minus sign
+                ++size;
+            }
+        }
+        return size;
+    }
+
+    /** Get the number of characters required for the digit portion of a string representation of
+     * this value. This excludes any exponent or thousands groupings characters.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters required for the digit portion of a string representation of
+     *      this value
+     */
+    private int getDigitStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = digitCount;
+        if (shouldIncludeMinus(opts)) {
+            ++size;
+        }
+        if (decimalPos < 1) {
+            // no whole component;
+            // add decimal point and leading zeros
+            size += 2 + Math.abs(decimalPos);
+        } else if (decimalPos >= digitCount) {
+            // no fraction component;
+            // add trailing zeros
+            size += decimalPos - digitCount;
+            if (opts.getIncludeFractionPlaceholder()) {
+                size += 2;
+            }
+        } else {
+            // whole and fraction components;
+            // add decimal point
+            size += 1;
+        }
+
+        return size;
+    }
+
+
+    /** Return true if formatted strings should include the minus sign, considering
+     * the value of this instance and the given format options.
+     * @param opts format options
+     * @return true if a minus sign should be included in the output
+     */
+    private boolean shouldIncludeMinus(final FormatOptions opts) {
+        return negative && (opts.getSignedZero() || !isZero());
+    }
+
+    /** Return true if a rounding operation for the given number of digits should
+     * round up.
+     * @param count number of digits to round to; must be greater than zero and less
+     *      than the current number of digits
+     * @return true if a rounding operation for the given number of digits should
+     *      round up
+     */
+    private boolean shouldRoundUp(final int count) {
+        // Round up in the following cases:
+        // 1. The digit after the last digit is greater than 5.
+        // 2. The digit after the last digit is 5 and there are additional (non-zero)
+        //      digits after it.
+        // 3. The digit after the last digit is 5, there are no additional digits afterward,
+        //      and the last digit is odd (half-even rounding).
+        final int digitAfterLast = digits[count];
+
+        return digitAfterLast > ROUND_CENTER || (digitAfterLast == ROUND_CENTER
+                && (count < digitCount - 1 || (digits[count - 1] % 2) != 0));
+    }
+
+    /** Round the value up to the given number of digits.
+     * @param count target number of digits; must be greater than zero and
+     *      less than the current number of digits
+     */
+    private void roundUp(final int count) {
+        int removedDigits = digitCount - count;
+        int i;
+        for (i = count - 1; i >= 0; --i) {
+            final int d = digits[i] + 1;
+
+            if (d < DECIMAL_RADIX) {
+                // value did not carry over; done adding
+                digits[i] = d;
+                break;
+            } else {
+                // value carried over; the current position is 0
+                // which we will ignore by shortening the digit count
+                ++removedDigits;
+            }
+        }
+
+        if (i < 0) {
+            // all values carried over
+            setSingleDigitValue(1, exponent + removedDigits);
+        } else {
+            // values were updated in-place; just need to update the length
+            truncate(digitCount - removedDigits);
+        }
+    }
+
+    /** Return true if this value is equal to zero. The sign field is ignored,
+     * meaning that this method will return true for both {@code +0} and {@code -0}.
+     * @return true if the value is equal to zero
+     */
+    boolean isZero() {
+        return digits[0] == 0;
+    }
+
+    /** Set the value of this instance to a single digit with the given exponent.
+     * The sign of the value is retained.
+     * @param digit digit value
+     * @param newExponent new exponent value
+     */
+    private void setSingleDigitValue(final int digit, final int newExponent) {
+        digits[0] = digit;
+        digitCount = 1;
+        exponent = newExponent;
+    }
+
+    /** Truncate the value to the given number of digits.
+     * @param count number of digits; must be greater than zero and less than
+     *      the current number of digits
+     */
+    private void truncate(final int count) {
+        int nonZeroCount = count;
+        for (int i = count - 1;
+                i >= 0 && digits[i] == 0;
+                --i) {
+            --nonZeroCount;
+        }
+        exponent += digitCount - nonZeroCount;
+        digitCount = nonZeroCount;
+    }
+
+    /** Construct a new instance from the given double value.
+     * @param d double value
+     * @return a new instance containing the parsed components of the given double value
+     * @throws IllegalArgumentException if {@code d} is {@code NaN} or infinite
+     */
+    public static ParsedDecimal from(final double d) {
+        if (!Double.isFinite(d)) {
+            throw new IllegalArgumentException("Double is not finite");
+        }
+
+        // Get the canonical string representation of the double value and parse
+        // it to extract the components of the decimal value. From the documentation
+        // of Double.toString() and the fact that d is finite, we are guaranteed the
+        // following:
+        // - the string will not be empty
+        // - it will contain exactly one decimal point character
+        // - all digit characters are in the ASCII range
+        final char[] strChars = Double.toString(d).toCharArray();

Review comment:
       I switched to using raw char arrays since I profiled the benchmarks and saw a non-trivial amount of time being spent in 'String.charAt()`




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r664783688



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,715 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int resultExponent = digitCount + exponent - decimalPos;
+
+        // only include the exponent string if it is non-zero or it is requested to be present
+        final char[] resultExponentChars = resultExponent != 0 || opts.getAlwaysIncludeExponent()
+                ? Integer.toString(Math.abs(resultExponent)).toCharArray()
+                : null;
+
+        prepareOutput(getScientificStringSize(decimalPos, resultExponent, resultExponentChars, opts));
+
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (resultExponentChars != null) {
+            append(opts.getExponentSeparatorChars());
+
+            if (resultExponent < 0) {
+                append(opts.getMinusSign());
+            }
+
+            final char[] localizedDigits = opts.getDigits();
+            for (int i = 0; i < resultExponentChars.length; ++i) {
+                final int val = digitValue(resultExponentChars[i]);
+                appendLocalizedDigit(val, localizedDigits);
+            }
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return true if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index
+     * @param opts format options
+     */
+    private void appendFraction(final int zeroCount, final int startIdx, final FormatOptions opts) {
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        if (startIdx < digitCount) {
+            append(opts.getDecimalSeparator());
+
+            // add the zero prefix
+            for (int i = 0; i < zeroCount; ++i) {
+                append(localizedZero);
+            }
+
+            // add the fraction digits
+            for (int i = startIdx; i < digitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+        } else if (opts.getIncludeFractionPlaceholder()) {
+            append(opts.getDecimalSeparator());
+            append(localizedZero);
+        }
+    }
+
+    /** Get the number of characters required to create a plain format representation
+     * of this value.
+     * @param decimalPos decimal position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters in the plain string representation of this value,
+     *      created using the given parameters
+     */
+    private int getPlainStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+
+        // adjust for groupings if needed
+        if (opts.getGroupThousands() && decimalPos > 0) {
+            size += (decimalPos - 1) / THOUSANDS_GROUP_SIZE;
+        }
+
+        return size;
+    }
+
+    /** Get the number of characters required to create a scientific format representation
+     * of this value.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param resultExponent target exponent
+     * @param resultExponentChars target exponent characters; if null, no explicit exponent is to be included
+     * @param opts format options
+     * @return number of characters in the scientific representation of this string, using the
+     *      given parameters
+     */
+    private int getScientificStringSize(final int decimalPos, final int resultExponent,
+            final char[] resultExponentChars, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+        if (resultExponentChars != null) {
+            size += opts.getExponentSeparatorChars().length + resultExponentChars.length;
+            if (resultExponent < 0) {
+                // adjust for the exponent minus sign
+                ++size;
+            }
+        }
+        return size;
+    }
+
+    /** Get the number of characters required for the digit portion of a string representation of
+     * this value. This excludes any exponent or thousands groupings characters.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters required for the digit portion of a string representation of
+     *      this value
+     */
+    private int getDigitStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = digitCount;
+        if (shouldIncludeMinus(opts)) {
+            ++size;
+        }
+        if (decimalPos < 1) {
+            // no whole component;
+            // add decimal point and leading zeros
+            size += 2 + Math.abs(decimalPos);
+        } else if (decimalPos >= digitCount) {
+            // no fraction component;
+            // add trailing zeros
+            size += decimalPos - digitCount;
+            if (opts.getIncludeFractionPlaceholder()) {
+                size += 2;
+            }
+        } else {
+            // whole and fraction components;
+            // add decimal point
+            size += 1;
+        }
+
+        return size;
+    }
+
+
+    /** Return true if formatted strings should include the minus sign, considering
+     * the value of this instance and the given format options.
+     * @param opts format options
+     * @return true if a minus sign should be included in the output
+     */
+    private boolean shouldIncludeMinus(final FormatOptions opts) {
+        return negative && (opts.getSignedZero() || !isZero());
+    }
+
+    /** Return true if a rounding operation for the given number of digits should
+     * round up.
+     * @param count number of digits to round to; must be greater than zero and less
+     *      than the current number of digits
+     * @return true if a rounding operation for the given number of digits should
+     *      round up
+     */
+    private boolean shouldRoundUp(final int count) {
+        // Round up in the following cases:
+        // 1. The digit after the last digit is greater than 5.
+        // 2. The digit after the last digit is 5 and there are additional (non-zero)
+        //      digits after it.
+        // 3. The digit after the last digit is 5, there are no additional digits afterward,
+        //      and the last digit is odd (half-even rounding).
+        final int digitAfterLast = digits[count];
+
+        return digitAfterLast > ROUND_CENTER || (digitAfterLast == ROUND_CENTER
+                && (count < digitCount - 1 || (digits[count - 1] % 2) != 0));
+    }
+
+    /** Round the value up to the given number of digits.
+     * @param count target number of digits; must be greater than zero and
+     *      less than the current number of digits
+     */
+    private void roundUp(final int count) {
+        int removedDigits = digitCount - count;
+        int i;
+        for (i = count - 1; i >= 0; --i) {
+            final int d = digits[i] + 1;
+
+            if (d < DECIMAL_RADIX) {
+                // value did not carry over; done adding
+                digits[i] = d;
+                break;
+            } else {
+                // value carried over; the current position is 0
+                // which we will ignore by shortening the digit count
+                ++removedDigits;
+            }
+        }
+
+        if (i < 0) {
+            // all values carried over
+            setSingleDigitValue(1, exponent + removedDigits);
+        } else {
+            // values were updated in-place; just need to update the length
+            truncate(digitCount - removedDigits);
+        }
+    }
+
+    /** Return true if this value is equal to zero. The sign field is ignored,
+     * meaning that this method will return true for both {@code +0} and {@code -0}.
+     * @return true if the value is equal to zero
+     */
+    boolean isZero() {
+        return digits[0] == 0;
+    }
+
+    /** Set the value of this instance to a single digit with the given exponent.
+     * The sign of the value is retained.
+     * @param digit digit value
+     * @param newExponent new exponent value
+     */
+    private void setSingleDigitValue(final int digit, final int newExponent) {
+        digits[0] = digit;
+        digitCount = 1;
+        exponent = newExponent;
+    }
+
+    /** Truncate the value to the given number of digits.
+     * @param count number of digits; must be greater than zero and less than
+     *      the current number of digits
+     */
+    private void truncate(final int count) {
+        int nonZeroCount = count;
+        for (int i = count - 1;
+                i >= 0 && digits[i] == 0;
+                --i) {
+            --nonZeroCount;
+        }
+        exponent += digitCount - nonZeroCount;
+        digitCount = nonZeroCount;
+    }
+
+    /** Construct a new instance from the given double value.
+     * @param d double value
+     * @return a new instance containing the parsed components of the given double value
+     * @throws IllegalArgumentException if {@code d} is {@code NaN} or infinite
+     */
+    public static ParsedDecimal from(final double d) {
+        if (!Double.isFinite(d)) {
+            throw new IllegalArgumentException("Double is not finite");
+        }
+
+        // Get the canonical string representation of the double value and parse
+        // it to extract the components of the decimal value. From the documentation
+        // of Double.toString() and the fact that d is finite, we are guaranteed the
+        // following:
+        // - the string will not be empty
+        // - it will contain exactly one decimal point character
+        // - all digit characters are in the ASCII range
+        final char[] strChars = Double.toString(d).toCharArray();
+
+        final boolean negative = strChars[0] == MINUS_CHAR;
+        final int digitStartIdx = negative ? 1 : 0;
+
+        final int[] digits = new int[strChars.length];
+
+        boolean foundDecimalPoint = false;
+        int digitCount = 0;
+        int significantDigitCount = 0;
+        int decimalPos = 0;
+
+        int i;
+        for (i = digitStartIdx; i < strChars.length; ++i) {
+            final char ch = strChars[i];
+
+            if (ch == DECIMAL_SEP_CHAR) {
+                foundDecimalPoint = true;
+                decimalPos = digitCount;
+            } else if (ch == EXPONENT_CHAR) {
+                // no more mantissa digits
+                break;
+            } else if (ch != ZERO_CHAR || digitCount > 0) {
+                // this is either the first non-zero digit or one after it
+                final int val = digitValue(ch);
+                digits[digitCount++] = val;
+
+                if (val > 0) {
+                    significantDigitCount = digitCount;
+                }
+            } else if (foundDecimalPoint) {
+                // leading zero in a fraction; adjust the decimal position
+                --decimalPos;
+            }
+        }
+
+        if (digitCount > 0) {
+            // determine the exponent
+            final int explicitExponent = i < strChars.length
+                    ? parseExponent(strChars, i + 1)
+                    : 0;
+            final int exponent = explicitExponent + decimalPos - significantDigitCount;
+
+            return new ParsedDecimal(negative, digits, significantDigitCount, exponent);
+        }
+
+        // no non-zero digits, so value is zero
+        return new ParsedDecimal(negative, new int[] {0}, 1, 0);

Review comment:
       > The examples were of output that could be possible with a zero depending on formatting options.
   
   The only possible output formats for zero are listed below. Each form could also be prefixed with a minus sign if needed and allowed by the configuration.
   ```
   0
   0.0
   0E0
   0.0E0
   ```
   > So can you change the state of a zero?
   
   Not the numeric state. However, each instance has an internal char buffer for building output strings. So, the instances are not thread-safe.
   




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r671691789



##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;

Review comment:
       I've gone back and forth on this. On one hand, having builder options that only apply to certain formats seems confusing. Users must read the docs in order to ensure that an option will apply to their situation. On the other hand, having separate builder types for each format would make the API considerably more complicated. Plus, with a single builder API, users can separate their configuration from the actual format used. For example,
   ```java
   private static DoubleFunction<String> createFormatter(DoubleFormat formatType) {
       return formatType.builder()
           .maxPrecision(6)
           .alwaysIncludeExponent(true) // may or may not apply
           .build();
   }
   ```
   So, I'm still leaning toward the current implementation, where the builder type contains a superset of all options across all format types.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r671600284



##########
File path: src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.java
##########
@@ -0,0 +1,764 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+import java.util.function.BiFunction;
+
+import org.apache.commons.rng.UniformRandomProvider;
+import org.apache.commons.rng.simple.RandomSource;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+class ParsedDecimalTest {
+
+    @Test
+    void testFrom() {
+        // act/assert
+        checkFrom(0.0, "0", 0);
+
+        checkFrom(1.0, "1", 0);
+        checkFrom(10.0, "1", 1);
+        checkFrom(100.0, "1", 2);
+        checkFrom(1000.0, "1", 3);
+        checkFrom(10000.0, "1", 4);
+
+        checkFrom(0.1, "1", -1);
+        checkFrom(0.01, "1", -2);
+        checkFrom(0.001, "1", -3);
+        checkFrom(0.0001, "1", -4);
+        checkFrom(0.00001, "1", -5);
+
+        checkFrom(1.2, "12", -1);
+        checkFrom(0.00971, "971", -5);
+        checkFrom(56300, "563", 2);
+
+        checkFrom(123.0, "123", 0);
+        checkFrom(1230.0, "123", 1);
+        checkFrom(12300.0, "123", 2);
+        checkFrom(123000.0, "123", 3);
+
+        checkFrom(12.3, "123", -1);
+        checkFrom(1.23, "123", -2);
+        checkFrom(0.123, "123", -3);
+        checkFrom(0.0123, "123", -4);
+
+        checkFrom(1.987654321e270, "1987654321", 261);
+        checkFrom(1.987654321e-270, "1987654321", -279);
+
+        checkFrom(Math.PI, "3141592653589793", -15);
+        checkFrom(Math.E, "2718281828459045", -15);
+
+        checkFrom(Double.MAX_VALUE, "17976931348623157", 292);
+        checkFrom(Double.MIN_VALUE, "49", -325);
+        checkFrom(Double.MIN_NORMAL, "22250738585072014", -324);
+    }
+
+    @Test
+    void testFrom_notFinite() {
+        // arrange
+        final String msg = "Double is not finite";
+
+        // act/assert
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NaN),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NEGATIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.POSITIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+    }
+
+    @Test
+    void testIsZero() {
+        // act/assert
+        Assertions.assertTrue(ParsedDecimal.from(0.0).isZero());
+        Assertions.assertTrue(ParsedDecimal.from(-0.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(1.0).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-1.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MIN_NORMAL).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_NORMAL).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MAX_VALUE).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_VALUE).isZero());
+    }
+
+    @Test
+    void testRound_one() {
+        // arrange
+        final double a = 1e-10;
+        final double b = -1;
+        final double c = 1e10;
+
+        // act/assert
+        assertRound(a, -11, false, "1", -10);
+        assertRound(a, -10, false, "1", -10);
+        assertRound(a, -9, false, "0", 0);
+
+        assertRound(b, -1, true, "1", 0);
+        assertRound(b, 0, true, "1", 0);
+        assertRound(b, 1, true, "0", 0);
+
+        assertRound(c, 9, false, "1", 10);
+        assertRound(c, 10, false, "1", 10);
+        assertRound(c, 11, false, "0", 0);
+    }
+
+    @Test
+    void testRound_nine() {
+        // arrange
+        final double a = 9e-10;
+        final double b = -9;
+        final double c = 9e10;
+
+        // act/assert
+        assertRound(a, -11, false, "9", -10);
+        assertRound(a, -10, false, "9", -10);
+        assertRound(a, -9, false, "1", -9);
+
+        assertRound(b, -1, true, "9", 0);
+        assertRound(b, 0, true, "9", 0);
+        assertRound(b, 1, true, "1", 1);
+
+        assertRound(c, 9, false, "9", 10);
+        assertRound(c, 10, false, "9", 10);
+        assertRound(c, 11, false, "1", 11);
+    }
+
+    @Test
+    void testRound_mixed() {
+        // arrange
+        final double a = 9.94e-10;
+        final double b = -3.1415;
+        final double c = 5.55e10;
+
+        // act/assert
+        assertRound(a, -13, false, "994", -12);
+        assertRound(a, -12, false, "994", -12);
+        assertRound(a, -11, false, "99", -11);
+        assertRound(a, -10, false, "1", -9);
+        assertRound(a, -9, false, "1", -9);
+        assertRound(a, -8, false, "0", 0);
+
+        assertRound(b, -5, true, "31415", -4);
+        assertRound(b, -4, true, "31415", -4);
+        assertRound(b, -3, true, "3142", -3);
+        assertRound(b, -2, true, "314", -2);
+        assertRound(b, -1, true, "31", -1);
+        assertRound(b, 0, true, "3", 0);
+        assertRound(b, 1, true, "0", 0);
+        assertRound(b, 2, true, "0", 0);
+
+        assertRound(c, 7, false, "555", 8);
+        assertRound(c, 8, false, "555", 8);
+        assertRound(c, 9, false, "56", 9);
+        assertRound(c, 10, false, "6", 10);
+        assertRound(c, 11, false, "1", 11);
+        assertRound(c, 12, false, "0", 0);
+    }
+
+    @Test
+    void testMaxPrecision() {
+        // arrange
+        final double d = 1.02576552;
+
+        // act
+        assertMaxPrecision(d, 10, false, "102576552", -8);
+        assertMaxPrecision(d, 9, false, "102576552", -8);
+        assertMaxPrecision(d, 8, false, "10257655", -7);
+        assertMaxPrecision(d, 7, false, "1025766", -6);
+        assertMaxPrecision(d, 6, false, "102577", -5);
+        assertMaxPrecision(d, 5, false, "10258", -4);
+        assertMaxPrecision(d, 4, false, "1026", -3);
+        assertMaxPrecision(d, 3, false, "103", -2);
+        assertMaxPrecision(d, 2, false, "1", 0);
+        assertMaxPrecision(d, 1, false, "1", 0);
+
+        assertMaxPrecision(d, 0, false, "102576552", -8);
+    }
+
+    @Test
+    void testMaxPrecision_carry() {
+        // arrange
+        final double d = -999.0999e50;
+
+        // act
+        assertMaxPrecision(d, 8, true, "9990999", 46);
+        assertMaxPrecision(d, 7, true, "9990999", 46);
+        assertMaxPrecision(d, 6, true, "9991", 49);
+        assertMaxPrecision(d, 5, true, "9991", 49);
+        assertMaxPrecision(d, 4, true, "9991", 49);
+        assertMaxPrecision(d, 3, true, "999", 50);
+        assertMaxPrecision(d, 2, true, "1", 53);
+        assertMaxPrecision(d, 1, true, "1", 53);
+
+        assertMaxPrecision(d, 0, true, "9990999", 46);
+    }
+
+    @Test
+    void testMaxPrecision_halfEvenRounding() {
+        // act/assert
+        // Test values taken from RoundingMode.HALF_EVEN javadocs
+        assertMaxPrecision(5.5, 1, false, "6", 0);
+        assertMaxPrecision(2.5, 1, false, "2", 0);
+        assertMaxPrecision(1.6, 1, false, "2", 0);
+        assertMaxPrecision(1.1, 1, false, "1", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-1.1, 1, true, "1", 0);
+        assertMaxPrecision(-1.6, 1, true, "2", 0);
+        assertMaxPrecision(-2.5, 1, true, "2", 0);
+        assertMaxPrecision(-5.5, 1, true, "6", 0);
+    }
+
+    @Test
+    void testMaxPrecision_singleDigits() {
+        // act
+        assertMaxPrecision(9.0, 1, false, "9", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+        assertMaxPrecision(0.0, 1, false, "0", 0);
+        assertMaxPrecision(-0.0, 1, true, "0", 0);
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-9.0, 1, true, "9", 0);
+    }
+
+    @Test
+    void testMaxPrecision_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+        final ParsedDecimal.FormatOptions opts = new FormatOptionsImpl();
+
+        for (int i = 0; i < 10_000; ++i) {
+            final double d = createRandomDouble(rand);
+            final int precision = rand.nextInt(20) + 1;
+            final MathContext ctx = new MathContext(precision, RoundingMode.HALF_EVEN);
+
+            final ParsedDecimal dec = ParsedDecimal.from(d);
+
+            // act
+            dec.maxPrecision(precision);
+
+            // assert
+            Assertions.assertEquals(new BigDecimal(Double.toString(d), ctx).doubleValue(),
+                    Double.parseDouble(scientificString(dec, opts)));
+        }
+    }
+
+    @Test
+    void testToPlainString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToPlainString(0.0, "0.0", opts);
+        checkToPlainString(-0.0, "-0.0", opts);
+        checkToPlainString(1.0, "1.0", opts);
+        checkToPlainString(1.5, "1.5", opts);
+
+        checkToPlainString(12, "12.0", opts);
+        checkToPlainString(123, "123.0", opts);
+        checkToPlainString(1234, "1234.0", opts);
+        checkToPlainString(12345, "12345.0", opts);
+        checkToPlainString(123456, "123456.0", opts);
+        checkToPlainString(1234567, "1234567.0", opts);
+        checkToPlainString(12345678, "12345678.0", opts);
+        checkToPlainString(123456789, "123456789.0", opts);
+        checkToPlainString(1234567890, "1234567890.0", opts);
+
+        checkToPlainString(-0.000123, "-0.000123", opts);
+        checkToPlainString(12301, "12301.0", opts);
+
+        checkToPlainString(Math.PI, "3.141592653589793", opts);
+        checkToPlainString(Math.E, "2.718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "-12345.6789", opts);
+        checkToPlainString(1.23e12, "1230000000000.0", opts);
+        checkToPlainString(1.23e-12, "0.00000000000123", opts);
+    }
+
+    @Test
+    void testToPlainString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setThousandsGroupingSeparator('_');
+        opts.setGroupThousands(true);
+
+        // act/assert
+        checkToPlainString(0.0, "0", opts);
+        checkToPlainString(-0.0, "0", opts);
+        checkToPlainString(1.0, "1", opts);
+        checkToPlainString(1.5, "1,5", opts);
+
+        checkToPlainString(12, "12", opts);
+        checkToPlainString(123, "123", opts);
+        checkToPlainString(1234, "1_234", opts);
+        checkToPlainString(12345, "12_345", opts);
+        checkToPlainString(123456, "123_456", opts);
+        checkToPlainString(1234567, "1_234_567", opts);
+        checkToPlainString(12345678, "12_345_678", opts);
+        checkToPlainString(123456789, "123_456_789", opts);
+        checkToPlainString(1234567890, "1_234_567_890", opts);
+
+        checkToPlainString(-0.000123, "!0,000123", opts);
+        checkToPlainString(12301, "12_301", opts);
+
+        checkToPlainString(Math.PI, "3,141592653589793", opts);
+        checkToPlainString(Math.E, "2,718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "!12_345,6789", opts);
+        checkToPlainString(1.23e12, "1_230_000_000_000", opts);
+        checkToPlainString(1.23e-12, "0,00000000000123", opts);
+    }
+
+    @Test
+    void testToScientificString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToScientificString(0.0, "0.0", opts);
+        checkToScientificString(-0.0, "-0.0", opts);
+        checkToScientificString(1.0, "1.0", opts);
+        checkToScientificString(1.5, "1.5", opts);
+
+        checkToScientificString(-0.000123, "-1.23E-4", opts);
+        checkToScientificString(12301, "1.2301E4", opts);
+
+        checkToScientificString(Math.PI, "3.141592653589793", opts);
+        checkToScientificString(Math.E, "2.718281828459045", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "-1.7976931348623157E308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2.2250738585072014E-308", opts);
+    }
+
+    @Test
+    void testToScientificString_altFormats() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToScientificString(0.0, "0x10^0", opts);
+        checkToScientificString(-0.0, "0x10^0", opts);
+        checkToScientificString(1.0, "1x10^0", opts);
+        checkToScientificString(1.5, "1,5x10^0", opts);
+
+        checkToScientificString(-0.000123, "!1,23x10^!4", opts);
+        checkToScientificString(12301, "1,2301x10^4", opts);
+
+        checkToScientificString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToScientificString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "!1,7976931348623157x10^308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2,2250738585072014x10^!308", opts);
+    }
+
+    @Test
+    void testToEngineeringString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToEngineeringString(0.0, "0.0", opts);
+        checkToEngineeringString(-0.0, "-0.0", opts);
+        checkToEngineeringString(1.0, "1.0", opts);
+        checkToEngineeringString(1.5, "1.5", opts);
+
+        checkToEngineeringString(10, "10.0", opts);
+
+        checkToEngineeringString(-0.000000123, "-123.0E-9", opts);
+        checkToEngineeringString(12300000, "12.3E6", opts);
+
+        checkToEngineeringString(Math.PI, "3.141592653589793", opts);
+        checkToEngineeringString(Math.E, "2.718281828459045", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "-179.76931348623157E306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22.250738585072014E-309", opts);
+    }
+
+    @Test
+    void testToEngineeringString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToEngineeringString(0.0, "0x10^0", opts);
+        checkToEngineeringString(-0.0, "0x10^0", opts);
+        checkToEngineeringString(1.0, "1x10^0", opts);
+        checkToEngineeringString(1.5, "1,5x10^0", opts);
+
+        checkToEngineeringString(10, "10x10^0", opts);
+
+        checkToEngineeringString(-0.000000123, "!123x10^!9", opts);
+        checkToEngineeringString(12300000, "12,3x10^6", opts);
+
+        checkToEngineeringString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToEngineeringString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "!179,76931348623157x10^306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22,250738585072014x10^!309", opts);
+    }
+
+    @Test
+    void testStringMethods_customDigits() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setDigitsFromString("abcdefghij");
+
+        // act/assert
+        Assertions.assertEquals("b.a", plainString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-a.abcd", plainString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", plainString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("baaaa.a", plainString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("jihgfedcba.a", plainString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", scientificString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-b.cdE-c", scientificString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("b.cdeEb", scientificString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("b.aEe", scientificString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", scientificString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", engineeringString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-bc.dE-d", engineeringString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", engineeringString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("ba.aEd", engineeringString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", engineeringString(ParsedDecimal.from(9876543210d), opts));
+    }
+
+    @Test
+    void testStringMethodAccuracy_sequence() {
+        // arrange
+        final double min = -1000;
+        final double max = 1000;
+        final double delta = 0.1;
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        Assertions.assertEquals(10.0, Double.parseDouble(scientificString(ParsedDecimal.from(10.0), stdOpts)));
+
+        for (double d = min; d <= max; d += delta) {
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    @Test
+    void testStringMethodAccuracy_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        double d;
+        for (int i = 0; i < 10_000; ++i) {
+            d = createRandomDouble(rand);
+
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    private static void checkFrom(final double d, final String digits, final int exponent) {
+        final boolean negative = Math.signum(d) < 0;
+
+        assertSimpleDecimal(ParsedDecimal.from(d), negative, digits, exponent);
+        assertSimpleDecimal(ParsedDecimal.from(-d), !negative, digits, exponent);
+    }
+
+    private static void checkToPlainString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::plainString, opts);
+    }
+
+    private static void checkToScientificString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::scientificString, opts);
+    }
+
+    private static void checkToEngineeringString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::engineeringString, opts);
+
+        // check the exponent value to make sure it is a multiple of 3
+        final String pos = engineeringString(ParsedDecimal.from(d), opts);
+        Assertions.assertEquals(0, parseExponent(pos, opts) % 3);
+
+        final String neg = engineeringString(ParsedDecimal.from(-d), opts);
+        Assertions.assertEquals(0, parseExponent(neg, opts) % 3);
+    }
+
+    private static int parseExponent(final String str, final ParsedDecimal.FormatOptions opts) {
+        final char[] expSep = opts.getExponentSeparatorChars();
+
+        final int expStartIdx = str.indexOf(String.valueOf(expSep));
+        if (expStartIdx > -1) {
+            int expIdx = expStartIdx + expSep.length;
+
+            boolean neg = false;
+            if (str.charAt(expIdx) == opts.getMinusSign()) {
+                ++expIdx;
+            }
+
+            final String expStr = str.substring(expIdx);
+            final int val = Integer.parseInt(expStr);
+            return neg

Review comment:
       Indeed. Thanks.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] coveralls commented on pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
coveralls commented on pull request #248:
URL: https://github.com/apache/commons-text/pull/248#issuecomment-875718096


   
   [![Coverage Status](https://coveralls.io/builds/41185905/badge)](https://coveralls.io/builds/41185905)
   
   Coverage increased (+0.1%) to 98.078% when pulling **a2789d6d526dc15ad79de85ecc18f048d9129fc5 on darkma773r:text-207-double-format** into **0ec85b01b043e0d838e706d14b0cd1d21dc48d54 on apache:master**.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r671690217



##########
File path: src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.java
##########
@@ -0,0 +1,764 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+import java.util.function.BiFunction;
+
+import org.apache.commons.rng.UniformRandomProvider;
+import org.apache.commons.rng.simple.RandomSource;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+class ParsedDecimalTest {
+
+    @Test
+    void testFrom() {
+        // act/assert
+        checkFrom(0.0, "0", 0);
+
+        checkFrom(1.0, "1", 0);
+        checkFrom(10.0, "1", 1);
+        checkFrom(100.0, "1", 2);
+        checkFrom(1000.0, "1", 3);
+        checkFrom(10000.0, "1", 4);
+
+        checkFrom(0.1, "1", -1);
+        checkFrom(0.01, "1", -2);
+        checkFrom(0.001, "1", -3);
+        checkFrom(0.0001, "1", -4);
+        checkFrom(0.00001, "1", -5);
+
+        checkFrom(1.2, "12", -1);
+        checkFrom(0.00971, "971", -5);
+        checkFrom(56300, "563", 2);
+
+        checkFrom(123.0, "123", 0);
+        checkFrom(1230.0, "123", 1);
+        checkFrom(12300.0, "123", 2);
+        checkFrom(123000.0, "123", 3);
+
+        checkFrom(12.3, "123", -1);
+        checkFrom(1.23, "123", -2);
+        checkFrom(0.123, "123", -3);
+        checkFrom(0.0123, "123", -4);
+
+        checkFrom(1.987654321e270, "1987654321", 261);
+        checkFrom(1.987654321e-270, "1987654321", -279);
+
+        checkFrom(Math.PI, "3141592653589793", -15);
+        checkFrom(Math.E, "2718281828459045", -15);
+
+        checkFrom(Double.MAX_VALUE, "17976931348623157", 292);
+        checkFrom(Double.MIN_VALUE, "49", -325);
+        checkFrom(Double.MIN_NORMAL, "22250738585072014", -324);
+    }
+
+    @Test
+    void testFrom_notFinite() {
+        // arrange
+        final String msg = "Double is not finite";
+
+        // act/assert
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NaN),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NEGATIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.POSITIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+    }
+
+    @Test
+    void testIsZero() {
+        // act/assert
+        Assertions.assertTrue(ParsedDecimal.from(0.0).isZero());
+        Assertions.assertTrue(ParsedDecimal.from(-0.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(1.0).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-1.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MIN_NORMAL).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_NORMAL).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MAX_VALUE).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_VALUE).isZero());
+    }
+
+    @Test
+    void testRound_one() {
+        // arrange
+        final double a = 1e-10;
+        final double b = -1;
+        final double c = 1e10;
+
+        // act/assert
+        assertRound(a, -11, false, "1", -10);
+        assertRound(a, -10, false, "1", -10);
+        assertRound(a, -9, false, "0", 0);
+
+        assertRound(b, -1, true, "1", 0);
+        assertRound(b, 0, true, "1", 0);
+        assertRound(b, 1, true, "0", 0);
+
+        assertRound(c, 9, false, "1", 10);
+        assertRound(c, 10, false, "1", 10);
+        assertRound(c, 11, false, "0", 0);
+    }
+
+    @Test
+    void testRound_nine() {
+        // arrange
+        final double a = 9e-10;
+        final double b = -9;
+        final double c = 9e10;
+
+        // act/assert
+        assertRound(a, -11, false, "9", -10);
+        assertRound(a, -10, false, "9", -10);
+        assertRound(a, -9, false, "1", -9);
+
+        assertRound(b, -1, true, "9", 0);
+        assertRound(b, 0, true, "9", 0);
+        assertRound(b, 1, true, "1", 1);
+
+        assertRound(c, 9, false, "9", 10);
+        assertRound(c, 10, false, "9", 10);
+        assertRound(c, 11, false, "1", 11);
+    }
+
+    @Test
+    void testRound_mixed() {
+        // arrange
+        final double a = 9.94e-10;
+        final double b = -3.1415;
+        final double c = 5.55e10;
+
+        // act/assert
+        assertRound(a, -13, false, "994", -12);
+        assertRound(a, -12, false, "994", -12);
+        assertRound(a, -11, false, "99", -11);
+        assertRound(a, -10, false, "1", -9);
+        assertRound(a, -9, false, "1", -9);
+        assertRound(a, -8, false, "0", 0);
+
+        assertRound(b, -5, true, "31415", -4);
+        assertRound(b, -4, true, "31415", -4);
+        assertRound(b, -3, true, "3142", -3);
+        assertRound(b, -2, true, "314", -2);
+        assertRound(b, -1, true, "31", -1);
+        assertRound(b, 0, true, "3", 0);
+        assertRound(b, 1, true, "0", 0);
+        assertRound(b, 2, true, "0", 0);
+
+        assertRound(c, 7, false, "555", 8);
+        assertRound(c, 8, false, "555", 8);
+        assertRound(c, 9, false, "56", 9);
+        assertRound(c, 10, false, "6", 10);
+        assertRound(c, 11, false, "1", 11);
+        assertRound(c, 12, false, "0", 0);
+    }
+
+    @Test
+    void testMaxPrecision() {
+        // arrange
+        final double d = 1.02576552;
+
+        // act
+        assertMaxPrecision(d, 10, false, "102576552", -8);
+        assertMaxPrecision(d, 9, false, "102576552", -8);
+        assertMaxPrecision(d, 8, false, "10257655", -7);
+        assertMaxPrecision(d, 7, false, "1025766", -6);
+        assertMaxPrecision(d, 6, false, "102577", -5);
+        assertMaxPrecision(d, 5, false, "10258", -4);
+        assertMaxPrecision(d, 4, false, "1026", -3);
+        assertMaxPrecision(d, 3, false, "103", -2);
+        assertMaxPrecision(d, 2, false, "1", 0);
+        assertMaxPrecision(d, 1, false, "1", 0);
+
+        assertMaxPrecision(d, 0, false, "102576552", -8);
+    }
+
+    @Test
+    void testMaxPrecision_carry() {
+        // arrange
+        final double d = -999.0999e50;
+
+        // act
+        assertMaxPrecision(d, 8, true, "9990999", 46);
+        assertMaxPrecision(d, 7, true, "9990999", 46);
+        assertMaxPrecision(d, 6, true, "9991", 49);
+        assertMaxPrecision(d, 5, true, "9991", 49);
+        assertMaxPrecision(d, 4, true, "9991", 49);
+        assertMaxPrecision(d, 3, true, "999", 50);
+        assertMaxPrecision(d, 2, true, "1", 53);
+        assertMaxPrecision(d, 1, true, "1", 53);
+
+        assertMaxPrecision(d, 0, true, "9990999", 46);
+    }
+
+    @Test
+    void testMaxPrecision_halfEvenRounding() {
+        // act/assert
+        // Test values taken from RoundingMode.HALF_EVEN javadocs
+        assertMaxPrecision(5.5, 1, false, "6", 0);
+        assertMaxPrecision(2.5, 1, false, "2", 0);
+        assertMaxPrecision(1.6, 1, false, "2", 0);
+        assertMaxPrecision(1.1, 1, false, "1", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-1.1, 1, true, "1", 0);
+        assertMaxPrecision(-1.6, 1, true, "2", 0);
+        assertMaxPrecision(-2.5, 1, true, "2", 0);
+        assertMaxPrecision(-5.5, 1, true, "6", 0);
+    }
+
+    @Test
+    void testMaxPrecision_singleDigits() {
+        // act
+        assertMaxPrecision(9.0, 1, false, "9", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+        assertMaxPrecision(0.0, 1, false, "0", 0);
+        assertMaxPrecision(-0.0, 1, true, "0", 0);
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-9.0, 1, true, "9", 0);
+    }
+
+    @Test
+    void testMaxPrecision_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+        final ParsedDecimal.FormatOptions opts = new FormatOptionsImpl();
+
+        for (int i = 0; i < 10_000; ++i) {
+            final double d = createRandomDouble(rand);
+            final int precision = rand.nextInt(20) + 1;
+            final MathContext ctx = new MathContext(precision, RoundingMode.HALF_EVEN);
+
+            final ParsedDecimal dec = ParsedDecimal.from(d);
+
+            // act
+            dec.maxPrecision(precision);
+
+            // assert
+            Assertions.assertEquals(new BigDecimal(Double.toString(d), ctx).doubleValue(),
+                    Double.parseDouble(scientificString(dec, opts)));
+        }
+    }
+
+    @Test
+    void testToPlainString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToPlainString(0.0, "0.0", opts);
+        checkToPlainString(-0.0, "-0.0", opts);
+        checkToPlainString(1.0, "1.0", opts);
+        checkToPlainString(1.5, "1.5", opts);
+
+        checkToPlainString(12, "12.0", opts);
+        checkToPlainString(123, "123.0", opts);
+        checkToPlainString(1234, "1234.0", opts);
+        checkToPlainString(12345, "12345.0", opts);
+        checkToPlainString(123456, "123456.0", opts);
+        checkToPlainString(1234567, "1234567.0", opts);
+        checkToPlainString(12345678, "12345678.0", opts);
+        checkToPlainString(123456789, "123456789.0", opts);
+        checkToPlainString(1234567890, "1234567890.0", opts);
+
+        checkToPlainString(-0.000123, "-0.000123", opts);
+        checkToPlainString(12301, "12301.0", opts);
+
+        checkToPlainString(Math.PI, "3.141592653589793", opts);
+        checkToPlainString(Math.E, "2.718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "-12345.6789", opts);
+        checkToPlainString(1.23e12, "1230000000000.0", opts);
+        checkToPlainString(1.23e-12, "0.00000000000123", opts);
+    }
+
+    @Test
+    void testToPlainString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setThousandsGroupingSeparator('_');
+        opts.setGroupThousands(true);
+
+        // act/assert
+        checkToPlainString(0.0, "0", opts);
+        checkToPlainString(-0.0, "0", opts);
+        checkToPlainString(1.0, "1", opts);
+        checkToPlainString(1.5, "1,5", opts);
+
+        checkToPlainString(12, "12", opts);
+        checkToPlainString(123, "123", opts);
+        checkToPlainString(1234, "1_234", opts);
+        checkToPlainString(12345, "12_345", opts);
+        checkToPlainString(123456, "123_456", opts);
+        checkToPlainString(1234567, "1_234_567", opts);
+        checkToPlainString(12345678, "12_345_678", opts);
+        checkToPlainString(123456789, "123_456_789", opts);
+        checkToPlainString(1234567890, "1_234_567_890", opts);
+
+        checkToPlainString(-0.000123, "!0,000123", opts);
+        checkToPlainString(12301, "12_301", opts);
+
+        checkToPlainString(Math.PI, "3,141592653589793", opts);
+        checkToPlainString(Math.E, "2,718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "!12_345,6789", opts);
+        checkToPlainString(1.23e12, "1_230_000_000_000", opts);
+        checkToPlainString(1.23e-12, "0,00000000000123", opts);
+    }
+
+    @Test
+    void testToScientificString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToScientificString(0.0, "0.0", opts);
+        checkToScientificString(-0.0, "-0.0", opts);
+        checkToScientificString(1.0, "1.0", opts);
+        checkToScientificString(1.5, "1.5", opts);
+
+        checkToScientificString(-0.000123, "-1.23E-4", opts);
+        checkToScientificString(12301, "1.2301E4", opts);
+
+        checkToScientificString(Math.PI, "3.141592653589793", opts);
+        checkToScientificString(Math.E, "2.718281828459045", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "-1.7976931348623157E308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2.2250738585072014E-308", opts);
+    }
+
+    @Test
+    void testToScientificString_altFormats() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToScientificString(0.0, "0x10^0", opts);
+        checkToScientificString(-0.0, "0x10^0", opts);
+        checkToScientificString(1.0, "1x10^0", opts);
+        checkToScientificString(1.5, "1,5x10^0", opts);
+
+        checkToScientificString(-0.000123, "!1,23x10^!4", opts);
+        checkToScientificString(12301, "1,2301x10^4", opts);
+
+        checkToScientificString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToScientificString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "!1,7976931348623157x10^308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2,2250738585072014x10^!308", opts);
+    }
+
+    @Test
+    void testToEngineeringString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToEngineeringString(0.0, "0.0", opts);
+        checkToEngineeringString(-0.0, "-0.0", opts);
+        checkToEngineeringString(1.0, "1.0", opts);
+        checkToEngineeringString(1.5, "1.5", opts);
+
+        checkToEngineeringString(10, "10.0", opts);
+
+        checkToEngineeringString(-0.000000123, "-123.0E-9", opts);
+        checkToEngineeringString(12300000, "12.3E6", opts);
+
+        checkToEngineeringString(Math.PI, "3.141592653589793", opts);
+        checkToEngineeringString(Math.E, "2.718281828459045", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "-179.76931348623157E306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22.250738585072014E-309", opts);
+    }
+
+    @Test
+    void testToEngineeringString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToEngineeringString(0.0, "0x10^0", opts);
+        checkToEngineeringString(-0.0, "0x10^0", opts);
+        checkToEngineeringString(1.0, "1x10^0", opts);
+        checkToEngineeringString(1.5, "1,5x10^0", opts);
+
+        checkToEngineeringString(10, "10x10^0", opts);
+
+        checkToEngineeringString(-0.000000123, "!123x10^!9", opts);
+        checkToEngineeringString(12300000, "12,3x10^6", opts);
+
+        checkToEngineeringString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToEngineeringString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "!179,76931348623157x10^306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22,250738585072014x10^!309", opts);
+    }
+
+    @Test
+    void testStringMethods_customDigits() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setDigitsFromString("abcdefghij");
+
+        // act/assert
+        Assertions.assertEquals("b.a", plainString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-a.abcd", plainString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", plainString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("baaaa.a", plainString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("jihgfedcba.a", plainString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", scientificString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-b.cdE-c", scientificString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("b.cdeEb", scientificString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("b.aEe", scientificString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", scientificString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", engineeringString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-bc.dE-d", engineeringString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", engineeringString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("ba.aEd", engineeringString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", engineeringString(ParsedDecimal.from(9876543210d), opts));
+    }
+
+    @Test
+    void testStringMethodAccuracy_sequence() {
+        // arrange
+        final double min = -1000;
+        final double max = 1000;
+        final double delta = 0.1;
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        Assertions.assertEquals(10.0, Double.parseDouble(scientificString(ParsedDecimal.from(10.0), stdOpts)));
+
+        for (double d = min; d <= max; d += delta) {
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    @Test
+    void testStringMethodAccuracy_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        double d;
+        for (int i = 0; i < 10_000; ++i) {
+            d = createRandomDouble(rand);
+
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    private static void checkFrom(final double d, final String digits, final int exponent) {
+        final boolean negative = Math.signum(d) < 0;
+
+        assertSimpleDecimal(ParsedDecimal.from(d), negative, digits, exponent);
+        assertSimpleDecimal(ParsedDecimal.from(-d), !negative, digits, exponent);
+    }
+
+    private static void checkToPlainString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::plainString, opts);
+    }
+
+    private static void checkToScientificString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::scientificString, opts);
+    }
+
+    private static void checkToEngineeringString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::engineeringString, opts);
+
+        // check the exponent value to make sure it is a multiple of 3
+        final String pos = engineeringString(ParsedDecimal.from(d), opts);
+        Assertions.assertEquals(0, parseExponent(pos, opts) % 3);
+
+        final String neg = engineeringString(ParsedDecimal.from(-d), opts);
+        Assertions.assertEquals(0, parseExponent(neg, opts) % 3);
+    }
+
+    private static int parseExponent(final String str, final ParsedDecimal.FormatOptions opts) {
+        final char[] expSep = opts.getExponentSeparatorChars();
+
+        final int expStartIdx = str.indexOf(String.valueOf(expSep));
+        if (expStartIdx > -1) {
+            int expIdx = expStartIdx + expSep.length;
+
+            boolean neg = false;
+            if (str.charAt(expIdx) == opts.getMinusSign()) {
+                ++expIdx;
+            }
+
+            final String expStr = str.substring(expIdx);
+            final int val = Integer.parseInt(expStr);
+            return neg
+                    ? -val
+                    : val;
+        }
+
+        return 0;
+    }
+
+    private static void checkToStringMethod(final double d, final String expected,
+            final BiFunction<ParsedDecimal, ParsedDecimal.FormatOptions, String> fn,
+            final ParsedDecimal.FormatOptions opts) {
+
+        final ParsedDecimal pos = ParsedDecimal.from(d);
+        final String actual = fn.apply(pos, opts);
+
+        Assertions.assertEquals(expected, actual);
+    }
+
+    private static void assertRound(final double d, final int roundExponent,
+            final boolean negative, final String digits, final int exponent) {
+        final ParsedDecimal dec = ParsedDecimal.from(d);
+        dec.round(roundExponent);
+
+        assertSimpleDecimal(dec, negative, digits, exponent);
+    }
+
+    private static void assertMaxPrecision(final double d, final int maxPrecision,
+            final boolean negative, final String digits, final int exponent) {
+        final ParsedDecimal dec = ParsedDecimal.from(d);
+        dec.maxPrecision(maxPrecision);
+
+        assertSimpleDecimal(dec, negative, digits, exponent);
+    }
+
+    private static void assertSimpleDecimal(final ParsedDecimal parsed, final boolean negative, final String digits,
+            final int exponent) {
+        Assertions.assertEquals(negative, parsed.negative);
+        Assertions.assertEquals(digits, digitString(parsed));
+        Assertions.assertEquals(exponent, parsed.getExponent());
+        Assertions.assertEquals(digits.length(), parsed.digitCount);
+        Assertions.assertEquals(exponent, parsed.getScientificExponent() - digits.length() + 1);
+    }
+
+    private static void assertThrowsWithMessage(final Executable fn, final Class<? extends Throwable> type,
+            final String msg) {
+        Throwable exc = Assertions.assertThrows(type, fn);
+        Assertions.assertEquals(msg, exc.getMessage());
+    }
+
+    private static double createRandomDouble(final UniformRandomProvider rng) {
+        final long mask = ((1L << 52) - 1) | 1L << 63;
+        final long bits = rng.nextLong() & mask;
+        final long exp = rng.nextInt(2045) + 1;
+        return Double.longBitsToDouble(bits | (exp << 52));
+    }
+
+    /** Get the raw digits in the given decimal as a string.
+     * @param dec decimal instancE
+     * @return decimal digits as a string
+     */
+    private static String digitString(final ParsedDecimal dec) {
+        final StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < dec.digitCount; ++i) {
+            sb.append(dec.digits[i]);
+        }
+        return sb.toString();
+    }
+
+    private static String plainString(final ParsedDecimal dec, final ParsedDecimal.FormatOptions opts) {
+//        try {
+//            StringBuilder sb = new StringBuilder();
+//            dec.toPlainString(sb, opts);
+//            return sb.toString();
+//        } catch (IOException exc) {
+//            throw new UncheckedIOException(exc);
+//        }
+        return dec.toPlainString(opts);
+    }
+
+    private static String scientificString(final ParsedDecimal dec, final ParsedDecimal.FormatOptions opts) {
+//        try {
+//            StringBuilder sb = new StringBuilder();
+//            dec.toScientificString(sb, opts);
+//            return sb.toString();
+//        } catch (IOException exc) {
+//            throw new UncheckedIOException(exc);
+//        }
+        return dec.toScientificString(opts);
+    }
+
+    private static String engineeringString(final ParsedDecimal dec, final ParsedDecimal.FormatOptions opts) {
+//        try {
+//            StringBuilder sb = new StringBuilder();
+//            dec.toEngineeringString(sb, opts);
+//            return sb.toString();
+//        } catch (IOException exc) {
+//            throw new UncheckedIOException(exc);
+//        }
+        return dec.toEngineeringString(opts);
+    }
+
+    private static final class FormatOptionsImpl implements ParsedDecimal.FormatOptions {
+
+        private boolean includeFractionPlaceholder = true;
+
+        private boolean signedZero = true;
+
+        private char[] digits = "0123456789".toCharArray();
+
+        private char decimalSeparator = '.';
+
+        private char thousandsGroupingSeparator = ',';
+
+        private boolean groupThousands = false;
+
+        private char minusSign = '-';

Review comment:
       I added this since it is part of `DecimalFormatSymbols`. I believe there are locales/languages that use a different symbol for minus but I can't think of any examples right now.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r671692434



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,725 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;

Review comment:
       I think I know the error you're talking about; something related to exposing implementation internals? I just made this constructor private (which it should have been in the first place) so hopefully that will address the situation.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on pull request #248:
URL: https://github.com/apache/commons-text/pull/248#issuecomment-881892620


   @kinow, I think I've addressed all your comments so far. Let me know if you see any other tweaks that need to be made. Thanks!


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] aherbert commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
aherbert commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r664774105



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,715 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int resultExponent = digitCount + exponent - decimalPos;
+
+        // only include the exponent string if it is non-zero or it is requested to be present
+        final char[] resultExponentChars = resultExponent != 0 || opts.getAlwaysIncludeExponent()
+                ? Integer.toString(Math.abs(resultExponent)).toCharArray()
+                : null;
+
+        prepareOutput(getScientificStringSize(decimalPos, resultExponent, resultExponentChars, opts));
+
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (resultExponentChars != null) {
+            append(opts.getExponentSeparatorChars());
+
+            if (resultExponent < 0) {
+                append(opts.getMinusSign());
+            }
+
+            final char[] localizedDigits = opts.getDigits();
+            for (int i = 0; i < resultExponentChars.length; ++i) {
+                final int val = digitValue(resultExponentChars[i]);
+                appendLocalizedDigit(val, localizedDigits);
+            }
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return true if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index
+     * @param opts format options
+     */
+    private void appendFraction(final int zeroCount, final int startIdx, final FormatOptions opts) {
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        if (startIdx < digitCount) {
+            append(opts.getDecimalSeparator());
+
+            // add the zero prefix
+            for (int i = 0; i < zeroCount; ++i) {
+                append(localizedZero);
+            }
+
+            // add the fraction digits
+            for (int i = startIdx; i < digitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+        } else if (opts.getIncludeFractionPlaceholder()) {
+            append(opts.getDecimalSeparator());
+            append(localizedZero);
+        }
+    }
+
+    /** Get the number of characters required to create a plain format representation
+     * of this value.
+     * @param decimalPos decimal position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters in the plain string representation of this value,
+     *      created using the given parameters
+     */
+    private int getPlainStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+
+        // adjust for groupings if needed
+        if (opts.getGroupThousands() && decimalPos > 0) {
+            size += (decimalPos - 1) / THOUSANDS_GROUP_SIZE;
+        }
+
+        return size;
+    }
+
+    /** Get the number of characters required to create a scientific format representation
+     * of this value.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param resultExponent target exponent
+     * @param resultExponentChars target exponent characters; if null, no explicit exponent is to be included
+     * @param opts format options
+     * @return number of characters in the scientific representation of this string, using the
+     *      given parameters
+     */
+    private int getScientificStringSize(final int decimalPos, final int resultExponent,
+            final char[] resultExponentChars, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+        if (resultExponentChars != null) {
+            size += opts.getExponentSeparatorChars().length + resultExponentChars.length;
+            if (resultExponent < 0) {
+                // adjust for the exponent minus sign
+                ++size;
+            }
+        }
+        return size;
+    }
+
+    /** Get the number of characters required for the digit portion of a string representation of
+     * this value. This excludes any exponent or thousands groupings characters.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters required for the digit portion of a string representation of
+     *      this value
+     */
+    private int getDigitStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = digitCount;
+        if (shouldIncludeMinus(opts)) {
+            ++size;
+        }
+        if (decimalPos < 1) {
+            // no whole component;
+            // add decimal point and leading zeros
+            size += 2 + Math.abs(decimalPos);
+        } else if (decimalPos >= digitCount) {
+            // no fraction component;
+            // add trailing zeros
+            size += decimalPos - digitCount;
+            if (opts.getIncludeFractionPlaceholder()) {
+                size += 2;
+            }
+        } else {
+            // whole and fraction components;
+            // add decimal point
+            size += 1;
+        }
+
+        return size;
+    }
+
+
+    /** Return true if formatted strings should include the minus sign, considering
+     * the value of this instance and the given format options.
+     * @param opts format options
+     * @return true if a minus sign should be included in the output
+     */
+    private boolean shouldIncludeMinus(final FormatOptions opts) {
+        return negative && (opts.getSignedZero() || !isZero());
+    }
+
+    /** Return true if a rounding operation for the given number of digits should
+     * round up.
+     * @param count number of digits to round to; must be greater than zero and less
+     *      than the current number of digits
+     * @return true if a rounding operation for the given number of digits should
+     *      round up
+     */
+    private boolean shouldRoundUp(final int count) {
+        // Round up in the following cases:
+        // 1. The digit after the last digit is greater than 5.
+        // 2. The digit after the last digit is 5 and there are additional (non-zero)
+        //      digits after it.
+        // 3. The digit after the last digit is 5, there are no additional digits afterward,
+        //      and the last digit is odd (half-even rounding).
+        final int digitAfterLast = digits[count];
+
+        return digitAfterLast > ROUND_CENTER || (digitAfterLast == ROUND_CENTER
+                && (count < digitCount - 1 || (digits[count - 1] % 2) != 0));
+    }
+
+    /** Round the value up to the given number of digits.
+     * @param count target number of digits; must be greater than zero and
+     *      less than the current number of digits
+     */
+    private void roundUp(final int count) {
+        int removedDigits = digitCount - count;
+        int i;
+        for (i = count - 1; i >= 0; --i) {
+            final int d = digits[i] + 1;
+
+            if (d < DECIMAL_RADIX) {
+                // value did not carry over; done adding
+                digits[i] = d;
+                break;
+            } else {
+                // value carried over; the current position is 0
+                // which we will ignore by shortening the digit count
+                ++removedDigits;
+            }
+        }
+
+        if (i < 0) {
+            // all values carried over
+            setSingleDigitValue(1, exponent + removedDigits);
+        } else {
+            // values were updated in-place; just need to update the length
+            truncate(digitCount - removedDigits);
+        }
+    }
+
+    /** Return true if this value is equal to zero. The sign field is ignored,
+     * meaning that this method will return true for both {@code +0} and {@code -0}.
+     * @return true if the value is equal to zero
+     */
+    boolean isZero() {
+        return digits[0] == 0;
+    }
+
+    /** Set the value of this instance to a single digit with the given exponent.
+     * The sign of the value is retained.
+     * @param digit digit value
+     * @param newExponent new exponent value
+     */
+    private void setSingleDigitValue(final int digit, final int newExponent) {
+        digits[0] = digit;
+        digitCount = 1;
+        exponent = newExponent;
+    }
+
+    /** Truncate the value to the given number of digits.
+     * @param count number of digits; must be greater than zero and less than
+     *      the current number of digits
+     */
+    private void truncate(final int count) {
+        int nonZeroCount = count;
+        for (int i = count - 1;
+                i >= 0 && digits[i] == 0;
+                --i) {
+            --nonZeroCount;
+        }
+        exponent += digitCount - nonZeroCount;
+        digitCount = nonZeroCount;
+    }
+
+    /** Construct a new instance from the given double value.
+     * @param d double value
+     * @return a new instance containing the parsed components of the given double value
+     * @throws IllegalArgumentException if {@code d} is {@code NaN} or infinite
+     */
+    public static ParsedDecimal from(final double d) {
+        if (!Double.isFinite(d)) {
+            throw new IllegalArgumentException("Double is not finite");
+        }
+
+        // Get the canonical string representation of the double value and parse
+        // it to extract the components of the decimal value. From the documentation
+        // of Double.toString() and the fact that d is finite, we are guaranteed the
+        // following:
+        // - the string will not be empty
+        // - it will contain exactly one decimal point character
+        // - all digit characters are in the ASCII range
+        final char[] strChars = Double.toString(d).toCharArray();
+
+        final boolean negative = strChars[0] == MINUS_CHAR;
+        final int digitStartIdx = negative ? 1 : 0;
+
+        final int[] digits = new int[strChars.length];
+
+        boolean foundDecimalPoint = false;
+        int digitCount = 0;
+        int significantDigitCount = 0;
+        int decimalPos = 0;
+
+        int i;
+        for (i = digitStartIdx; i < strChars.length; ++i) {
+            final char ch = strChars[i];
+
+            if (ch == DECIMAL_SEP_CHAR) {
+                foundDecimalPoint = true;
+                decimalPos = digitCount;
+            } else if (ch == EXPONENT_CHAR) {
+                // no more mantissa digits
+                break;
+            } else if (ch != ZERO_CHAR || digitCount > 0) {
+                // this is either the first non-zero digit or one after it
+                final int val = digitValue(ch);
+                digits[digitCount++] = val;
+
+                if (val > 0) {
+                    significantDigitCount = digitCount;
+                }
+            } else if (foundDecimalPoint) {
+                // leading zero in a fraction; adjust the decimal position
+                --decimalPos;
+            }
+        }
+
+        if (digitCount > 0) {
+            // determine the exponent
+            final int explicitExponent = i < strChars.length
+                    ? parseExponent(strChars, i + 1)
+                    : 0;
+            final int exponent = explicitExponent + decimalPos - significantDigitCount;
+
+            return new ParsedDecimal(negative, digits, significantDigitCount, exponent);
+        }
+
+        // no non-zero digits, so value is zero
+        return new ParsedDecimal(negative, new int[] {0}, 1, 0);

Review comment:
       The examples were of output that could be possible with a zero depending on formatting options. They were not meant to be input. The intension was to ask if the formatting supports this output for zero. Then a singleton for zero would have to respond appropriately.
   
   `"it still feels weird to have singletons of mutable instances"`
   
   It is not any weirder than having a mutable instance that cannot have its state changed. Which is my point. If you can never change the state of the `ParsedDecimal` for zero then surely this is the case for a singleton. Not having read the entire code I do not know if this is true. So can you change the state of a zero?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on pull request #248:
URL: https://github.com/apache/commons-text/pull/248#issuecomment-880621515


   @kinow, thanks for the detailed review! I've been busy recently so I haven't been able to look at this yet. I'll try to get to it in the next few days.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] kinow commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
kinow commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r667588788



##########
File path: src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.java
##########
@@ -0,0 +1,764 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+import java.util.function.BiFunction;
+
+import org.apache.commons.rng.UniformRandomProvider;
+import org.apache.commons.rng.simple.RandomSource;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+class ParsedDecimalTest {
+
+    @Test
+    void testFrom() {
+        // act/assert
+        checkFrom(0.0, "0", 0);
+
+        checkFrom(1.0, "1", 0);
+        checkFrom(10.0, "1", 1);
+        checkFrom(100.0, "1", 2);
+        checkFrom(1000.0, "1", 3);
+        checkFrom(10000.0, "1", 4);
+
+        checkFrom(0.1, "1", -1);
+        checkFrom(0.01, "1", -2);
+        checkFrom(0.001, "1", -3);
+        checkFrom(0.0001, "1", -4);
+        checkFrom(0.00001, "1", -5);
+
+        checkFrom(1.2, "12", -1);
+        checkFrom(0.00971, "971", -5);
+        checkFrom(56300, "563", 2);
+
+        checkFrom(123.0, "123", 0);
+        checkFrom(1230.0, "123", 1);
+        checkFrom(12300.0, "123", 2);
+        checkFrom(123000.0, "123", 3);
+
+        checkFrom(12.3, "123", -1);
+        checkFrom(1.23, "123", -2);
+        checkFrom(0.123, "123", -3);
+        checkFrom(0.0123, "123", -4);
+
+        checkFrom(1.987654321e270, "1987654321", 261);
+        checkFrom(1.987654321e-270, "1987654321", -279);
+
+        checkFrom(Math.PI, "3141592653589793", -15);
+        checkFrom(Math.E, "2718281828459045", -15);
+
+        checkFrom(Double.MAX_VALUE, "17976931348623157", 292);
+        checkFrom(Double.MIN_VALUE, "49", -325);
+        checkFrom(Double.MIN_NORMAL, "22250738585072014", -324);
+    }
+
+    @Test
+    void testFrom_notFinite() {
+        // arrange
+        final String msg = "Double is not finite";
+
+        // act/assert
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NaN),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NEGATIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.POSITIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+    }
+
+    @Test
+    void testIsZero() {
+        // act/assert
+        Assertions.assertTrue(ParsedDecimal.from(0.0).isZero());
+        Assertions.assertTrue(ParsedDecimal.from(-0.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(1.0).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-1.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MIN_NORMAL).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_NORMAL).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MAX_VALUE).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_VALUE).isZero());
+    }
+
+    @Test
+    void testRound_one() {
+        // arrange
+        final double a = 1e-10;
+        final double b = -1;
+        final double c = 1e10;
+
+        // act/assert
+        assertRound(a, -11, false, "1", -10);
+        assertRound(a, -10, false, "1", -10);
+        assertRound(a, -9, false, "0", 0);
+
+        assertRound(b, -1, true, "1", 0);
+        assertRound(b, 0, true, "1", 0);
+        assertRound(b, 1, true, "0", 0);
+
+        assertRound(c, 9, false, "1", 10);
+        assertRound(c, 10, false, "1", 10);
+        assertRound(c, 11, false, "0", 0);
+    }
+
+    @Test
+    void testRound_nine() {
+        // arrange
+        final double a = 9e-10;
+        final double b = -9;
+        final double c = 9e10;
+
+        // act/assert
+        assertRound(a, -11, false, "9", -10);
+        assertRound(a, -10, false, "9", -10);
+        assertRound(a, -9, false, "1", -9);
+
+        assertRound(b, -1, true, "9", 0);
+        assertRound(b, 0, true, "9", 0);
+        assertRound(b, 1, true, "1", 1);
+
+        assertRound(c, 9, false, "9", 10);
+        assertRound(c, 10, false, "9", 10);
+        assertRound(c, 11, false, "1", 11);
+    }
+
+    @Test
+    void testRound_mixed() {
+        // arrange
+        final double a = 9.94e-10;
+        final double b = -3.1415;
+        final double c = 5.55e10;
+
+        // act/assert
+        assertRound(a, -13, false, "994", -12);
+        assertRound(a, -12, false, "994", -12);
+        assertRound(a, -11, false, "99", -11);
+        assertRound(a, -10, false, "1", -9);
+        assertRound(a, -9, false, "1", -9);
+        assertRound(a, -8, false, "0", 0);
+
+        assertRound(b, -5, true, "31415", -4);
+        assertRound(b, -4, true, "31415", -4);
+        assertRound(b, -3, true, "3142", -3);
+        assertRound(b, -2, true, "314", -2);
+        assertRound(b, -1, true, "31", -1);
+        assertRound(b, 0, true, "3", 0);
+        assertRound(b, 1, true, "0", 0);
+        assertRound(b, 2, true, "0", 0);
+
+        assertRound(c, 7, false, "555", 8);
+        assertRound(c, 8, false, "555", 8);
+        assertRound(c, 9, false, "56", 9);
+        assertRound(c, 10, false, "6", 10);
+        assertRound(c, 11, false, "1", 11);
+        assertRound(c, 12, false, "0", 0);
+    }
+
+    @Test
+    void testMaxPrecision() {
+        // arrange
+        final double d = 1.02576552;
+
+        // act
+        assertMaxPrecision(d, 10, false, "102576552", -8);
+        assertMaxPrecision(d, 9, false, "102576552", -8);
+        assertMaxPrecision(d, 8, false, "10257655", -7);
+        assertMaxPrecision(d, 7, false, "1025766", -6);
+        assertMaxPrecision(d, 6, false, "102577", -5);
+        assertMaxPrecision(d, 5, false, "10258", -4);
+        assertMaxPrecision(d, 4, false, "1026", -3);
+        assertMaxPrecision(d, 3, false, "103", -2);
+        assertMaxPrecision(d, 2, false, "1", 0);
+        assertMaxPrecision(d, 1, false, "1", 0);
+
+        assertMaxPrecision(d, 0, false, "102576552", -8);
+    }
+
+    @Test
+    void testMaxPrecision_carry() {
+        // arrange
+        final double d = -999.0999e50;
+
+        // act
+        assertMaxPrecision(d, 8, true, "9990999", 46);
+        assertMaxPrecision(d, 7, true, "9990999", 46);
+        assertMaxPrecision(d, 6, true, "9991", 49);
+        assertMaxPrecision(d, 5, true, "9991", 49);
+        assertMaxPrecision(d, 4, true, "9991", 49);
+        assertMaxPrecision(d, 3, true, "999", 50);
+        assertMaxPrecision(d, 2, true, "1", 53);
+        assertMaxPrecision(d, 1, true, "1", 53);
+
+        assertMaxPrecision(d, 0, true, "9990999", 46);
+    }
+
+    @Test
+    void testMaxPrecision_halfEvenRounding() {
+        // act/assert
+        // Test values taken from RoundingMode.HALF_EVEN javadocs
+        assertMaxPrecision(5.5, 1, false, "6", 0);
+        assertMaxPrecision(2.5, 1, false, "2", 0);
+        assertMaxPrecision(1.6, 1, false, "2", 0);
+        assertMaxPrecision(1.1, 1, false, "1", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-1.1, 1, true, "1", 0);
+        assertMaxPrecision(-1.6, 1, true, "2", 0);
+        assertMaxPrecision(-2.5, 1, true, "2", 0);
+        assertMaxPrecision(-5.5, 1, true, "6", 0);
+    }
+
+    @Test
+    void testMaxPrecision_singleDigits() {
+        // act
+        assertMaxPrecision(9.0, 1, false, "9", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+        assertMaxPrecision(0.0, 1, false, "0", 0);
+        assertMaxPrecision(-0.0, 1, true, "0", 0);
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-9.0, 1, true, "9", 0);
+    }
+
+    @Test
+    void testMaxPrecision_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+        final ParsedDecimal.FormatOptions opts = new FormatOptionsImpl();
+
+        for (int i = 0; i < 10_000; ++i) {
+            final double d = createRandomDouble(rand);
+            final int precision = rand.nextInt(20) + 1;
+            final MathContext ctx = new MathContext(precision, RoundingMode.HALF_EVEN);
+
+            final ParsedDecimal dec = ParsedDecimal.from(d);
+
+            // act
+            dec.maxPrecision(precision);
+
+            // assert
+            Assertions.assertEquals(new BigDecimal(Double.toString(d), ctx).doubleValue(),
+                    Double.parseDouble(scientificString(dec, opts)));
+        }
+    }
+
+    @Test
+    void testToPlainString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToPlainString(0.0, "0.0", opts);
+        checkToPlainString(-0.0, "-0.0", opts);
+        checkToPlainString(1.0, "1.0", opts);
+        checkToPlainString(1.5, "1.5", opts);
+
+        checkToPlainString(12, "12.0", opts);
+        checkToPlainString(123, "123.0", opts);
+        checkToPlainString(1234, "1234.0", opts);
+        checkToPlainString(12345, "12345.0", opts);
+        checkToPlainString(123456, "123456.0", opts);
+        checkToPlainString(1234567, "1234567.0", opts);
+        checkToPlainString(12345678, "12345678.0", opts);
+        checkToPlainString(123456789, "123456789.0", opts);
+        checkToPlainString(1234567890, "1234567890.0", opts);
+
+        checkToPlainString(-0.000123, "-0.000123", opts);
+        checkToPlainString(12301, "12301.0", opts);
+
+        checkToPlainString(Math.PI, "3.141592653589793", opts);
+        checkToPlainString(Math.E, "2.718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "-12345.6789", opts);
+        checkToPlainString(1.23e12, "1230000000000.0", opts);
+        checkToPlainString(1.23e-12, "0.00000000000123", opts);
+    }
+
+    @Test
+    void testToPlainString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setThousandsGroupingSeparator('_');
+        opts.setGroupThousands(true);
+
+        // act/assert
+        checkToPlainString(0.0, "0", opts);
+        checkToPlainString(-0.0, "0", opts);
+        checkToPlainString(1.0, "1", opts);
+        checkToPlainString(1.5, "1,5", opts);
+
+        checkToPlainString(12, "12", opts);
+        checkToPlainString(123, "123", opts);
+        checkToPlainString(1234, "1_234", opts);
+        checkToPlainString(12345, "12_345", opts);
+        checkToPlainString(123456, "123_456", opts);
+        checkToPlainString(1234567, "1_234_567", opts);
+        checkToPlainString(12345678, "12_345_678", opts);
+        checkToPlainString(123456789, "123_456_789", opts);
+        checkToPlainString(1234567890, "1_234_567_890", opts);
+
+        checkToPlainString(-0.000123, "!0,000123", opts);
+        checkToPlainString(12301, "12_301", opts);
+
+        checkToPlainString(Math.PI, "3,141592653589793", opts);
+        checkToPlainString(Math.E, "2,718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "!12_345,6789", opts);
+        checkToPlainString(1.23e12, "1_230_000_000_000", opts);
+        checkToPlainString(1.23e-12, "0,00000000000123", opts);
+    }
+
+    @Test
+    void testToScientificString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToScientificString(0.0, "0.0", opts);
+        checkToScientificString(-0.0, "-0.0", opts);
+        checkToScientificString(1.0, "1.0", opts);
+        checkToScientificString(1.5, "1.5", opts);
+
+        checkToScientificString(-0.000123, "-1.23E-4", opts);
+        checkToScientificString(12301, "1.2301E4", opts);
+
+        checkToScientificString(Math.PI, "3.141592653589793", opts);
+        checkToScientificString(Math.E, "2.718281828459045", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "-1.7976931348623157E308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2.2250738585072014E-308", opts);
+    }
+
+    @Test
+    void testToScientificString_altFormats() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToScientificString(0.0, "0x10^0", opts);
+        checkToScientificString(-0.0, "0x10^0", opts);
+        checkToScientificString(1.0, "1x10^0", opts);
+        checkToScientificString(1.5, "1,5x10^0", opts);
+
+        checkToScientificString(-0.000123, "!1,23x10^!4", opts);
+        checkToScientificString(12301, "1,2301x10^4", opts);
+
+        checkToScientificString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToScientificString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "!1,7976931348623157x10^308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2,2250738585072014x10^!308", opts);
+    }
+
+    @Test
+    void testToEngineeringString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToEngineeringString(0.0, "0.0", opts);
+        checkToEngineeringString(-0.0, "-0.0", opts);
+        checkToEngineeringString(1.0, "1.0", opts);
+        checkToEngineeringString(1.5, "1.5", opts);
+
+        checkToEngineeringString(10, "10.0", opts);
+
+        checkToEngineeringString(-0.000000123, "-123.0E-9", opts);
+        checkToEngineeringString(12300000, "12.3E6", opts);
+
+        checkToEngineeringString(Math.PI, "3.141592653589793", opts);
+        checkToEngineeringString(Math.E, "2.718281828459045", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "-179.76931348623157E306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22.250738585072014E-309", opts);
+    }
+
+    @Test
+    void testToEngineeringString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToEngineeringString(0.0, "0x10^0", opts);
+        checkToEngineeringString(-0.0, "0x10^0", opts);
+        checkToEngineeringString(1.0, "1x10^0", opts);
+        checkToEngineeringString(1.5, "1,5x10^0", opts);
+
+        checkToEngineeringString(10, "10x10^0", opts);
+
+        checkToEngineeringString(-0.000000123, "!123x10^!9", opts);
+        checkToEngineeringString(12300000, "12,3x10^6", opts);
+
+        checkToEngineeringString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToEngineeringString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "!179,76931348623157x10^306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22,250738585072014x10^!309", opts);
+    }
+
+    @Test
+    void testStringMethods_customDigits() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setDigitsFromString("abcdefghij");
+
+        // act/assert
+        Assertions.assertEquals("b.a", plainString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-a.abcd", plainString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", plainString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("baaaa.a", plainString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("jihgfedcba.a", plainString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", scientificString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-b.cdE-c", scientificString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("b.cdeEb", scientificString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("b.aEe", scientificString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", scientificString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", engineeringString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-bc.dE-d", engineeringString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", engineeringString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("ba.aEd", engineeringString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", engineeringString(ParsedDecimal.from(9876543210d), opts));
+    }
+
+    @Test
+    void testStringMethodAccuracy_sequence() {
+        // arrange
+        final double min = -1000;
+        final double max = 1000;
+        final double delta = 0.1;
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        Assertions.assertEquals(10.0, Double.parseDouble(scientificString(ParsedDecimal.from(10.0), stdOpts)));
+
+        for (double d = min; d <= max; d += delta) {
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    @Test
+    void testStringMethodAccuracy_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        double d;
+        for (int i = 0; i < 10_000; ++i) {
+            d = createRandomDouble(rand);
+
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    private static void checkFrom(final double d, final String digits, final int exponent) {
+        final boolean negative = Math.signum(d) < 0;
+
+        assertSimpleDecimal(ParsedDecimal.from(d), negative, digits, exponent);
+        assertSimpleDecimal(ParsedDecimal.from(-d), !negative, digits, exponent);
+    }
+
+    private static void checkToPlainString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::plainString, opts);
+    }
+
+    private static void checkToScientificString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::scientificString, opts);
+    }
+
+    private static void checkToEngineeringString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::engineeringString, opts);
+
+        // check the exponent value to make sure it is a multiple of 3
+        final String pos = engineeringString(ParsedDecimal.from(d), opts);
+        Assertions.assertEquals(0, parseExponent(pos, opts) % 3);
+
+        final String neg = engineeringString(ParsedDecimal.from(-d), opts);
+        Assertions.assertEquals(0, parseExponent(neg, opts) % 3);
+    }
+
+    private static int parseExponent(final String str, final ParsedDecimal.FormatOptions opts) {
+        final char[] expSep = opts.getExponentSeparatorChars();
+
+        final int expStartIdx = str.indexOf(String.valueOf(expSep));
+        if (expStartIdx > -1) {
+            int expIdx = expStartIdx + expSep.length;
+
+            boolean neg = false;
+            if (str.charAt(expIdx) == opts.getMinusSign()) {
+                ++expIdx;
+            }
+
+            final String expStr = str.substring(expIdx);
+            final int val = Integer.parseInt(expStr);
+            return neg

Review comment:
       Were we supposed to set `neg = true` inside the `if` block above, where it checks if a character at a certain index was equal to `opts.getMinusSign()`?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r671600358



##########
File path: src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.java
##########
@@ -0,0 +1,764 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+import java.util.function.BiFunction;
+
+import org.apache.commons.rng.UniformRandomProvider;
+import org.apache.commons.rng.simple.RandomSource;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+class ParsedDecimalTest {
+
+    @Test
+    void testFrom() {
+        // act/assert
+        checkFrom(0.0, "0", 0);
+
+        checkFrom(1.0, "1", 0);
+        checkFrom(10.0, "1", 1);
+        checkFrom(100.0, "1", 2);
+        checkFrom(1000.0, "1", 3);
+        checkFrom(10000.0, "1", 4);
+
+        checkFrom(0.1, "1", -1);
+        checkFrom(0.01, "1", -2);
+        checkFrom(0.001, "1", -3);
+        checkFrom(0.0001, "1", -4);
+        checkFrom(0.00001, "1", -5);
+
+        checkFrom(1.2, "12", -1);
+        checkFrom(0.00971, "971", -5);
+        checkFrom(56300, "563", 2);
+
+        checkFrom(123.0, "123", 0);
+        checkFrom(1230.0, "123", 1);
+        checkFrom(12300.0, "123", 2);
+        checkFrom(123000.0, "123", 3);
+
+        checkFrom(12.3, "123", -1);
+        checkFrom(1.23, "123", -2);
+        checkFrom(0.123, "123", -3);
+        checkFrom(0.0123, "123", -4);
+
+        checkFrom(1.987654321e270, "1987654321", 261);
+        checkFrom(1.987654321e-270, "1987654321", -279);
+
+        checkFrom(Math.PI, "3141592653589793", -15);
+        checkFrom(Math.E, "2718281828459045", -15);
+
+        checkFrom(Double.MAX_VALUE, "17976931348623157", 292);
+        checkFrom(Double.MIN_VALUE, "49", -325);
+        checkFrom(Double.MIN_NORMAL, "22250738585072014", -324);
+    }
+
+    @Test
+    void testFrom_notFinite() {
+        // arrange
+        final String msg = "Double is not finite";
+
+        // act/assert
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NaN),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.NEGATIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+        assertThrowsWithMessage(() -> ParsedDecimal.from(Double.POSITIVE_INFINITY),
+                IllegalArgumentException.class, msg);
+    }
+
+    @Test
+    void testIsZero() {
+        // act/assert
+        Assertions.assertTrue(ParsedDecimal.from(0.0).isZero());
+        Assertions.assertTrue(ParsedDecimal.from(-0.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(1.0).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-1.0).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MIN_NORMAL).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_NORMAL).isZero());
+
+        Assertions.assertFalse(ParsedDecimal.from(Double.MAX_VALUE).isZero());
+        Assertions.assertFalse(ParsedDecimal.from(-Double.MIN_VALUE).isZero());
+    }
+
+    @Test
+    void testRound_one() {
+        // arrange
+        final double a = 1e-10;
+        final double b = -1;
+        final double c = 1e10;
+
+        // act/assert
+        assertRound(a, -11, false, "1", -10);
+        assertRound(a, -10, false, "1", -10);
+        assertRound(a, -9, false, "0", 0);
+
+        assertRound(b, -1, true, "1", 0);
+        assertRound(b, 0, true, "1", 0);
+        assertRound(b, 1, true, "0", 0);
+
+        assertRound(c, 9, false, "1", 10);
+        assertRound(c, 10, false, "1", 10);
+        assertRound(c, 11, false, "0", 0);
+    }
+
+    @Test
+    void testRound_nine() {
+        // arrange
+        final double a = 9e-10;
+        final double b = -9;
+        final double c = 9e10;
+
+        // act/assert
+        assertRound(a, -11, false, "9", -10);
+        assertRound(a, -10, false, "9", -10);
+        assertRound(a, -9, false, "1", -9);
+
+        assertRound(b, -1, true, "9", 0);
+        assertRound(b, 0, true, "9", 0);
+        assertRound(b, 1, true, "1", 1);
+
+        assertRound(c, 9, false, "9", 10);
+        assertRound(c, 10, false, "9", 10);
+        assertRound(c, 11, false, "1", 11);
+    }
+
+    @Test
+    void testRound_mixed() {
+        // arrange
+        final double a = 9.94e-10;
+        final double b = -3.1415;
+        final double c = 5.55e10;
+
+        // act/assert
+        assertRound(a, -13, false, "994", -12);
+        assertRound(a, -12, false, "994", -12);
+        assertRound(a, -11, false, "99", -11);
+        assertRound(a, -10, false, "1", -9);
+        assertRound(a, -9, false, "1", -9);
+        assertRound(a, -8, false, "0", 0);
+
+        assertRound(b, -5, true, "31415", -4);
+        assertRound(b, -4, true, "31415", -4);
+        assertRound(b, -3, true, "3142", -3);
+        assertRound(b, -2, true, "314", -2);
+        assertRound(b, -1, true, "31", -1);
+        assertRound(b, 0, true, "3", 0);
+        assertRound(b, 1, true, "0", 0);
+        assertRound(b, 2, true, "0", 0);
+
+        assertRound(c, 7, false, "555", 8);
+        assertRound(c, 8, false, "555", 8);
+        assertRound(c, 9, false, "56", 9);
+        assertRound(c, 10, false, "6", 10);
+        assertRound(c, 11, false, "1", 11);
+        assertRound(c, 12, false, "0", 0);
+    }
+
+    @Test
+    void testMaxPrecision() {
+        // arrange
+        final double d = 1.02576552;
+
+        // act
+        assertMaxPrecision(d, 10, false, "102576552", -8);
+        assertMaxPrecision(d, 9, false, "102576552", -8);
+        assertMaxPrecision(d, 8, false, "10257655", -7);
+        assertMaxPrecision(d, 7, false, "1025766", -6);
+        assertMaxPrecision(d, 6, false, "102577", -5);
+        assertMaxPrecision(d, 5, false, "10258", -4);
+        assertMaxPrecision(d, 4, false, "1026", -3);
+        assertMaxPrecision(d, 3, false, "103", -2);
+        assertMaxPrecision(d, 2, false, "1", 0);
+        assertMaxPrecision(d, 1, false, "1", 0);
+
+        assertMaxPrecision(d, 0, false, "102576552", -8);
+    }
+
+    @Test
+    void testMaxPrecision_carry() {
+        // arrange
+        final double d = -999.0999e50;
+
+        // act
+        assertMaxPrecision(d, 8, true, "9990999", 46);
+        assertMaxPrecision(d, 7, true, "9990999", 46);
+        assertMaxPrecision(d, 6, true, "9991", 49);
+        assertMaxPrecision(d, 5, true, "9991", 49);
+        assertMaxPrecision(d, 4, true, "9991", 49);
+        assertMaxPrecision(d, 3, true, "999", 50);
+        assertMaxPrecision(d, 2, true, "1", 53);
+        assertMaxPrecision(d, 1, true, "1", 53);
+
+        assertMaxPrecision(d, 0, true, "9990999", 46);
+    }
+
+    @Test
+    void testMaxPrecision_halfEvenRounding() {
+        // act/assert
+        // Test values taken from RoundingMode.HALF_EVEN javadocs
+        assertMaxPrecision(5.5, 1, false, "6", 0);
+        assertMaxPrecision(2.5, 1, false, "2", 0);
+        assertMaxPrecision(1.6, 1, false, "2", 0);
+        assertMaxPrecision(1.1, 1, false, "1", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-1.1, 1, true, "1", 0);
+        assertMaxPrecision(-1.6, 1, true, "2", 0);
+        assertMaxPrecision(-2.5, 1, true, "2", 0);
+        assertMaxPrecision(-5.5, 1, true, "6", 0);
+    }
+
+    @Test
+    void testMaxPrecision_singleDigits() {
+        // act
+        assertMaxPrecision(9.0, 1, false, "9", 0);
+        assertMaxPrecision(1.0, 1, false, "1", 0);
+        assertMaxPrecision(0.0, 1, false, "0", 0);
+        assertMaxPrecision(-0.0, 1, true, "0", 0);
+        assertMaxPrecision(-1.0, 1, true, "1", 0);
+        assertMaxPrecision(-9.0, 1, true, "9", 0);
+    }
+
+    @Test
+    void testMaxPrecision_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+        final ParsedDecimal.FormatOptions opts = new FormatOptionsImpl();
+
+        for (int i = 0; i < 10_000; ++i) {
+            final double d = createRandomDouble(rand);
+            final int precision = rand.nextInt(20) + 1;
+            final MathContext ctx = new MathContext(precision, RoundingMode.HALF_EVEN);
+
+            final ParsedDecimal dec = ParsedDecimal.from(d);
+
+            // act
+            dec.maxPrecision(precision);
+
+            // assert
+            Assertions.assertEquals(new BigDecimal(Double.toString(d), ctx).doubleValue(),
+                    Double.parseDouble(scientificString(dec, opts)));
+        }
+    }
+
+    @Test
+    void testToPlainString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToPlainString(0.0, "0.0", opts);
+        checkToPlainString(-0.0, "-0.0", opts);
+        checkToPlainString(1.0, "1.0", opts);
+        checkToPlainString(1.5, "1.5", opts);
+
+        checkToPlainString(12, "12.0", opts);
+        checkToPlainString(123, "123.0", opts);
+        checkToPlainString(1234, "1234.0", opts);
+        checkToPlainString(12345, "12345.0", opts);
+        checkToPlainString(123456, "123456.0", opts);
+        checkToPlainString(1234567, "1234567.0", opts);
+        checkToPlainString(12345678, "12345678.0", opts);
+        checkToPlainString(123456789, "123456789.0", opts);
+        checkToPlainString(1234567890, "1234567890.0", opts);
+
+        checkToPlainString(-0.000123, "-0.000123", opts);
+        checkToPlainString(12301, "12301.0", opts);
+
+        checkToPlainString(Math.PI, "3.141592653589793", opts);
+        checkToPlainString(Math.E, "2.718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "-12345.6789", opts);
+        checkToPlainString(1.23e12, "1230000000000.0", opts);
+        checkToPlainString(1.23e-12, "0.00000000000123", opts);
+    }
+
+    @Test
+    void testToPlainString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setThousandsGroupingSeparator('_');
+        opts.setGroupThousands(true);
+
+        // act/assert
+        checkToPlainString(0.0, "0", opts);
+        checkToPlainString(-0.0, "0", opts);
+        checkToPlainString(1.0, "1", opts);
+        checkToPlainString(1.5, "1,5", opts);
+
+        checkToPlainString(12, "12", opts);
+        checkToPlainString(123, "123", opts);
+        checkToPlainString(1234, "1_234", opts);
+        checkToPlainString(12345, "12_345", opts);
+        checkToPlainString(123456, "123_456", opts);
+        checkToPlainString(1234567, "1_234_567", opts);
+        checkToPlainString(12345678, "12_345_678", opts);
+        checkToPlainString(123456789, "123_456_789", opts);
+        checkToPlainString(1234567890, "1_234_567_890", opts);
+
+        checkToPlainString(-0.000123, "!0,000123", opts);
+        checkToPlainString(12301, "12_301", opts);
+
+        checkToPlainString(Math.PI, "3,141592653589793", opts);
+        checkToPlainString(Math.E, "2,718281828459045", opts);
+
+        checkToPlainString(-12345.6789, "!12_345,6789", opts);
+        checkToPlainString(1.23e12, "1_230_000_000_000", opts);
+        checkToPlainString(1.23e-12, "0,00000000000123", opts);
+    }
+
+    @Test
+    void testToScientificString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToScientificString(0.0, "0.0", opts);
+        checkToScientificString(-0.0, "-0.0", opts);
+        checkToScientificString(1.0, "1.0", opts);
+        checkToScientificString(1.5, "1.5", opts);
+
+        checkToScientificString(-0.000123, "-1.23E-4", opts);
+        checkToScientificString(12301, "1.2301E4", opts);
+
+        checkToScientificString(Math.PI, "3.141592653589793", opts);
+        checkToScientificString(Math.E, "2.718281828459045", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "-1.7976931348623157E308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2.2250738585072014E-308", opts);
+    }
+
+    @Test
+    void testToScientificString_altFormats() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToScientificString(0.0, "0x10^0", opts);
+        checkToScientificString(-0.0, "0x10^0", opts);
+        checkToScientificString(1.0, "1x10^0", opts);
+        checkToScientificString(1.5, "1,5x10^0", opts);
+
+        checkToScientificString(-0.000123, "!1,23x10^!4", opts);
+        checkToScientificString(12301, "1,2301x10^4", opts);
+
+        checkToScientificString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToScientificString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToScientificString(-Double.MAX_VALUE, "!1,7976931348623157x10^308", opts);
+        checkToScientificString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToScientificString(Double.MIN_NORMAL, "2,2250738585072014x10^!308", opts);
+    }
+
+    @Test
+    void testToEngineeringString_defaults() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+
+        // act/assert
+        checkToEngineeringString(0.0, "0.0", opts);
+        checkToEngineeringString(-0.0, "-0.0", opts);
+        checkToEngineeringString(1.0, "1.0", opts);
+        checkToEngineeringString(1.5, "1.5", opts);
+
+        checkToEngineeringString(10, "10.0", opts);
+
+        checkToEngineeringString(-0.000000123, "-123.0E-9", opts);
+        checkToEngineeringString(12300000, "12.3E6", opts);
+
+        checkToEngineeringString(Math.PI, "3.141592653589793", opts);
+        checkToEngineeringString(Math.E, "2.718281828459045", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "-179.76931348623157E306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4.9E-324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22.250738585072014E-309", opts);
+    }
+
+    @Test
+    void testToEngineeringString_altFormat() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setIncludeFractionPlaceholder(false);
+        opts.setSignedZero(false);
+        opts.setDecimalSeparator(',');
+        opts.setMinusSign('!');
+        opts.setExponentSeparator("x10^");
+        opts.setAlwaysIncludeExponent(true);
+
+        // act/assert
+        checkToEngineeringString(0.0, "0x10^0", opts);
+        checkToEngineeringString(-0.0, "0x10^0", opts);
+        checkToEngineeringString(1.0, "1x10^0", opts);
+        checkToEngineeringString(1.5, "1,5x10^0", opts);
+
+        checkToEngineeringString(10, "10x10^0", opts);
+
+        checkToEngineeringString(-0.000000123, "!123x10^!9", opts);
+        checkToEngineeringString(12300000, "12,3x10^6", opts);
+
+        checkToEngineeringString(Math.PI, "3,141592653589793x10^0", opts);
+        checkToEngineeringString(Math.E, "2,718281828459045x10^0", opts);
+
+        checkToEngineeringString(-Double.MAX_VALUE, "!179,76931348623157x10^306", opts);
+        checkToEngineeringString(Double.MIN_VALUE, "4,9x10^!324", opts);
+        checkToEngineeringString(Double.MIN_NORMAL, "22,250738585072014x10^!309", opts);
+    }
+
+    @Test
+    void testStringMethods_customDigits() {
+        // arrange
+        final FormatOptionsImpl opts = new FormatOptionsImpl();
+        opts.setDigitsFromString("abcdefghij");
+
+        // act/assert
+        Assertions.assertEquals("b.a", plainString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-a.abcd", plainString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", plainString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("baaaa.a", plainString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("jihgfedcba.a", plainString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", scientificString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-b.cdE-c", scientificString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("b.cdeEb", scientificString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("b.aEe", scientificString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", scientificString(ParsedDecimal.from(9876543210d), opts));
+
+        Assertions.assertEquals("b.a", engineeringString(ParsedDecimal.from(1.0), opts));
+        Assertions.assertEquals("-bc.dE-d", engineeringString(ParsedDecimal.from(-0.0123), opts));
+        Assertions.assertEquals("bc.de", engineeringString(ParsedDecimal.from(12.34), opts));
+        Assertions.assertEquals("ba.aEd", engineeringString(ParsedDecimal.from(10000), opts));
+        Assertions.assertEquals("j.ihgfedcbEj", engineeringString(ParsedDecimal.from(9876543210d), opts));
+    }
+
+    @Test
+    void testStringMethodAccuracy_sequence() {
+        // arrange
+        final double min = -1000;
+        final double max = 1000;
+        final double delta = 0.1;
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        Assertions.assertEquals(10.0, Double.parseDouble(scientificString(ParsedDecimal.from(10.0), stdOpts)));
+
+        for (double d = min; d <= max; d += delta) {
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    @Test
+    void testStringMethodAccuracy_random() {
+        // arrange
+        final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 0L);
+
+        final FormatOptionsImpl stdOpts = new FormatOptionsImpl();
+        final FormatOptionsImpl altOpts = new FormatOptionsImpl();
+        altOpts.setExponentSeparator("e");
+        altOpts.setIncludeFractionPlaceholder(false);
+
+        double d;
+        for (int i = 0; i < 10_000; ++i) {
+            d = createRandomDouble(rand);
+
+            // act/assert
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(scientificString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(engineeringString(ParsedDecimal.from(d), altOpts)));
+
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), stdOpts)));
+            Assertions.assertEquals(d, Double.parseDouble(plainString(ParsedDecimal.from(d), altOpts)));
+        }
+    }
+
+    private static void checkFrom(final double d, final String digits, final int exponent) {
+        final boolean negative = Math.signum(d) < 0;
+
+        assertSimpleDecimal(ParsedDecimal.from(d), negative, digits, exponent);
+        assertSimpleDecimal(ParsedDecimal.from(-d), !negative, digits, exponent);
+    }
+
+    private static void checkToPlainString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::plainString, opts);
+    }
+
+    private static void checkToScientificString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::scientificString, opts);
+    }
+
+    private static void checkToEngineeringString(final double d, final String expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimalTest::engineeringString, opts);
+
+        // check the exponent value to make sure it is a multiple of 3
+        final String pos = engineeringString(ParsedDecimal.from(d), opts);
+        Assertions.assertEquals(0, parseExponent(pos, opts) % 3);
+
+        final String neg = engineeringString(ParsedDecimal.from(-d), opts);
+        Assertions.assertEquals(0, parseExponent(neg, opts) % 3);
+    }
+
+    private static int parseExponent(final String str, final ParsedDecimal.FormatOptions opts) {
+        final char[] expSep = opts.getExponentSeparatorChars();
+
+        final int expStartIdx = str.indexOf(String.valueOf(expSep));
+        if (expStartIdx > -1) {
+            int expIdx = expStartIdx + expSep.length;
+
+            boolean neg = false;
+            if (str.charAt(expIdx) == opts.getMinusSign()) {
+                ++expIdx;
+            }
+
+            final String expStr = str.substring(expIdx);
+            final int val = Integer.parseInt(expStr);
+            return neg
+                    ? -val
+                    : val;
+        }
+
+        return 0;
+    }
+
+    private static void checkToStringMethod(final double d, final String expected,
+            final BiFunction<ParsedDecimal, ParsedDecimal.FormatOptions, String> fn,
+            final ParsedDecimal.FormatOptions opts) {
+
+        final ParsedDecimal pos = ParsedDecimal.from(d);
+        final String actual = fn.apply(pos, opts);
+
+        Assertions.assertEquals(expected, actual);
+    }
+
+    private static void assertRound(final double d, final int roundExponent,
+            final boolean negative, final String digits, final int exponent) {
+        final ParsedDecimal dec = ParsedDecimal.from(d);
+        dec.round(roundExponent);
+
+        assertSimpleDecimal(dec, negative, digits, exponent);
+    }
+
+    private static void assertMaxPrecision(final double d, final int maxPrecision,
+            final boolean negative, final String digits, final int exponent) {
+        final ParsedDecimal dec = ParsedDecimal.from(d);
+        dec.maxPrecision(maxPrecision);
+
+        assertSimpleDecimal(dec, negative, digits, exponent);
+    }
+
+    private static void assertSimpleDecimal(final ParsedDecimal parsed, final boolean negative, final String digits,
+            final int exponent) {
+        Assertions.assertEquals(negative, parsed.negative);
+        Assertions.assertEquals(digits, digitString(parsed));
+        Assertions.assertEquals(exponent, parsed.getExponent());
+        Assertions.assertEquals(digits.length(), parsed.digitCount);
+        Assertions.assertEquals(exponent, parsed.getScientificExponent() - digits.length() + 1);
+    }
+
+    private static void assertThrowsWithMessage(final Executable fn, final Class<? extends Throwable> type,
+            final String msg) {
+        Throwable exc = Assertions.assertThrows(type, fn);
+        Assertions.assertEquals(msg, exc.getMessage());
+    }
+
+    private static double createRandomDouble(final UniformRandomProvider rng) {
+        final long mask = ((1L << 52) - 1) | 1L << 63;
+        final long bits = rng.nextLong() & mask;
+        final long exp = rng.nextInt(2045) + 1;
+        return Double.longBitsToDouble(bits | (exp << 52));
+    }
+
+    /** Get the raw digits in the given decimal as a string.
+     * @param dec decimal instancE
+     * @return decimal digits as a string
+     */
+    private static String digitString(final ParsedDecimal dec) {
+        final StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < dec.digitCount; ++i) {
+            sb.append(dec.digits[i]);
+        }
+        return sb.toString();
+    }
+
+    private static String plainString(final ParsedDecimal dec, final ParsedDecimal.FormatOptions opts) {
+//        try {
+//            StringBuilder sb = new StringBuilder();
+//            dec.toPlainString(sb, opts);
+//            return sb.toString();
+//        } catch (IOException exc) {
+//            throw new UncheckedIOException(exc);
+//        }
+        return dec.toPlainString(opts);
+    }
+
+    private static String scientificString(final ParsedDecimal dec, final ParsedDecimal.FormatOptions opts) {
+//        try {
+//            StringBuilder sb = new StringBuilder();
+//            dec.toScientificString(sb, opts);
+//            return sb.toString();
+//        } catch (IOException exc) {
+//            throw new UncheckedIOException(exc);
+//        }
+        return dec.toScientificString(opts);
+    }
+
+    private static String engineeringString(final ParsedDecimal dec, final ParsedDecimal.FormatOptions opts) {
+//        try {
+//            StringBuilder sb = new StringBuilder();
+//            dec.toEngineeringString(sb, opts);
+//            return sb.toString();
+//        } catch (IOException exc) {
+//            throw new UncheckedIOException(exc);
+//        }

Review comment:
       This is a relic from a previous refactor. I've removed these methods.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r671600258



##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;
+
+        /** Construct a new instance that delegates double function construction
+         * to the given factory object.
+         * @param factory factory function
+         */
+        private Builder(final Function<Builder, DoubleFunction<String>> factory) {
+            this.factory = factory;
+        }
+
+        /** Set the maximum number of significant decimal digits used in format
+         * results. A value of {@code 0} indicates no limit. The default value is {@code 0}.
+         * @param maxPrecision maximum precision
+         * @return this instance
+         */
+        public Builder maxPrecision(final int maxPrecision) {
+            this.maxPrecision = maxPrecision;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for formatted strings. No digits with an
+         * absolute value of less than <code>10<sup>minDecimalExponent</sup></code> will
+         * be included in format results. If the number being formatted does not contain
+         * any such digits, then zero is returned. For example, if {@code minDecimalExponent}
+         * is set to {@code -2} and the number {@code 3.14159} is formatted, the plain
+         * format result will be {@code "3.14"}. If {@code 0.001} is formatted, then the
+         * result is the zero string.
+         * @param minDecimalExponent minimum decimal exponent
+         * @return this instance
+         */
+        public Builder minDecimalExponent(final int minDecimalExponent) {
+            this.minDecimalExponent = minDecimalExponent;
+            return this;
+        }
+
+        /** Set the maximum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code 2}, the number {@code 999} will be formatted as {@code "999.0"}
+         * while {@code 1000} will be formatted as {@code "1.0E3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMaxDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMaxDecimalExponent(final int plainFormatMaxDecimalExponent) {
+            this.plainFormatMaxDecimalExponent = plainFormatMaxDecimalExponent;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code -2}, the number {@code 0.01} will be formatted as {@code "0.01"}
+         * while {@code 0.0099} will be formatted as {@code "9.9E-3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMinDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMinDecimalExponent(final int plainFormatMinDecimalExponent) {
+            this.plainFormatMinDecimalExponent = plainFormatMinDecimalExponent;
+            return this;
+        }
+
+        /** Set the flag determining whether or not the zero string may be returned with the minus
+         * sign or if it will always be returned in the positive form. For example, if set to true,
+         * the string {@code "-0.0"} may be returned for some input numbers. If false, only {@code "0.0"}
+         * will be returned, regardless of the sign of the input number. The default value is {@code true}.
+         * @param signedZero if true, the zero string may be returned with a preceding minus sign; if false,
+         *      the zero string will only be returned in its positive form
+         * @return this instance
+         */
+        public Builder allowSignedZero(final boolean signedZero) {
+            this.signedZero = signedZero;
+            return this;
+        }
+
+        /** Set the string containing the digit characters 0-9, in that order. The
+         * default value is the string {@code "0123456789"}.
+         * @param digits string containing the digit characters 0-9
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         * @throws IllegalArgumentException if the argument does not have a length of exactly 10
+         */
+        public Builder digits(final String digits) {
+            Objects.requireNonNull(digits, "Digits string cannot be null");
+            if (digits.length() != DEFAULT_DECIMAL_DIGITS.length()) {
+                throw new IllegalArgumentException("Digits string must contain exactly "
+                        + DEFAULT_DECIMAL_DIGITS.length() + " characters.");
+            }
+
+            this.digits = digits;
+            return this;
+        }
+
+        /** Set the flag determining whether or not a zero character is added in the fraction position
+         * when no fractional value is present. For example, if set to true, the number {@code 1} would
+         * be formatted as {@code "1.0"}. If false, it would be formatted as {@code "1"}. The default
+         * value is {@code true}.
+         * @param fractionPlaceholder if true, a zero character is placed in the fraction position when
+         *      no fractional value is present; if false, fractional digits are only included when needed
+         * @return this instance
+         */
+        public Builder includeFractionPlaceholder(final boolean fractionPlaceholder) {
+            this.fractionPlaceholder = fractionPlaceholder;
+            return this;
+        }
+
+        /** Set the character used as the minus sign.
+         * @param minusSign character to use as the minus sign
+         * @return this instance
+         */
+        public Builder minusSign(final char minusSign) {
+            this.minusSign = minusSign;
+            return this;
+        }
+
+        /** Set the decimal separator character, i.e., the character placed between the
+         * whole number and fractional portions of the formatted strings. The default value
+         * is {@code '.'}.
+         * @param decimalSeparator decimal separator character
+         * @return this instance
+         */
+        public Builder decimalSeparator(final char decimalSeparator) {
+            this.decimalSeparator = decimalSeparator;
+            return this;
+        }
+
+        /** Set the character used to separate groups of thousands. Default value is {@code ','}.
+         * @param groupingSeparator character used to separate groups of thousands
+         * @return this instance
+         * @see #groupThousands(boolean)
+         */
+        public Builder groupingSeparator(final char groupingSeparator) {
+            this.groupingSeparator = groupingSeparator;
+            return this;
+        }
+
+        /** If set to true, thousands will be grouped with the
+         * {@link #groupingSeparator(char) grouping separator}. For example, if set to true,
+         * the number {@code 1000} could be formatted as {@code "1,000"}. This property only applies
+         * to the {@link DoubleFormat#PLAIN PLAIN} format. Default value is {@code false}.
+         * @param groupThousands if true, thousands will be grouped
+         * @return this instance
+         * @see #groupingSeparator(char)
+         */
+        public Builder groupThousands(final boolean groupThousands) {
+            this.groupThousands = groupThousands;
+            return this;
+        }
+
+        /** Set the exponent separator character, i.e., the string placed between
+         * the mantissa and the exponent. The default value is {@code "E"}, as in
+         * {@code "1.2E6"}.
+         * @param exponentSeparator exponent separator string
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder exponentSeparator(final String exponentSeparator) {
+            Objects.requireNonNull(exponentSeparator, "Exponent separator cannot be null");
+
+            this.exponentSeparator = exponentSeparator;
+            return this;
+        }
+
+        /** Set the flag indicating if an exponent value should always be included in the
+         * formatted value, even if the exponent value is zero. This property only applies
+         * to formats that use scientific notation, namely
+         * {@link DoubleFormat#SCIENTIFIC SCIENTIFIC},
+         * {@link DoubleFormat#ENGINEERING ENGINEERING}, and
+         * {@link DoubleFormat#MIXED MIXED}. The default value is {@code false}.
+         * @param alwaysIncludeExponent if true, exponents will always be included in formatted
+         *      output even if the exponent value is zero
+         * @return this instance
+         */
+        public Builder alwaysIncludeExponent(final boolean alwaysIncludeExponent) {
+            this.alwaysIncludeExponent = alwaysIncludeExponent;
+            return this;
+        }
+
+        /** Set the string used to represent infinity. For negative infinity, this string
+         * is prefixed with the {@link #minusSign(char) minus sign}.
+         * @param infinity string used to represent infinity
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder infinity(final String infinity) {
+            Objects.requireNonNull(infinity, "Infinity string cannot be null");
+
+            this.infinity = infinity;
+            return this;
+        }
+
+        /** Set the string used to represent {@link Double#NaN}.
+         * @param nan string used to represent {@link Double#NaN}
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder nan(final String nan) {
+            Objects.requireNonNull(nan, "NaN string cannot be null");
+
+            this.nan = nan;
+            return this;
+        }
+
+        /** Configure this instance with the given format symbols. The following values
+         * are set:
+         * <ul>
+         *  <li>{@link #digits(String) digit characters}</li>
+         *  <li>{@link #decimalSeparator(char) decimal separator}</li>
+         *  <li>{@link #groupingSeparator(char) thousands grouping separator}</li>
+         *  <li>{@link #minusSign(char) minus sign}</li>
+         *  <li>{@link #exponentSeparator(String) exponent separator}</li>
+         *  <li>{@link #infinity(String) infinity}</li>
+         *  <li>{@link #nan(String) NaN}</li>
+         * </ul>
+         * The digit character string is constructed by starting at the configured
+         * {@link DecimalFormatSymbols#getZeroDigit() zero digit} and adding the next
+         * 9 consecutive characters.
+         * @param symbols format symbols
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder formatSymbols(final DecimalFormatSymbols symbols) {
+            Objects.requireNonNull(symbols, "Decimal format symbols cannot be null");
+
+            return digits(getDigitString(symbols))
+                    .decimalSeparator(symbols.getDecimalSeparator())
+                    .groupingSeparator(symbols.getGroupingSeparator())
+                    .minusSign(symbols.getMinusSign())
+                    .exponentSeparator(symbols.getExponentSeparator())
+                    .infinity(symbols.getInfinity())
+                    .nan(symbols.getNaN());
+        }
+
+        /** Get a string containing the localized digits 0-9 for the given symbols object. The
+         * string is constructed by starting at the {@link DecimalFormatSymbols#getZeroDigit() zero digit}
+         * and adding the next 9 consecutive characters.
+         * @param symbols symbols object
+         * @return string containing the localized digits 0-9
+         */
+        private String getDigitString(final DecimalFormatSymbols symbols) {
+            final int zeroDelta = symbols.getZeroDigit() - DEFAULT_DECIMAL_DIGITS.charAt(0);
+
+            final char[] digitChars = new char[DEFAULT_DECIMAL_DIGITS.length()];
+            for (int i = 0; i < DEFAULT_DECIMAL_DIGITS.length(); ++i) {
+                digitChars[i] = (char) (DEFAULT_DECIMAL_DIGITS.charAt(i) + zeroDelta);
+            }
+
+            return String.valueOf(digitChars);
+        }
+
+        /** Construct a new double format function.
+         * @return format function
+         */
+        public DoubleFunction<String> build() {
+            return factory.apply(this);
+        }
+    }
+
+    /** Base class for standard double formatting classes.
+     */
+    private abstract static class AbstractDoubleFormat
+        implements DoubleFunction<String>, ParsedDecimal.FormatOptions {
+
+        /** Maximum precision; 0 indicates no limit. */
+        private final int maxPrecision;
+
+        /** Minimum decimal exponent. */
+        private final int minDecimalExponent;
+
+        /** String representing positive infinity. */
+        private final String postiveInfinity;
+
+        /** String representing negative infinity. */
+        private final String negativeInfinity;
+
+        /** String representing NaN. */
+        private final String nan;
+
+        /** Flag determining if fraction placeholders should be used. */
+        private final boolean fractionPlaceholder;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private final boolean signedZero;
+
+        /** String containing the digits 0-9. */
+        private final char[] digits;
+
+        /** Decimal separator character. */
+        private final char decimalSeparator;
+
+        /** Thousands grouping separator. */
+        private final char groupingSeparator;
+
+        /** Flag indicating if thousands should be grouped. */
+        private final boolean groupThousands;
+
+        /** Minus sign character. */
+        private final char minusSign;
+
+        /** Exponent separator character. */
+        private final char[] exponentSeparatorChars;
+
+        /** Flag indicating if exponent values should always be included, even if zero. */
+        private final boolean alwaysIncludeExponent;
+
+        /** Construct a new instance.
+         * @param builder builder instance containing configuration values
+         */
+        AbstractDoubleFormat(final Builder builder) {
+            this.maxPrecision = builder.maxPrecision;
+            this.minDecimalExponent = builder.minDecimalExponent;
+
+            this.postiveInfinity = builder.infinity;
+            this.negativeInfinity = builder.minusSign + builder.infinity;
+            this.nan = builder.nan;
+
+            this.fractionPlaceholder = builder.fractionPlaceholder;
+            this.signedZero = builder.signedZero;
+            this.digits = builder.digits.toCharArray();
+            this.decimalSeparator = builder.decimalSeparator;
+            this.groupingSeparator = builder.groupingSeparator;
+            this.groupThousands = builder.groupThousands;
+            this.minusSign = builder.minusSign;
+            this.exponentSeparatorChars = builder.exponentSeparator.toCharArray();
+            this.alwaysIncludeExponent = builder.alwaysIncludeExponent;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean getIncludeFractionPlaceholder() {
+            return fractionPlaceholder;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean getSignedZero() {
+            return signedZero;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public char[] getDigits() {
+            return digits;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public char getDecimalSeparator() {
+            return decimalSeparator;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public char getGroupingSeparator() {
+            return groupingSeparator;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean getGroupThousands() {

Review comment:
       I've updated to use the `is` convention.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r664675463



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,715 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int resultExponent = digitCount + exponent - decimalPos;
+
+        // only include the exponent string if it is non-zero or it is requested to be present
+        final char[] resultExponentChars = resultExponent != 0 || opts.getAlwaysIncludeExponent()
+                ? Integer.toString(Math.abs(resultExponent)).toCharArray()
+                : null;
+
+        prepareOutput(getScientificStringSize(decimalPos, resultExponent, resultExponentChars, opts));
+
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (resultExponentChars != null) {
+            append(opts.getExponentSeparatorChars());
+
+            if (resultExponent < 0) {
+                append(opts.getMinusSign());
+            }
+
+            final char[] localizedDigits = opts.getDigits();
+            for (int i = 0; i < resultExponentChars.length; ++i) {
+                final int val = digitValue(resultExponentChars[i]);
+                appendLocalizedDigit(val, localizedDigits);
+            }
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return true if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index
+     * @param opts format options
+     */
+    private void appendFraction(final int zeroCount, final int startIdx, final FormatOptions opts) {
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        if (startIdx < digitCount) {
+            append(opts.getDecimalSeparator());
+
+            // add the zero prefix
+            for (int i = 0; i < zeroCount; ++i) {
+                append(localizedZero);
+            }
+
+            // add the fraction digits
+            for (int i = startIdx; i < digitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+        } else if (opts.getIncludeFractionPlaceholder()) {
+            append(opts.getDecimalSeparator());
+            append(localizedZero);
+        }
+    }
+
+    /** Get the number of characters required to create a plain format representation
+     * of this value.
+     * @param decimalPos decimal position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters in the plain string representation of this value,
+     *      created using the given parameters
+     */
+    private int getPlainStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+
+        // adjust for groupings if needed
+        if (opts.getGroupThousands() && decimalPos > 0) {
+            size += (decimalPos - 1) / THOUSANDS_GROUP_SIZE;
+        }
+
+        return size;
+    }
+
+    /** Get the number of characters required to create a scientific format representation
+     * of this value.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param resultExponent target exponent
+     * @param resultExponentChars target exponent characters; if null, no explicit exponent is to be included
+     * @param opts format options
+     * @return number of characters in the scientific representation of this string, using the
+     *      given parameters
+     */
+    private int getScientificStringSize(final int decimalPos, final int resultExponent,
+            final char[] resultExponentChars, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+        if (resultExponentChars != null) {
+            size += opts.getExponentSeparatorChars().length + resultExponentChars.length;
+            if (resultExponent < 0) {
+                // adjust for the exponent minus sign
+                ++size;
+            }
+        }
+        return size;
+    }
+
+    /** Get the number of characters required for the digit portion of a string representation of
+     * this value. This excludes any exponent or thousands groupings characters.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters required for the digit portion of a string representation of
+     *      this value
+     */
+    private int getDigitStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = digitCount;
+        if (shouldIncludeMinus(opts)) {
+            ++size;
+        }
+        if (decimalPos < 1) {
+            // no whole component;
+            // add decimal point and leading zeros
+            size += 2 + Math.abs(decimalPos);
+        } else if (decimalPos >= digitCount) {
+            // no fraction component;
+            // add trailing zeros
+            size += decimalPos - digitCount;
+            if (opts.getIncludeFractionPlaceholder()) {
+                size += 2;
+            }
+        } else {
+            // whole and fraction components;
+            // add decimal point
+            size += 1;
+        }
+
+        return size;
+    }
+
+
+    /** Return true if formatted strings should include the minus sign, considering
+     * the value of this instance and the given format options.
+     * @param opts format options
+     * @return true if a minus sign should be included in the output
+     */
+    private boolean shouldIncludeMinus(final FormatOptions opts) {
+        return negative && (opts.getSignedZero() || !isZero());
+    }
+
+    /** Return true if a rounding operation for the given number of digits should
+     * round up.
+     * @param count number of digits to round to; must be greater than zero and less
+     *      than the current number of digits
+     * @return true if a rounding operation for the given number of digits should
+     *      round up
+     */
+    private boolean shouldRoundUp(final int count) {
+        // Round up in the following cases:
+        // 1. The digit after the last digit is greater than 5.
+        // 2. The digit after the last digit is 5 and there are additional (non-zero)
+        //      digits after it.
+        // 3. The digit after the last digit is 5, there are no additional digits afterward,
+        //      and the last digit is odd (half-even rounding).
+        final int digitAfterLast = digits[count];
+
+        return digitAfterLast > ROUND_CENTER || (digitAfterLast == ROUND_CENTER
+                && (count < digitCount - 1 || (digits[count - 1] % 2) != 0));
+    }
+
+    /** Round the value up to the given number of digits.
+     * @param count target number of digits; must be greater than zero and
+     *      less than the current number of digits
+     */
+    private void roundUp(final int count) {
+        int removedDigits = digitCount - count;
+        int i;
+        for (i = count - 1; i >= 0; --i) {
+            final int d = digits[i] + 1;
+
+            if (d < DECIMAL_RADIX) {
+                // value did not carry over; done adding
+                digits[i] = d;
+                break;
+            } else {
+                // value carried over; the current position is 0
+                // which we will ignore by shortening the digit count
+                ++removedDigits;
+            }
+        }
+
+        if (i < 0) {
+            // all values carried over
+            setSingleDigitValue(1, exponent + removedDigits);
+        } else {
+            // values were updated in-place; just need to update the length
+            truncate(digitCount - removedDigits);
+        }
+    }
+
+    /** Return true if this value is equal to zero. The sign field is ignored,
+     * meaning that this method will return true for both {@code +0} and {@code -0}.
+     * @return true if the value is equal to zero
+     */
+    boolean isZero() {
+        return digits[0] == 0;
+    }
+
+    /** Set the value of this instance to a single digit with the given exponent.
+     * The sign of the value is retained.
+     * @param digit digit value
+     * @param newExponent new exponent value
+     */
+    private void setSingleDigitValue(final int digit, final int newExponent) {
+        digits[0] = digit;
+        digitCount = 1;
+        exponent = newExponent;
+    }
+
+    /** Truncate the value to the given number of digits.
+     * @param count number of digits; must be greater than zero and less than
+     *      the current number of digits
+     */
+    private void truncate(final int count) {
+        int nonZeroCount = count;
+        for (int i = count - 1;
+                i >= 0 && digits[i] == 0;
+                --i) {
+            --nonZeroCount;
+        }
+        exponent += digitCount - nonZeroCount;
+        digitCount = nonZeroCount;
+    }
+
+    /** Construct a new instance from the given double value.
+     * @param d double value
+     * @return a new instance containing the parsed components of the given double value
+     * @throws IllegalArgumentException if {@code d} is {@code NaN} or infinite
+     */
+    public static ParsedDecimal from(final double d) {
+        if (!Double.isFinite(d)) {
+            throw new IllegalArgumentException("Double is not finite");
+        }
+
+        // Get the canonical string representation of the double value and parse
+        // it to extract the components of the decimal value. From the documentation
+        // of Double.toString() and the fact that d is finite, we are guaranteed the
+        // following:
+        // - the string will not be empty
+        // - it will contain exactly one decimal point character
+        // - all digit characters are in the ASCII range
+        final char[] strChars = Double.toString(d).toCharArray();
+
+        final boolean negative = strChars[0] == MINUS_CHAR;
+        final int digitStartIdx = negative ? 1 : 0;
+
+        final int[] digits = new int[strChars.length];
+
+        boolean foundDecimalPoint = false;
+        int digitCount = 0;
+        int significantDigitCount = 0;
+        int decimalPos = 0;
+
+        int i;
+        for (i = digitStartIdx; i < strChars.length; ++i) {
+            final char ch = strChars[i];
+
+            if (ch == DECIMAL_SEP_CHAR) {
+                foundDecimalPoint = true;
+                decimalPos = digitCount;
+            } else if (ch == EXPONENT_CHAR) {
+                // no more mantissa digits
+                break;
+            } else if (ch != ZERO_CHAR || digitCount > 0) {
+                // this is either the first non-zero digit or one after it
+                final int val = digitValue(ch);
+                digits[digitCount++] = val;
+
+                if (val > 0) {
+                    significantDigitCount = digitCount;
+                }
+            } else if (foundDecimalPoint) {
+                // leading zero in a fraction; adjust the decimal position
+                --decimalPos;
+            }
+        }
+
+        if (digitCount > 0) {
+            // determine the exponent
+            final int explicitExponent = i < strChars.length
+                    ? parseExponent(strChars, i + 1)
+                    : 0;
+            final int exponent = explicitExponent + decimalPos - significantDigitCount;
+
+            return new ParsedDecimal(negative, digits, significantDigitCount, exponent);
+        }
+
+        // no non-zero digits, so value is zero
+        return new ParsedDecimal(negative, new int[] {0}, 1, 0);

Review comment:
       All of the examples you gave there will be interpreted as zero. Also, no additional rounding is possible when the instance is zero. Regardless, it still feels weird to have singletons of mutable instances. Is this what you're proposing here?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] coveralls edited a comment on pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
coveralls edited a comment on pull request #248:
URL: https://github.com/apache/commons-text/pull/248#issuecomment-875718096


   
   [![Coverage Status](https://coveralls.io/builds/41445889/badge)](https://coveralls.io/builds/41445889)
   
   Coverage increased (+0.1%) to 98.077% when pulling **a1ca0eb84164db4836d9975ba46c4668de6dd289 on darkma773r:text-207-double-format** into **23a17acd1fa408b41988866e3df480e9c89b4903 on apache:master**.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] aherbert commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
aherbert commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r664655170



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,715 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return true if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return true if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return true if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return true if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}
+     * string.
+     * @return exponent value
+     */
+    public int getExponent() {
+        return exponent;
+    }
+
+    /** Get the exponent that would be used when representing this number in scientific
+     * notation (i.e., with a single non-zero digit in front of the decimal point.
+     * @return the exponent that would be used when representing this number in scientific
+     *      notation
+     */
+    public int getScientificExponent() {
+        return digitCount + exponent - 1;
+    }
+
+    /** Round the instance to the given decimal exponent position using
+     * {@link java.math.RoundingMode#HALF_EVEN half-even rounding}. For example, a value of {@code -2}
+     * will round the instance to the digit at the position 10<sup>-2</sup> (i.e. to the closest multiple of 0.01).
+     * @param roundExponent exponent defining the decimal place to round to
+     */
+    public void round(final int roundExponent) {
+        if (roundExponent > exponent) {
+            final int max = digitCount + exponent;
+
+            if (roundExponent < max) {
+                // rounding to a decimal place less than the max; set max precision
+                maxPrecision(max - roundExponent);
+            } else if (roundExponent == max && shouldRoundUp(0)) {
+                // rounding up directly on the max decimal place
+                setSingleDigitValue(1, roundExponent);
+            } else {
+                // change to zero
+                setSingleDigitValue(0, 0);
+            }
+        }
+    }
+
+    /** Ensure that this instance has <em>at most</em> the given number of significant digits
+     * (i.e. precision). If this instance already has a precision less than or equal
+     * to the argument, nothing is done. If the given precision requires a reduction in the number
+     * of digits, then the value is rounded using {@link java.math.RoundingMode#HALF_EVEN half-even rounding}.
+     * @param precision maximum number of significant digits to include
+     */
+    public void maxPrecision(final int precision) {
+        if (precision > 0 && precision < digitCount) {
+            if (shouldRoundUp(precision)) {
+                roundUp(precision);
+            } else {
+                truncate(precision);
+            }
+        }
+    }
+
+    /** Return a string representation of this value with no exponent field. Ex:
+     * <pre>
+     * 10 = "10.0"
+     * 1e-6 = "0.000001"
+     * 1e11 = "100000000000.0"
+     * </pre>
+     * @param opts format options
+     * @return value in plain format
+     */
+    public String toPlainString(final FormatOptions opts) {
+        final int decimalPos = digitCount + exponent;
+        final int fractionZeroCount = decimalPos < 1
+                ? Math.abs(decimalPos)
+                : 0;
+
+        prepareOutput(getPlainStringSize(decimalPos, opts));
+
+        final int fractionStartIdx = opts.getGroupThousands()
+                ? appendWholeGrouped(decimalPos, opts)
+                : appendWhole(decimalPos, opts);
+
+        appendFraction(fractionZeroCount, fractionStartIdx, opts);
+
+        return outputString();
+    }
+
+    /** Return a string representation of this value in scientific notation. Ex:
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "1.0E1"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "1.0E11"
+     * </pre>
+     * @param opts format options
+     * @return value in scientific format
+     */
+    public String toScientificString(final FormatOptions opts) {
+        return toScientificString(1, opts);
+    }
+
+    /** Return a string representation of this value in engineering notation. This
+     * is similar to {@link #toScientificString(FormatOptions) scientific notation}
+     * but with the exponent forced to be a multiple of 3, allowing easier alignment with SI prefixes.
+     * <pre>
+     * 0 = "0.0"
+     * 10 = "10.0"
+     * 1e-6 = "1.0E-6"
+     * 1e11 = "100.0E9"
+     * </pre>
+     * @param opts format options
+     * @return value in engineering format
+     */
+    public String toEngineeringString(final FormatOptions opts) {
+        final int decimalPos = 1 + Math.floorMod(getScientificExponent(), ENG_EXPONENT_MOD);
+        return toScientificString(decimalPos, opts);
+    }
+
+    /** Return a string representation of the value in scientific notation using the
+     * given decimal point position.
+     * @param decimalPos decimal position relative to the {@code digits} array; this value
+     *      is expected to be greater than 0
+     * @param opts format options
+     * @return value in scientific format
+     */
+    private String toScientificString(final int decimalPos, final FormatOptions opts) {
+        final int resultExponent = digitCount + exponent - decimalPos;
+
+        // only include the exponent string if it is non-zero or it is requested to be present
+        final char[] resultExponentChars = resultExponent != 0 || opts.getAlwaysIncludeExponent()
+                ? Integer.toString(Math.abs(resultExponent)).toCharArray()
+                : null;
+
+        prepareOutput(getScientificStringSize(decimalPos, resultExponent, resultExponentChars, opts));
+
+        final int fractionStartIdx = appendWhole(decimalPos, opts);
+        appendFraction(0, fractionStartIdx, opts);
+
+        if (resultExponentChars != null) {
+            append(opts.getExponentSeparatorChars());
+
+            if (resultExponent < 0) {
+                append(opts.getMinusSign());
+            }
+
+            final char[] localizedDigits = opts.getDigits();
+            for (int i = 0; i < resultExponentChars.length; ++i) {
+                final int val = digitValue(resultExponentChars[i]);
+                appendLocalizedDigit(val, localizedDigits);
+            }
+        }
+
+        return outputString();
+    }
+
+    /** Prepare the output buffer for a string of the given size.
+     * @param size buffer size
+     */
+    private void prepareOutput(final int size) {
+        outputChars = new char[size];
+        outputIdx = 0;
+    }
+
+    /** Get the output buffer as a string.
+     * @return output buffer as a string
+     */
+    private String outputString() {
+        final String str = String.valueOf(outputChars);
+        outputChars = null;
+        return str;
+    }
+
+    /** Append the given character to the output buffer.
+     * @param ch character to append
+     */
+    private void append(final char ch) {
+        outputChars[outputIdx++] = ch;
+    }
+
+    /** Append the given character array directly to the output buffer.
+     * @param chars characters to append
+     */
+    private void append(final char[] chars) {
+        for (int i = 0; i < chars.length; ++i) {
+            append(chars[i]);
+        }
+    }
+
+    /** Append the localized representation of the digit {@code n} to the output buffer.
+     * @param n digit to append
+     * @param digitChars character array containing localized versions of the digits {@code 0-9}
+     *      in that order
+     */
+    private void appendLocalizedDigit(final int n, final char[] digitChars) {
+        append(digitChars[n]);
+    }
+
+    /** Append the whole number portion of this value to the output buffer. No thousands
+     * separators are added.
+     * @param wholeCount total number of digits required to the left of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWholeGrouped(int, FormatOptions)
+     */
+    private int appendWhole(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        final int significantDigitCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (significantDigitCount > 0) {
+            int i;
+            for (i = 0; i < significantDigitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+
+            for (; i < wholeCount; ++i) {
+                append(localizedZero);
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return significantDigitCount;
+    }
+
+    /** Append the whole number portion of this value to the output buffer, adding thousands
+     * separators as needed.
+     * @param wholeCount total number of digits required to the right of the decimal point
+     * @param opts format options
+     * @return number of digits from {@code digits} appended to the output buffer
+     * @see #appendWhole(int, FormatOptions)
+     */
+    private int appendWholeGrouped(final int wholeCount, final FormatOptions opts) {
+        if (shouldIncludeMinus(opts)) {
+            append(opts.getMinusSign());
+        }
+
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+        final char groupingChar = opts.getGroupingSeparator();
+
+        final int appendCount = Math.max(0, Math.min(wholeCount, digitCount));
+
+        if (appendCount > 0) {
+            int i;
+            int pos = wholeCount;
+            for (i = 0; i < appendCount; ++i, --pos) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+
+            for (; i < wholeCount; ++i, --pos) {
+                append(localizedZero);
+                if (requiresGroupingSeparatorAfterPosition(pos)) {
+                    append(groupingChar);
+                }
+            }
+        } else {
+            append(localizedZero);
+        }
+
+        return appendCount;
+    }
+
+    /** Return true if a grouping separator should be added after the whole digit
+     * character at the given position.
+     * @param pos whole digit character position, with values starting at 1 and increasing
+     *      from right to left.
+     * @return true if a grouping separator should be added
+     */
+    private boolean requiresGroupingSeparatorAfterPosition(final int pos) {
+        return pos > 1 && (pos % THOUSANDS_GROUP_SIZE) == 1;
+    }
+
+    /** Append the fractional component of the number to the current output buffer.
+     * @param zeroCount number of zeros to add after the decimal point and before the
+     *      first significant digit
+     * @param fractionStartIdx significant digit start index
+     * @param opts format options
+     */
+    private void appendFraction(final int zeroCount, final int startIdx, final FormatOptions opts) {
+        final char[] localizedDigits = opts.getDigits();
+        final char localizedZero = localizedDigits[0];
+
+        if (startIdx < digitCount) {
+            append(opts.getDecimalSeparator());
+
+            // add the zero prefix
+            for (int i = 0; i < zeroCount; ++i) {
+                append(localizedZero);
+            }
+
+            // add the fraction digits
+            for (int i = startIdx; i < digitCount; ++i) {
+                appendLocalizedDigit(digits[i], localizedDigits);
+            }
+        } else if (opts.getIncludeFractionPlaceholder()) {
+            append(opts.getDecimalSeparator());
+            append(localizedZero);
+        }
+    }
+
+    /** Get the number of characters required to create a plain format representation
+     * of this value.
+     * @param decimalPos decimal position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters in the plain string representation of this value,
+     *      created using the given parameters
+     */
+    private int getPlainStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+
+        // adjust for groupings if needed
+        if (opts.getGroupThousands() && decimalPos > 0) {
+            size += (decimalPos - 1) / THOUSANDS_GROUP_SIZE;
+        }
+
+        return size;
+    }
+
+    /** Get the number of characters required to create a scientific format representation
+     * of this value.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param resultExponent target exponent
+     * @param resultExponentChars target exponent characters; if null, no explicit exponent is to be included
+     * @param opts format options
+     * @return number of characters in the scientific representation of this string, using the
+     *      given parameters
+     */
+    private int getScientificStringSize(final int decimalPos, final int resultExponent,
+            final char[] resultExponentChars, final FormatOptions opts) {
+        int size = getDigitStringSize(decimalPos, opts);
+        if (resultExponentChars != null) {
+            size += opts.getExponentSeparatorChars().length + resultExponentChars.length;
+            if (resultExponent < 0) {
+                // adjust for the exponent minus sign
+                ++size;
+            }
+        }
+        return size;
+    }
+
+    /** Get the number of characters required for the digit portion of a string representation of
+     * this value. This excludes any exponent or thousands groupings characters.
+     * @param decimalPos decimal point position relative to the {@code digits} array
+     * @param opts format options
+     * @return number of characters required for the digit portion of a string representation of
+     *      this value
+     */
+    private int getDigitStringSize(final int decimalPos, final FormatOptions opts) {
+        int size = digitCount;
+        if (shouldIncludeMinus(opts)) {
+            ++size;
+        }
+        if (decimalPos < 1) {
+            // no whole component;
+            // add decimal point and leading zeros
+            size += 2 + Math.abs(decimalPos);
+        } else if (decimalPos >= digitCount) {
+            // no fraction component;
+            // add trailing zeros
+            size += decimalPos - digitCount;
+            if (opts.getIncludeFractionPlaceholder()) {
+                size += 2;
+            }
+        } else {
+            // whole and fraction components;
+            // add decimal point
+            size += 1;
+        }
+
+        return size;
+    }
+
+
+    /** Return true if formatted strings should include the minus sign, considering
+     * the value of this instance and the given format options.
+     * @param opts format options
+     * @return true if a minus sign should be included in the output
+     */
+    private boolean shouldIncludeMinus(final FormatOptions opts) {
+        return negative && (opts.getSignedZero() || !isZero());
+    }
+
+    /** Return true if a rounding operation for the given number of digits should
+     * round up.
+     * @param count number of digits to round to; must be greater than zero and less
+     *      than the current number of digits
+     * @return true if a rounding operation for the given number of digits should
+     *      round up
+     */
+    private boolean shouldRoundUp(final int count) {
+        // Round up in the following cases:
+        // 1. The digit after the last digit is greater than 5.
+        // 2. The digit after the last digit is 5 and there are additional (non-zero)
+        //      digits after it.
+        // 3. The digit after the last digit is 5, there are no additional digits afterward,
+        //      and the last digit is odd (half-even rounding).
+        final int digitAfterLast = digits[count];
+
+        return digitAfterLast > ROUND_CENTER || (digitAfterLast == ROUND_CENTER
+                && (count < digitCount - 1 || (digits[count - 1] % 2) != 0));
+    }
+
+    /** Round the value up to the given number of digits.
+     * @param count target number of digits; must be greater than zero and
+     *      less than the current number of digits
+     */
+    private void roundUp(final int count) {
+        int removedDigits = digitCount - count;
+        int i;
+        for (i = count - 1; i >= 0; --i) {
+            final int d = digits[i] + 1;
+
+            if (d < DECIMAL_RADIX) {
+                // value did not carry over; done adding
+                digits[i] = d;
+                break;
+            } else {
+                // value carried over; the current position is 0
+                // which we will ignore by shortening the digit count
+                ++removedDigits;
+            }
+        }
+
+        if (i < 0) {
+            // all values carried over
+            setSingleDigitValue(1, exponent + removedDigits);
+        } else {
+            // values were updated in-place; just need to update the length
+            truncate(digitCount - removedDigits);
+        }
+    }
+
+    /** Return true if this value is equal to zero. The sign field is ignored,
+     * meaning that this method will return true for both {@code +0} and {@code -0}.
+     * @return true if the value is equal to zero
+     */
+    boolean isZero() {
+        return digits[0] == 0;
+    }
+
+    /** Set the value of this instance to a single digit with the given exponent.
+     * The sign of the value is retained.
+     * @param digit digit value
+     * @param newExponent new exponent value
+     */
+    private void setSingleDigitValue(final int digit, final int newExponent) {
+        digits[0] = digit;
+        digitCount = 1;
+        exponent = newExponent;
+    }
+
+    /** Truncate the value to the given number of digits.
+     * @param count number of digits; must be greater than zero and less than
+     *      the current number of digits
+     */
+    private void truncate(final int count) {
+        int nonZeroCount = count;
+        for (int i = count - 1;
+                i >= 0 && digits[i] == 0;
+                --i) {
+            --nonZeroCount;
+        }
+        exponent += digitCount - nonZeroCount;
+        digitCount = nonZeroCount;
+    }
+
+    /** Construct a new instance from the given double value.
+     * @param d double value
+     * @return a new instance containing the parsed components of the given double value
+     * @throws IllegalArgumentException if {@code d} is {@code NaN} or infinite
+     */
+    public static ParsedDecimal from(final double d) {
+        if (!Double.isFinite(d)) {
+            throw new IllegalArgumentException("Double is not finite");
+        }
+
+        // Get the canonical string representation of the double value and parse
+        // it to extract the components of the decimal value. From the documentation
+        // of Double.toString() and the fact that d is finite, we are guaranteed the
+        // following:
+        // - the string will not be empty
+        // - it will contain exactly one decimal point character
+        // - all digit characters are in the ASCII range
+        final char[] strChars = Double.toString(d).toCharArray();
+
+        final boolean negative = strChars[0] == MINUS_CHAR;
+        final int digitStartIdx = negative ? 1 : 0;
+
+        final int[] digits = new int[strChars.length];
+
+        boolean foundDecimalPoint = false;
+        int digitCount = 0;
+        int significantDigitCount = 0;
+        int decimalPos = 0;
+
+        int i;
+        for (i = digitStartIdx; i < strChars.length; ++i) {
+            final char ch = strChars[i];
+
+            if (ch == DECIMAL_SEP_CHAR) {
+                foundDecimalPoint = true;
+                decimalPos = digitCount;
+            } else if (ch == EXPONENT_CHAR) {
+                // no more mantissa digits
+                break;
+            } else if (ch != ZERO_CHAR || digitCount > 0) {
+                // this is either the first non-zero digit or one after it
+                final int val = digitValue(ch);
+                digits[digitCount++] = val;
+
+                if (val > 0) {
+                    significantDigitCount = digitCount;
+                }
+            } else if (foundDecimalPoint) {
+                // leading zero in a fraction; adjust the decimal position
+                --decimalPos;
+            }
+        }
+
+        if (digitCount > 0) {
+            // determine the exponent
+            final int explicitExponent = i < strChars.length
+                    ? parseExponent(strChars, i + 1)
+                    : 0;
+            final int exponent = explicitExponent + decimalPos - significantDigitCount;
+
+            return new ParsedDecimal(negative, digits, significantDigitCount, exponent);
+        }
+
+        // no non-zero digits, so value is zero
+        return new ParsedDecimal(negative, new int[] {0}, 1, 0);

Review comment:
       Is this not zero (i.e. no non-zero digits)? Does the output differ when formatting parameters are changed for the value zero beyond putting extra zeros on:
   ```
   0.0
   0.00
   0.000
   0.0000e0
   0.00000e000
   ```
   Is any rounding possible when the value is zero?
   
   You could have a singleton for negative and positive zero as this is something supported by a double.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r671690929



##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;
+
+        /** Construct a new instance that delegates double function construction
+         * to the given factory object.
+         * @param factory factory function
+         */
+        private Builder(final Function<Builder, DoubleFunction<String>> factory) {
+            this.factory = factory;
+        }
+
+        /** Set the maximum number of significant decimal digits used in format
+         * results. A value of {@code 0} indicates no limit. The default value is {@code 0}.
+         * @param maxPrecision maximum precision
+         * @return this instance
+         */
+        public Builder maxPrecision(final int maxPrecision) {
+            this.maxPrecision = maxPrecision;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for formatted strings. No digits with an
+         * absolute value of less than <code>10<sup>minDecimalExponent</sup></code> will
+         * be included in format results. If the number being formatted does not contain
+         * any such digits, then zero is returned. For example, if {@code minDecimalExponent}
+         * is set to {@code -2} and the number {@code 3.14159} is formatted, the plain
+         * format result will be {@code "3.14"}. If {@code 0.001} is formatted, then the
+         * result is the zero string.
+         * @param minDecimalExponent minimum decimal exponent
+         * @return this instance
+         */
+        public Builder minDecimalExponent(final int minDecimalExponent) {
+            this.minDecimalExponent = minDecimalExponent;
+            return this;
+        }
+
+        /** Set the maximum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code 2}, the number {@code 999} will be formatted as {@code "999.0"}
+         * while {@code 1000} will be formatted as {@code "1.0E3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMaxDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMaxDecimalExponent(final int plainFormatMaxDecimalExponent) {
+            this.plainFormatMaxDecimalExponent = plainFormatMaxDecimalExponent;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code -2}, the number {@code 0.01} will be formatted as {@code "0.01"}
+         * while {@code 0.0099} will be formatted as {@code "9.9E-3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMinDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMinDecimalExponent(final int plainFormatMinDecimalExponent) {
+            this.plainFormatMinDecimalExponent = plainFormatMinDecimalExponent;
+            return this;
+        }
+
+        /** Set the flag determining whether or not the zero string may be returned with the minus
+         * sign or if it will always be returned in the positive form. For example, if set to true,
+         * the string {@code "-0.0"} may be returned for some input numbers. If false, only {@code "0.0"}
+         * will be returned, regardless of the sign of the input number. The default value is {@code true}.
+         * @param signedZero if true, the zero string may be returned with a preceding minus sign; if false,
+         *      the zero string will only be returned in its positive form
+         * @return this instance
+         */
+        public Builder allowSignedZero(final boolean signedZero) {
+            this.signedZero = signedZero;
+            return this;
+        }
+
+        /** Set the string containing the digit characters 0-9, in that order. The
+         * default value is the string {@code "0123456789"}.
+         * @param digits string containing the digit characters 0-9
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         * @throws IllegalArgumentException if the argument does not have a length of exactly 10
+         */
+        public Builder digits(final String digits) {
+            Objects.requireNonNull(digits, "Digits string cannot be null");
+            if (digits.length() != DEFAULT_DECIMAL_DIGITS.length()) {
+                throw new IllegalArgumentException("Digits string must contain exactly "
+                        + DEFAULT_DECIMAL_DIGITS.length() + " characters.");
+            }
+
+            this.digits = digits;
+            return this;
+        }
+
+        /** Set the flag determining whether or not a zero character is added in the fraction position
+         * when no fractional value is present. For example, if set to true, the number {@code 1} would
+         * be formatted as {@code "1.0"}. If false, it would be formatted as {@code "1"}. The default
+         * value is {@code true}.
+         * @param fractionPlaceholder if true, a zero character is placed in the fraction position when
+         *      no fractional value is present; if false, fractional digits are only included when needed
+         * @return this instance
+         */
+        public Builder includeFractionPlaceholder(final boolean fractionPlaceholder) {
+            this.fractionPlaceholder = fractionPlaceholder;
+            return this;
+        }
+
+        /** Set the character used as the minus sign.
+         * @param minusSign character to use as the minus sign
+         * @return this instance
+         */
+        public Builder minusSign(final char minusSign) {
+            this.minusSign = minusSign;
+            return this;
+        }
+
+        /** Set the decimal separator character, i.e., the character placed between the
+         * whole number and fractional portions of the formatted strings. The default value
+         * is {@code '.'}.
+         * @param decimalSeparator decimal separator character
+         * @return this instance
+         */
+        public Builder decimalSeparator(final char decimalSeparator) {
+            this.decimalSeparator = decimalSeparator;
+            return this;
+        }
+
+        /** Set the character used to separate groups of thousands. Default value is {@code ','}.
+         * @param groupingSeparator character used to separate groups of thousands
+         * @return this instance
+         * @see #groupThousands(boolean)
+         */
+        public Builder groupingSeparator(final char groupingSeparator) {
+            this.groupingSeparator = groupingSeparator;
+            return this;
+        }
+
+        /** If set to true, thousands will be grouped with the
+         * {@link #groupingSeparator(char) grouping separator}. For example, if set to true,
+         * the number {@code 1000} could be formatted as {@code "1,000"}. This property only applies
+         * to the {@link DoubleFormat#PLAIN PLAIN} format. Default value is {@code false}.
+         * @param groupThousands if true, thousands will be grouped
+         * @return this instance
+         * @see #groupingSeparator(char)
+         */
+        public Builder groupThousands(final boolean groupThousands) {
+            this.groupThousands = groupThousands;
+            return this;
+        }
+
+        /** Set the exponent separator character, i.e., the string placed between
+         * the mantissa and the exponent. The default value is {@code "E"}, as in
+         * {@code "1.2E6"}.
+         * @param exponentSeparator exponent separator string
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder exponentSeparator(final String exponentSeparator) {
+            Objects.requireNonNull(exponentSeparator, "Exponent separator cannot be null");
+
+            this.exponentSeparator = exponentSeparator;
+            return this;
+        }
+
+        /** Set the flag indicating if an exponent value should always be included in the
+         * formatted value, even if the exponent value is zero. This property only applies
+         * to formats that use scientific notation, namely
+         * {@link DoubleFormat#SCIENTIFIC SCIENTIFIC},
+         * {@link DoubleFormat#ENGINEERING ENGINEERING}, and
+         * {@link DoubleFormat#MIXED MIXED}. The default value is {@code false}.
+         * @param alwaysIncludeExponent if true, exponents will always be included in formatted
+         *      output even if the exponent value is zero
+         * @return this instance
+         */
+        public Builder alwaysIncludeExponent(final boolean alwaysIncludeExponent) {
+            this.alwaysIncludeExponent = alwaysIncludeExponent;
+            return this;
+        }
+
+        /** Set the string used to represent infinity. For negative infinity, this string
+         * is prefixed with the {@link #minusSign(char) minus sign}.
+         * @param infinity string used to represent infinity
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder infinity(final String infinity) {
+            Objects.requireNonNull(infinity, "Infinity string cannot be null");
+
+            this.infinity = infinity;
+            return this;
+        }
+
+        /** Set the string used to represent {@link Double#NaN}.
+         * @param nan string used to represent {@link Double#NaN}
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder nan(final String nan) {
+            Objects.requireNonNull(nan, "NaN string cannot be null");
+
+            this.nan = nan;
+            return this;
+        }
+
+        /** Configure this instance with the given format symbols. The following values
+         * are set:
+         * <ul>
+         *  <li>{@link #digits(String) digit characters}</li>
+         *  <li>{@link #decimalSeparator(char) decimal separator}</li>
+         *  <li>{@link #groupingSeparator(char) thousands grouping separator}</li>
+         *  <li>{@link #minusSign(char) minus sign}</li>
+         *  <li>{@link #exponentSeparator(String) exponent separator}</li>
+         *  <li>{@link #infinity(String) infinity}</li>
+         *  <li>{@link #nan(String) NaN}</li>
+         * </ul>
+         * The digit character string is constructed by starting at the configured
+         * {@link DecimalFormatSymbols#getZeroDigit() zero digit} and adding the next
+         * 9 consecutive characters.
+         * @param symbols format symbols
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         */
+        public Builder formatSymbols(final DecimalFormatSymbols symbols) {
+            Objects.requireNonNull(symbols, "Decimal format symbols cannot be null");
+
+            return digits(getDigitString(symbols))
+                    .decimalSeparator(symbols.getDecimalSeparator())
+                    .groupingSeparator(symbols.getGroupingSeparator())
+                    .minusSign(symbols.getMinusSign())
+                    .exponentSeparator(symbols.getExponentSeparator())
+                    .infinity(symbols.getInfinity())
+                    .nan(symbols.getNaN());
+        }
+
+        /** Get a string containing the localized digits 0-9 for the given symbols object. The
+         * string is constructed by starting at the {@link DecimalFormatSymbols#getZeroDigit() zero digit}
+         * and adding the next 9 consecutive characters.
+         * @param symbols symbols object
+         * @return string containing the localized digits 0-9
+         */
+        private String getDigitString(final DecimalFormatSymbols symbols) {
+            final int zeroDelta = symbols.getZeroDigit() - DEFAULT_DECIMAL_DIGITS.charAt(0);
+
+            final char[] digitChars = new char[DEFAULT_DECIMAL_DIGITS.length()];
+            for (int i = 0; i < DEFAULT_DECIMAL_DIGITS.length(); ++i) {
+                digitChars[i] = (char) (DEFAULT_DECIMAL_DIGITS.charAt(i) + zeroDelta);
+            }
+
+            return String.valueOf(digitChars);
+        }
+
+        /** Construct a new double format function.
+         * @return format function
+         */
+        public DoubleFunction<String> build() {
+            return factory.apply(this);
+        }
+    }
+
+    /** Base class for standard double formatting classes.
+     */
+    private abstract static class AbstractDoubleFormat
+        implements DoubleFunction<String>, ParsedDecimal.FormatOptions {
+
+        /** Maximum precision; 0 indicates no limit. */
+        private final int maxPrecision;
+
+        /** Minimum decimal exponent. */
+        private final int minDecimalExponent;
+
+        /** String representing positive infinity. */
+        private final String postiveInfinity;
+
+        /** String representing negative infinity. */
+        private final String negativeInfinity;
+
+        /** String representing NaN. */
+        private final String nan;
+
+        /** Flag determining if fraction placeholders should be used. */
+        private final boolean fractionPlaceholder;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private final boolean signedZero;
+
+        /** String containing the digits 0-9. */
+        private final char[] digits;
+
+        /** Decimal separator character. */
+        private final char decimalSeparator;
+
+        /** Thousands grouping separator. */
+        private final char groupingSeparator;
+
+        /** Flag indicating if thousands should be grouped. */
+        private final boolean groupThousands;
+
+        /** Minus sign character. */
+        private final char minusSign;

Review comment:
       Good point. I think that we can leave this as-is since the code in your example is doing exactly what was requested, even though it's not a very good idea. The JDK also does not perform any validation on this front. For example, the code below produces the same output as your example.
   ```java
   DecimalFormatSymbols sym = new DecimalFormatSymbols();
   sym.setMinusSign('1');
   
   DecimalFormat fmt = new DecimalFormat("0.0##", sym);
   
   System.out.println(fmt.format(-1.3)); // gives 11.3
   ```




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r671600128



##########
File path: src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
##########
@@ -0,0 +1,724 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+/** Internal class representing a decimal value parsed into separate components. Each number
+ * is represented with
+ * <ul>
+ *  <li>a boolean flag for the sign,</li>
+ *  <li> a sequence of the digits {@code 0 - 10} representing an unsigned integer with leading and trailing zeros
+ *      removed, and</li>
+ *  <li>an exponent value that when applied to the base 10 digits produces a floating point value with the
+ *      correct magnitude.</li>
+ * </ul>
+ * <p><strong>Examples</strong></p>
+ * <table>
+ *  <tr><th>Double</th><th>Negative</th><th>Digits</th><th>Exponent</th></tr>
+ *  <tr><td>0.0</td><td>false</td><td>[0]</td><td>0</td></tr>
+ *  <tr><td>1.2</td><td>false</td><td>[1, 2]</td><td>-1</td></tr>
+ *  <tr><td>-0.00971</td><td>true</td><td>[9, 7, 1]</td><td>-5</td></tr>
+ *  <tr><td>56300</td><td>true</td><td>[5, 6, 3]</td><td>2</td></tr>
+ * </table>
+ */
+final class ParsedDecimal {
+
+    /** Interface containing values used during string formatting.
+     */
+    interface FormatOptions {
+
+        /** Return {@code true} if fraction placeholders (e.g., {@code ".0"} in {@code "1.0"})
+         * should be included.
+         * @return true if fraction placeholders should be included
+         */
+        boolean getIncludeFractionPlaceholder();
+
+        /** Return {@code true} if the string zero should be prefixed with the minus sign
+         * for negative zero values.
+         * @return true if the minus zero string should be allowed
+         */
+        boolean getSignedZero();
+
+        /** Get an array containing the localized digit characters 0-9 in that order.
+         * This string <em>must</em> be non-null and have a length of 10.
+         * @return array containing the digit characters 0-9
+         */
+        char[] getDigits();
+
+        /** Get the decimal separator character.
+         * @return decimal separator character
+         */
+        char getDecimalSeparator();
+
+        /** Get the character used to separate thousands groupings.
+         * @return character used to separate thousands groupings
+         */
+        char getGroupingSeparator();
+
+        /** Return {@code true} if thousands should be grouped.
+         * @return true if thousand should be grouped
+         */
+        boolean getGroupThousands();
+
+        /** Get the minus sign character.
+         * @return minus sign character
+         */
+        char getMinusSign();
+
+        /** Get the exponent separator as an array of characters.
+         * @return exponent separator as an array of characters
+         */
+        char[] getExponentSeparatorChars();
+
+        /** Return {@code true} if exponent values should always be included in
+         * formatted output, even if the value is zero.
+         * @return true if exponent values should always be included
+         */
+        boolean getAlwaysIncludeExponent();
+    }
+
+    /** Minus sign character. */
+    private static final char MINUS_CHAR = '-';
+
+    /** Decimal separator character. */
+    private static final char DECIMAL_SEP_CHAR = '.';
+
+    /** Exponent character. */
+    private static final char EXPONENT_CHAR = 'E';
+
+    /** Zero digit character. */
+    private static final char ZERO_CHAR = '0';
+
+    /** Number of characters in thousands groupings. */
+    private static final int THOUSANDS_GROUP_SIZE = 3;
+
+    /** Radix for decimal numbers. */
+    private static final int DECIMAL_RADIX = 10;
+
+    /** Center value used when rounding. */
+    private static final int ROUND_CENTER = DECIMAL_RADIX / 2;
+
+    /** Number that exponents in engineering format must be a multiple of. */
+    private static final int ENG_EXPONENT_MOD = 3;
+
+    /** True if the value is negative. */
+    final boolean negative;
+
+    /** Array containing the significant decimal digits for the value. */
+    final int[] digits;
+
+    /** Number of digits used in the digits array; not necessarily equal to the length. */
+    int digitCount;
+
+    /** Exponent for the value. */
+    int exponent;
+
+    /** Output buffer for use in creating string representations. */
+    private char[] outputChars;
+
+    /** Output buffer index. */
+    private int outputIdx;
+
+    /** Construct a new instance from its parts.
+     * @param negative true if the value is negative
+     * @param digits array containing significant digits
+     * @param digitCount number of digits used from the {@code digits} array
+     * @param exponent exponent value
+     */
+    ParsedDecimal(final boolean negative, final int[] digits, final int digitCount,
+            final int exponent) {
+        this.negative = negative;
+        this.digits = digits;
+        this.digitCount = digitCount;
+        this.exponent = exponent;
+    }
+
+    /** Get the exponent value. This exponent produces a floating point value with the
+     * correct magnitude when applied to the unsigned integer represented by the {@link #getDigits() digit}

Review comment:
       This is a reference to an old method that I removed. I'll remove the reference.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] coveralls edited a comment on pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
coveralls edited a comment on pull request #248:
URL: https://github.com/apache/commons-text/pull/248#issuecomment-875718096


   
   [![Coverage Status](https://coveralls.io/builds/41447750/badge)](https://coveralls.io/builds/41447750)
   
   Coverage increased (+0.1%) to 98.077% when pulling **3370a2816069031cc6d00aa4c0b34ae7373557ef on darkma773r:text-207-double-format** into **23a17acd1fa408b41988866e3df480e9c89b4903 on apache:master**.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] asfgit merged pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
asfgit merged pull request #248:
URL: https://github.com/apache/commons-text/pull/248


   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-text] darkma773r commented on a change in pull request #248: TEXT-207: adding DoubleFormat utility

Posted by GitBox <gi...@apache.org>.
darkma773r commented on a change in pull request #248:
URL: https://github.com/apache/commons-text/pull/248#discussion_r671600875



##########
File path: src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
##########
@@ -0,0 +1,736 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.text.numbers;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Objects;
+import java.util.function.DoubleFunction;
+import java.util.function.Function;
+
+/** Enum containing standard double format types with methods to produce
+ * configured formatter instances. This type is intended to provide a
+ * quick and convenient way to create lightweight, thread-safe double format functions
+ * for common format types using a builder pattern. Output can be localized by
+ * passing a {@link DecimalFormatSymbols} instance to the
+ * {@link Builder#formatSymbols(DecimalFormatSymbols) formatSymbols} method or by
+ * directly calling the various other builder configuration methods, such as
+ * {@link Builder#digits(String) digits}.
+ *
+ * <p><strong>Comparison with DecimalFormat</strong>
+ * <p>This type provides some of the same functionality as Java's own
+ * {@link java.text.DecimalFormat}. However, unlike {@code DecimalFormat}, the format
+ * functions produced by this type are lightweight and thread-safe, making them
+ * much easier to work with in multi-threaded environments. They also provide performance
+ * comparable to, and in many cases faster than, {@code DecimalFormat}.
+ *
+ * <p><strong>Examples</strong>
+ * <pre>
+ * // construct a formatter equivalent to Double.toString()
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder().build();
+ *
+ * // construct a formatter equivalent to Double.toString() but using
+ * // format symbols for a specific locale
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.MIXED.builder()
+ *      .formatSymbols(DecimalFormatSymbols.getInstance(locale))
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "#,##0.0##",
+ * // where whole number groups of thousands are separated
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.PLAIN.builder()
+ *      .minDecimalExponent(-3)
+ *      .groupThousands(true)
+ *      .build();
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "0.0##E0"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.SCIENTIFIC.builder()
+ *      .maxPrecision(4)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ *
+ * // construct a formatter equivalent to the DecimalFormat pattern "##0.0##E0",
+ * // i.e. "engineering format"
+ * DoubleFunction&lt;String&gt; fmt = DoubleFormat.ENGINEERING.builder()
+ *      .maxPrecision(6)
+ *      .alwaysIncludeExponent(true)
+ *      .build()
+ * </pre>
+ *
+ * <p><strong>Implementation Notes</strong>
+ * <p>{@link java.math.RoundingMode#HALF_EVEN Half-even} rounding is used in cases where the
+ * decimal value must be rounded in order to meet the configuration requirements of the formatter
+ * instance.
+ */
+public enum DoubleFormat {
+
+    /** Number format without exponents.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1450000000.0
+     * 0.0000000000123
+     * </pre>
+     */
+    PLAIN(PlainDoubleFormat::new),
+
+    /** Number format that uses exponents and contains a single digit
+     * to the left of the decimal point.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 1.2401E1
+     * 1.0E5
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    SCIENTIFIC(ScientificDoubleFormat::new),
+
+    /** Number format similar to {@link #SCIENTIFIC scientific format} but adjusted
+     * so that the exponent value is always a multiple of 3, allowing easier alignment
+     * with SI prefixes.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100.0E3
+     * 1.45E9
+     * 12.3E-12
+     * </pre>
+     */
+    ENGINEERING(EngineeringDoubleFormat::new),
+
+    /** Number format that uses {@link #PLAIN plain format} for small numbers and
+     * {@link #SCIENTIFIC scientific format} for large numbers. The number thresholds
+     * can be configured through the
+     * {@link Builder#plainFormatMinDecimalExponent plainFormatMinDecimalExponent}
+     * and
+     * {@link Builder#plainFormatMaxDecimalExponent plainFormatMaxDecimalExponent}
+     * properties.
+     * Ex:
+     * <pre>
+     * 0.0
+     * 12.401
+     * 100000.0
+     * 1.45E9
+     * 1.23E-11
+     * </pre>
+     */
+    MIXED(MixedDoubleFormat::new);
+
+    /** Function used to construct instances for this format type. */
+    private final Function<Builder, DoubleFunction<String>> factory;
+
+    /** Construct a new instance.
+     * @param factory function used to construct format instances
+     */
+    DoubleFormat(final Function<Builder, DoubleFunction<String>> factory) {
+        this.factory = factory;
+    }
+
+    /** Return a {@link Builder} for constructing formatter functions for this format type.
+     * @return builder instance
+     */
+    public Builder builder() {
+        return new Builder(factory);
+    }
+
+    /** Class for constructing configured format functions for standard double format types.
+     */
+    public static final class Builder {
+
+        /** Default value for the plain format max decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT = 6;
+
+        /** Default value for the plain format min decimal exponent. */
+        private static final int DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT = -3;
+
+        /** Default decimal digit characters. */
+        private static final String DEFAULT_DECIMAL_DIGITS = "0123456789";
+
+        /** Function used to construct format instances. */
+        private final Function<Builder, DoubleFunction<String>> factory;
+
+        /** Maximum number of significant decimal digits in formatted strings. */
+        private int maxPrecision = 0;
+
+        /** Minimum decimal exponent. */
+        private int minDecimalExponent = Integer.MIN_VALUE;
+
+        /** Max decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMaxDecimalExponent = DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT;
+
+        /** Min decimal exponent to use with plain formatting with the mixed format type. */
+        private int plainFormatMinDecimalExponent = DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT;
+
+        /** String representing infinity. */
+        private String infinity = "Infinity";
+
+        /** String representing NaN. */
+        private String nan = "NaN";
+
+        /** Flag determining if fraction placeholders should be used. */
+        private boolean fractionPlaceholder = true;
+
+        /** Flag determining if signed zero strings are allowed. */
+        private boolean signedZero = true;
+
+        /** String of digit characters 0-9. */
+        private String digits = DEFAULT_DECIMAL_DIGITS;
+
+        /** Decimal separator character. */
+        private char decimalSeparator = '.';
+
+        /** Character used to separate groups of thousands. */
+        private char groupingSeparator = ',';
+
+        /** If true, thousands groups will be separated by the grouping separator. */
+        private boolean groupThousands = false;
+
+        /** Minus sign character. */
+        private char minusSign = '-';
+
+        /** Exponent separator character. */
+        private String exponentSeparator = "E";
+
+        /** Flag indicating if the exponent value should always be included, even if zero. */
+        private boolean alwaysIncludeExponent = false;
+
+        /** Construct a new instance that delegates double function construction
+         * to the given factory object.
+         * @param factory factory function
+         */
+        private Builder(final Function<Builder, DoubleFunction<String>> factory) {
+            this.factory = factory;
+        }
+
+        /** Set the maximum number of significant decimal digits used in format
+         * results. A value of {@code 0} indicates no limit. The default value is {@code 0}.
+         * @param maxPrecision maximum precision
+         * @return this instance
+         */
+        public Builder maxPrecision(final int maxPrecision) {
+            this.maxPrecision = maxPrecision;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for formatted strings. No digits with an
+         * absolute value of less than <code>10<sup>minDecimalExponent</sup></code> will
+         * be included in format results. If the number being formatted does not contain
+         * any such digits, then zero is returned. For example, if {@code minDecimalExponent}
+         * is set to {@code -2} and the number {@code 3.14159} is formatted, the plain
+         * format result will be {@code "3.14"}. If {@code 0.001} is formatted, then the
+         * result is the zero string.
+         * @param minDecimalExponent minimum decimal exponent
+         * @return this instance
+         */
+        public Builder minDecimalExponent(final int minDecimalExponent) {
+            this.minDecimalExponent = minDecimalExponent;
+            return this;
+        }
+
+        /** Set the maximum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code 2}, the number {@code 999} will be formatted as {@code "999.0"}
+         * while {@code 1000} will be formatted as {@code "1.0E3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MAX_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMaxDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMaxDecimalExponent(final int plainFormatMaxDecimalExponent) {
+            this.plainFormatMaxDecimalExponent = plainFormatMaxDecimalExponent;
+            return this;
+        }
+
+        /** Set the minimum decimal exponent for numbers formatted as plain decimal strings when
+         * using the {@link DoubleFormat#MIXED MIXED} format type. If the number being formatted
+         * has an absolute value less than <code>10<sup>plainFormatMaxDecimalExponent + 1</sup></code> and
+         * greater than or equal to <code>10<sup>plainFormatMinDecimalExponent</sup></code> after any
+         * necessary rounding, then the formatted result will use the {@link DoubleFormat#PLAIN PLAIN} format type.
+         * Otherwise, {@link DoubleFormat#SCIENTIFIC SCIENTIFIC} format will be used. For example,
+         * if this value is set to {@code -2}, the number {@code 0.01} will be formatted as {@code "0.01"}
+         * while {@code 0.0099} will be formatted as {@code "9.9E-3"}.
+         *
+         * <p>The default value is {@value #DEFAULT_PLAIN_FORMAT_MIN_DECIMAL_EXPONENT}.
+         *
+         * <p>This value is ignored for formats other than {@link DoubleFormat#MIXED}.
+         * @param plainFormatMinDecimalExponent maximum decimal exponent for values formatted as plain
+         *      strings when using the {@link DoubleFormat#MIXED MIXED} format type.
+         * @return this instance
+         * @see #plainFormatMinDecimalExponent(int)
+         */
+        public Builder plainFormatMinDecimalExponent(final int plainFormatMinDecimalExponent) {
+            this.plainFormatMinDecimalExponent = plainFormatMinDecimalExponent;
+            return this;
+        }
+
+        /** Set the flag determining whether or not the zero string may be returned with the minus
+         * sign or if it will always be returned in the positive form. For example, if set to true,
+         * the string {@code "-0.0"} may be returned for some input numbers. If false, only {@code "0.0"}
+         * will be returned, regardless of the sign of the input number. The default value is {@code true}.
+         * @param signedZero if true, the zero string may be returned with a preceding minus sign; if false,
+         *      the zero string will only be returned in its positive form
+         * @return this instance
+         */
+        public Builder allowSignedZero(final boolean signedZero) {
+            this.signedZero = signedZero;
+            return this;
+        }
+
+        /** Set the string containing the digit characters 0-9, in that order. The
+         * default value is the string {@code "0123456789"}.
+         * @param digits string containing the digit characters 0-9
+         * @return this instance
+         * @throws NullPointerException if the argument is null
+         * @throws IllegalArgumentException if the argument does not have a length of exactly 10
+         */
+        public Builder digits(final String digits) {
+            Objects.requireNonNull(digits, "Digits string cannot be null");

Review comment:
       I don't think this needs any changes since `minusSign` and others only accept primitive values. The NPE in the example above would be thrown when computing the arguments to pass to `minusSign`.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@commons.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org