This is an automated email from the ASF dual-hosted git repository.

mattjuntunen pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-text.git


The following commit(s) were added to refs/heads/master by this push:
     new ff372c2  TEXT-207: adding DoubleFormat utility
ff372c2 is described below

commit ff372c2dd5400c636433ac1d40c885692f7195ac
Author: Matt Juntunen <mattjuntu...@apache.org>
AuthorDate: Mon Jul 5 22:59:03 2021 -0400

    TEXT-207: adding DoubleFormat utility
---
 pom.xml                                            |  68 +-
 src/changes/changes.xml                            |  13 +-
 .../apache/commons/text/numbers/DoubleFormat.java  | 730 ++++++++++++++++++++
 .../apache/commons/text/numbers/ParsedDecimal.java | 723 ++++++++++++++++++++
 .../apache/commons/text/numbers/package-info.java  |  24 +
 .../commons/text/jmh/DoubleFormatPerformance.java  | 273 ++++++++
 .../commons/text/numbers/DoubleFormatTest.java     | 595 +++++++++++++++++
 .../commons/text/numbers/ParsedDecimalTest.java    | 732 +++++++++++++++++++++
 8 files changed, 3149 insertions(+), 9 deletions(-)

diff --git a/pom.xml b/pom.xml
index e402e68..6f0f5a4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -52,23 +52,26 @@
 
     <spotbugs.plugin.version>4.3.0</spotbugs.plugin.version>
     <spotbugs.impl.version>4.3.0</spotbugs.impl.version>
-    
+
     <commons.mockito.version>3.11.2</commons.mockito.version>
     <commons.jacoco.version>0.8.6</commons.jacoco.version>
     <commons.javadoc.version>3.2.0</commons.javadoc.version>
 
     <graalvm.version>21.1.0</graalvm.version>
+    <commons.rng.version>1.3</commons.rng.version>
 
     <commons.japicmp.version>0.15.3</commons.japicmp.version>
     <japicmp.skip>false</japicmp.skip>
     <clirr.skip>true</clirr.skip>
 
+    <jmh.version>1.32</jmh.version>
+
     <!-- Commons Release Plugin -->
     <commons.bc.version>1.9</commons.bc.version>
     <commons.rc.version>RC1</commons.rc.version>
     <commons.release.isDistModule>true</commons.release.isDistModule>
     
<commons.distSvnStagingUrl>scm:svn:https://dist.apache.org/repos/dist/dev/commons/${commons.componentid}</commons.distSvnStagingUrl>
-    <commons.releaseManagerName>Gary Gregory</commons.releaseManagerName>    
+    <commons.releaseManagerName>Gary Gregory</commons.releaseManagerName>
     <commons.releaseManagerKey>86fdc7e2a11262cb</commons.releaseManagerKey>
   </properties>
 
@@ -116,6 +119,24 @@
       <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>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.openjdk.jmh</groupId>
+      <artifactId>jmh-generator-annprocess</artifactId>
+      <version>${jmh.version}</version>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
@@ -160,6 +181,7 @@
           
<suppressionsLocation>${basedir}/checkstyle-suppressions.xml</suppressionsLocation>
           
<suppressionsFileExpression>${basedir}/checkstyle-suppressions.xml</suppressionsFileExpression>
           <includeTestSourceDirectory>true</includeTestSourceDirectory>
+          <excludes>**/generated/**.java,**/jmh_generated/**.java</excludes>
         </configuration>
         <dependencies>
           <dependency>
@@ -243,6 +265,7 @@
           
<suppressionsLocation>${basedir}/checkstyle-suppressions.xml</suppressionsLocation>
           
<suppressionsFileExpression>${basedir}/checkstyle-suppressions.xml</suppressionsFileExpression>
           <includeTestSourceDirectory>true</includeTestSourceDirectory>
+          <excludes>**/generated/**.java,**/jmh_generated/**.java</excludes>
         </configuration>
         <reportSets>
           <reportSet>
@@ -355,7 +378,7 @@
       <email>ggregory at apache.org</email>
       <url>https://www.garygregory.com</url>
       <organization>The Apache Software Foundation</organization>
-      <organizationUrl>https://www.apache.org/</organizationUrl>      
+      <organizationUrl>https://www.apache.org/</organizationUrl>
       <roles>
         <role>PMC Member</role>
       </roles>
@@ -513,5 +536,44 @@
         </plugins>
       </build>
     </profile>
+    <profile>
+      <id>benchmark</id>
+      <properties>
+        <skipTests>true</skipTests>
+        <benchmark>org.apache</benchmark>
+      </properties>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.codehaus.mojo</groupId>
+            <artifactId>exec-maven-plugin</artifactId>
+            <version>3.0.0</version>
+            <executions>
+              <execution>
+                <id>benchmark</id>
+                <phase>test</phase>
+                <goals>
+                  <goal>exec</goal>
+                </goals>
+                <configuration>
+                  <classpathScope>test</classpathScope>
+                  <executable>java</executable>
+                  <arguments>
+                    <argument>-classpath</argument>
+                    <classpath/>
+                    <argument>org.openjdk.jmh.Main</argument>
+                    <argument>-rf</argument>
+                    <argument>json</argument>
+                    <argument>-rff</argument>
+                    <argument>target/jmh-result.${benchmark}.json</argument>
+                    <argument>${benchmark}</argument>
+                  </arguments>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
   </profiles>
 </project>
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index a7328fa..034a7a6 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -52,6 +52,7 @@ The <action> type attribute can be add,update,fix,remove.
     <action issue="TEXT-186" type="fix" dev="ggregory" due-to="Gautam Korlam, 
Gary Gregory">StringSubstitutor map constructor throws NPE on 1.9 with null 
map.</action>
     <action issue="TEXT-191" type="fix" dev="kinow" due-to="Bradley David 
Rumball">JaroWinklerDistance returns the same values as 
JaroWinklerSimilarity.</action>
     <!-- ADD -->
+    <action issue="TEXT-207" type="add" dev="mattjuntunen">Add DoubleFormat 
utility.</action>
     <action issue="TEXT-190" type="add" dev="kinow" due-to="Benjamin 
Bing">Document negative limit for WordUtils abbreviate method</action>
     <action issue="TEXT-188" type="add" dev="kinow" due-to="Jakob 
Vesterstrøm">Speed up LevenshteinDistance with threshold by exiting 
early</action>
     <action issue="TEXT-185" type="add" dev="ggregory" due-to="Larry West, 
Gary Gregory">Release Notes page hasn't been updated for 1.9 release 
yet.</action>
@@ -98,7 +99,7 @@ The <action> type attribute can be add,update,fix,remove.
     <action                  type="update" dev="ggregory" due-to="Johan 
Hammar">[javadoc] Fix compiler warnings in Java code example in Javadoc 
#124.</action>
     <action issue="TEXT-177" type="update" dev="ggregory" due-to="Gary 
Gregory">Update from Apache Commons Lang 3.9 to 3.11.</action>
     <action                  type="add" dev="ggregory" due-to="Gary 
Gregory">Add StringMatcher.size().</action>
-    <action                  type="add" dev="ggregory" due-to="Gary 
Gregory">Refactor TextStringBuilder.readFrom(Readable), extracting 
readFrom(CharBuffer) and readFrom(Reader).</action>    
+    <action                  type="add" dev="ggregory" due-to="Gary 
Gregory">Refactor TextStringBuilder.readFrom(Readable), extracting 
readFrom(CharBuffer) and readFrom(Reader).</action>
     <action                  type="add" dev="ggregory" due-to="Gary 
Gregory">Add BiStringLookup and implementation BiFunctionStringLookup.</action>
     <action                  type="add" dev="ggregory" due-to="Gary 
Gregory">Add 
org.apache.commons.text.StringSubstitutor.StringSubstitutor(StringSubstitutor).</action>
     <action                  type="add" dev="ggregory" due-to="Gary 
Gregory">Add 
org.apache.commons.text.TextStringBuilder.TextStringBuilder(CharSequence).</action>
@@ -128,9 +129,9 @@ The <action> type attribute can be add,update,fix,remove.
     <action                  type="update" dev="ggregory" due-to="Gary 
Gregory">[build] checkstyle.version 8.27 -> 8.33.</action>
     <action                  type="update" dev="ggregory" due-to="Gary 
Gregory">[build] org.apache.commons:commons-parent 48 -> 51.</action>
     <action                  type="update" dev="ggregory" due-to="Gary 
Gregory">[build] maven-pmd-plugin 3.12.0 -> 3.13.0.</action>
-    <action                  type="update" dev="ggregory" due-to="Gary 
Gregory">[build] org.mockito 3.3.3 -> 3.4.4.</action>    
+    <action                  type="update" dev="ggregory" due-to="Gary 
Gregory">[build] org.mockito 3.3.3 -> 3.4.4.</action>
   </release>
-  
+
   <release version="1.8" date="2019-08-30" description="Release 1.8. Requires 
Java 8.">
     <action issue="TEXT-167" type="fix" dev="ggregory" due-to="Larry 
West">commons-text web page missing "RELEASE-NOTES-1.7.txt"</action>
     <action issue="TEXT-168" type="fix" dev="ggregory" due-to="luksan47">(doc) 
Fixed wrong value for Jaro-Winkler example #117</action>
@@ -140,7 +141,7 @@ The <action> type attribute can be add,update,fix,remove.
     <action                  type="update" dev="ggregory" due-to="Gary 
Gregory">Expand Javadoc for StringSubstitutor and friends.</action>
     <action                  type="update" dev="ggregory" due-to="Gary 
Gregory">[site] checkstyle.version 8.21 -> 8.23.</action>
   </release>
-  
+
   <release version="1.7" date="2019-06-30" description="Release 1.7. Requires 
Java 8.">
     <action issue="TEXT-111" type="fix" dev="kinow" 
due-to="@CAPS50">WordUtils.wrap must calculate offset increment from wrapOn 
pattern length</action>
     <action issue="TEXT-104" type="update" dev="kinow" due-to="Sascha 
Szott">Jaro Winkler Distance refers to similarity</action>
@@ -187,8 +188,8 @@ The <action> type attribute can be add,update,fix,remove.
     <action issue="TEXT-120" type="fix" 
dev="pschumacher">StringEscapeUtils#unescapeJson does not unescape double 
quotes and forward slash</action>
     <action issue="TEXT-119" type="fix" dev="pschumacher">Remove mention of 
SQL escaping from user guide</action>
     <action issue="TEXT-121" type="update" dev="ggregory" 
due-to="pschumacher">Update Java requirement from version 7 to 8.</action>
-    <action issue="TEXT-122" type="update" dev="ggregory">Allow full 
customization with new API 
org.apache.commons.text.lookup.StringLookupFactory.interpolatorStringLookup(Map&lt;String,
 StringLookup>, StringLookup, boolean).</action>    
-    <action issue="TEXT-123" type="fix" dev="ggregory" due-to="Takanobu 
Asanuma">WordUtils.wrap throws StringIndexOutOfBoundsException when wrapLength 
is Integer.MAX_VALUE.</action>   
+    <action issue="TEXT-122" type="update" dev="ggregory">Allow full 
customization with new API 
org.apache.commons.text.lookup.StringLookupFactory.interpolatorStringLookup(Map&lt;String,
 StringLookup>, StringLookup, boolean).</action>
+    <action issue="TEXT-123" type="fix" dev="ggregory" due-to="Takanobu 
Asanuma">WordUtils.wrap throws StringIndexOutOfBoundsException when wrapLength 
is Integer.MAX_VALUE.</action>
   </release>
 
   <release version="1.3" date="2018-03-16" description="Release 1.3. Requires 
Java 7.">
diff --git a/src/main/java/org/apache/commons/text/numbers/DoubleFormat.java 
b/src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
new file mode 100644
index 0000000..3f3fc74
--- /dev/null
+++ b/src/main/java/org/apache/commons/text/numbers/DoubleFormat.java
@@ -0,0 +1,730 @@
+/*
+ * 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 {@code 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 {@code true},
+         * the string {@code "-0.0"} may be returned for some input numbers. 
If {@code 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 {@code true}, the zero string may be returned 
with a preceding minus sign;
+         *      if {@code 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 {@code 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 {@code 
true}, the number {@code 1} would
+         * be formatted as {@code "1.0"}. If {@code false}, it would be 
formatted as {@code "1"}. The default
+         * value is {@code true}.
+         * @param fractionPlaceholder if {@code true}, a zero character is 
placed in the fraction position when
+         *      no fractional value is present; if {@code 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 {@code true}, thousands will be grouped with the
+         * {@link #groupingSeparator(char) grouping separator}. For example, 
if set to {@code 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 {@code 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 {@code null}
+         */
+        public Builder exponentSeparator(final String exponentSeparator) {
+            this.exponentSeparator = Objects.requireNonNull(exponentSeparator, 
"Exponent separator cannot be null");
+            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 {@code 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 {@code null}
+         */
+        public Builder infinity(final String infinity) {
+            this.infinity = Objects.requireNonNull(infinity, "Infinity string 
cannot be null");
+            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 {@code null}
+         */
+        public Builder nan(final String nan) {
+            this.nan = Objects.requireNonNull(nan, "NaN string cannot be 
null");
+            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 {@code 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 positiveInfinity;
+
+        /** 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.positiveInfinity = 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 isIncludeFractionPlaceholder() {
+            return fractionPlaceholder;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean isSignedZero() {
+            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 isGroupThousands() {
+            return groupThousands;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public char getMinusSign() {
+            return minusSign;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public char[] getExponentSeparatorChars() {
+            return exponentSeparatorChars;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean isAlwaysIncludeExponent() {
+            return alwaysIncludeExponent;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String apply(final double d) {
+            if (Double.isFinite(d)) {
+                return applyFinite(d);
+            } else if (Double.isInfinite(d)) {
+                return d > 0.0
+                        ? positiveInfinity
+                        : negativeInfinity;
+            }
+            return nan;
+        }
+
+        /** Return a formatted string representation of the given finite value.
+         * @param d double value
+         */
+        private String applyFinite(final double d) {
+            final ParsedDecimal n = ParsedDecimal.from(d);
+
+            int roundExponent = Math.max(n.getExponent(), minDecimalExponent);
+            if (maxPrecision > 0) {
+                roundExponent = Math.max(n.getScientificExponent() - 
maxPrecision + 1, roundExponent);
+            }
+            n.round(roundExponent);
+
+            return applyFiniteInternal(n);
+        }
+
+        /** Return a formatted representation of the given rounded decimal 
value to {@code dst}.
+         * @param val value to format
+         */
+        protected abstract String applyFiniteInternal(ParsedDecimal val);
+    }
+
+    /** Format class that produces plain decimal strings that do not use
+     * scientific notation.
+     */
+    private static class PlainDoubleFormat extends AbstractDoubleFormat {
+
+        /** Construct a new instance.
+         * @param builder builder instance containing configuration values
+         */
+        PlainDoubleFormat(final Builder builder) {
+            super(builder);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected String applyFiniteInternal(final ParsedDecimal val) {
+            return val.toPlainString(this);
+        }
+    }
+
+    /** Format class producing results similar to {@link Double#toString()}, 
with
+     * plain decimal notation for small numbers relatively close to zero and 
scientific
+     * notation otherwise.
+     */
+    private static final class MixedDoubleFormat extends AbstractDoubleFormat {
+
+        /** Max decimal exponent for plain format. */
+        private final int plainMaxExponent;
+
+        /** Min decimal exponent for plain format. */
+        private final int plainMinExponent;
+
+        /** Construct a new instance.
+         * @param builder builder instance containing configuration values
+         */
+        MixedDoubleFormat(final Builder builder) {
+            super(builder);
+
+            this.plainMaxExponent = builder.plainFormatMaxDecimalExponent;
+            this.plainMinExponent = builder.plainFormatMinDecimalExponent;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected String applyFiniteInternal(final ParsedDecimal val) {
+            final int sciExp = val.getScientificExponent();
+            if (sciExp <= plainMaxExponent && sciExp >= plainMinExponent) {
+                return val.toPlainString(this);
+            }
+            return val.toScientificString(this);
+        }
+    }
+
+    /** Format class that uses scientific notation for all values.
+     */
+    private static class ScientificDoubleFormat extends AbstractDoubleFormat {
+
+        /** Construct a new instance.
+         * @param builder builder instance containing configuration values
+         */
+        ScientificDoubleFormat(final Builder builder) {
+            super(builder);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String applyFiniteInternal(final ParsedDecimal val) {
+            return val.toScientificString(this);
+        }
+    }
+
+    /** Format class that uses engineering notation for all values.
+     */
+    private static class EngineeringDoubleFormat extends AbstractDoubleFormat {
+
+        /** Construct a new instance.
+         * @param builder builder instance containing configuration values
+         */
+        EngineeringDoubleFormat(final Builder builder) {
+            super(builder);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String applyFiniteInternal(final ParsedDecimal val) {
+            return val.toEngineeringString(this);
+        }
+    }
+}
diff --git a/src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java 
b/src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
new file mode 100644
index 0000000..953eb76
--- /dev/null
+++ b/src/main/java/org/apache/commons/text/numbers/ParsedDecimal.java
@@ -0,0 +1,723 @@
+/*
+ * 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 {@code true} if fraction placeholders should be included
+         */
+        boolean isIncludeFractionPlaceholder();
+
+        /** Return {@code true} if the string zero should be prefixed with the 
minus sign
+         * for negative zero values.
+         * @return {@code true} if the minus zero string should be allowed
+         */
+        boolean isSignedZero();
+
+        /** 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 {@code true} if thousand should be grouped
+         */
+        boolean isGroupThousands();
+
+        /** 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 {@code true} if exponent values should always be included
+         */
+        boolean isAlwaysIncludeExponent();
+    }
+
+    /** 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 {@code 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
+     */
+    private 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 internal unsigned integer.
+     * @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.isGroupThousands()
+                ? 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 (final char c : chars) {
+            append(c);
+        }
+    }
+
+    /** 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 {@code 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 startIdx 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.isIncludeFractionPlaceholder()) {
+            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.isGroupThousands() && decimalPos > 0) {
+            size += (decimalPos - 1) / THOUSANDS_GROUP_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.isIncludeFractionPlaceholder()) {
+                size += 2;
+            }
+        } else {
+            // whole and fraction components;
+            // add decimal point
+            size += 1;
+        }
+
+        return size;
+    }
+
+    /** Return {@code 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 {@code true} if a minus sign should be included in the output
+     */
+    private boolean shouldIncludeMinus(final FormatOptions opts) {
+        return negative && (opts.isSignedZero() || !isZero());
+    }
+
+    /** Return {@code true} if a formatted string with the given target 
exponent should include
+     * the exponent field.
+     * @param targetExponent exponent of the formatted result
+     * @param opts format options
+     * @return {@code true} if the formatted string should include the 
exponent field
+     */
+    private boolean shouldIncludeExponent(final int targetExponent, final 
FormatOptions opts) {
+        return targetExponent != 0 || opts.isAlwaysIncludeExponent();
+    }
+
+    /** Return {@code 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 {@code 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 {@code true} if this value is equal to zero. The sign field is 
ignored,
+     * meaning that this method will return {@code true} for both {@code +0} 
and {@code -0}.
+     * @return {@code 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) {
+        // trim all trailing zero digits, making sure to leave
+        // at least one digit left
+        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 - digitStartIdx - 1];
+
+        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);
+    }
+
+    /** Parse a double exponent value from {@code chars}, starting at the 
{@code start}
+     * index and continuing through the end of the array.
+     * @param chars character array to parse a double exponent value from
+     * @param start start index
+     * @return parsed exponent value
+     */
+    private static int parseExponent(final char[] chars, final int start) {
+        int i = start;
+        boolean neg = chars[i] == MINUS_CHAR;
+        if (neg) {
+            ++i;
+        }
+
+        int exp = 0;
+        for (; i < chars.length; ++i) {
+            exp = (exp * DECIMAL_RADIX) + digitValue(chars[i]);
+        }
+
+        return neg ? -exp : exp;
+    }
+
+    /** Get the numeric value of the given digit character. No validation of 
the
+     * character type is performed.
+     * @param ch digit character
+     * @return numeric value of the digit character, ex: '1' = 1
+     */
+    private static int digitValue(final char ch) {
+        return ch - ZERO_CHAR;
+    }
+}
diff --git a/src/main/java/org/apache/commons/text/numbers/package-info.java 
b/src/main/java/org/apache/commons/text/numbers/package-info.java
new file mode 100644
index 0000000..0e9e671
--- /dev/null
+++ b/src/main/java/org/apache/commons/text/numbers/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+/**
+ * <p>
+ * Provides algorithms for converting numbers to strings.
+ * </p>
+ *
+ * @since 1.10
+ */
+package org.apache.commons.text.numbers;
diff --git 
a/src/test/java/org/apache/commons/text/jmh/DoubleFormatPerformance.java 
b/src/test/java/org/apache/commons/text/jmh/DoubleFormatPerformance.java
new file mode 100644
index 0000000..92c3c7c
--- /dev/null
+++ b/src/test/java/org/apache/commons/text/jmh/DoubleFormatPerformance.java
@@ -0,0 +1,273 @@
+/*
+ * 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.jmh;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.DecimalFormat;
+import java.util.concurrent.TimeUnit;
+import java.util.function.DoubleFunction;
+
+import org.apache.commons.rng.UniformRandomProvider;
+import org.apache.commons.rng.simple.RandomSource;
+import org.apache.commons.text.numbers.DoubleFormat;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/** Benchmarks for the {@link DoubleFormat} class.
+ */
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 1, jvmArgs = {"-server", "-Xms512M", "-Xmx512M"})
+public class DoubleFormatPerformance {
+
+    /** Decimal format pattern for plain output. */
+    private static final String PLAIN_PATTERN = "0.0##";
+
+    /** Decimal format pattern for plain output with thousands grouping. */
+    private static final String PLAIN_GROUPED_PATTERN = "#,##0.0##";
+
+    /** Decimal format pattern for scientific output. */
+    private static final String SCI_PATTERN = "0.0##E0";
+
+    /** Decimal format pattern for engineering output. */
+    private static final String ENG_PATTERN = "##0.0##E0";
+
+    /** Benchmark input providing a source of random double values. */
+    @State(Scope.Thread)
+    public static class DoubleInput {
+
+        /** The number of doubles in the input array. */
+        @Param({"10000"})
+        private int size;
+
+        /** Minimum base 2 exponent for random input doubles. */
+        @Param("-100")
+        private int minExp;
+
+        /** Maximum base 2 exponent for random input doubles. */
+        @Param("100")
+        private int maxExp;
+
+        /** Double input array. */
+        private double[] input;
+
+        /** Get the input doubles.
+         * @return the input doubles
+         */
+        public double[] getInput() {
+            return input;
+        }
+
+        /** Set up the instance for the benchmark. */
+        @Setup(Level.Iteration)
+        public void setup() {
+            input = randomDoubleArray(size, minExp, maxExp,
+                RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP));
+        }
+    }
+
+    /** Create a random double value with exponent in the range {@code 
[minExp, maxExp]}.
+     * @param minExp minimum exponent; must be less than {@code maxExp}
+     * @param maxExp maximum exponent; must be greater than {@code minExp}
+     * @param rng random number generator
+     * @return random double
+     */
+    private static double randomDouble(final int minExp, final int maxExp, 
final UniformRandomProvider rng) {
+        // Create random doubles using random bits in the sign bit and the 
mantissa.
+        final long mask = ((1L << 52) - 1) | 1L << 63;
+        final long bits = rng.nextLong() & mask;
+        // The exponent must be unsigned so + 1023 to the signed exponent
+        final long exp = rng.nextInt(maxExp - minExp + 1) + minExp + 1023;
+        return Double.longBitsToDouble(bits | (exp << 52));
+    }
+
+    /** Create an array with the given length containing random doubles with 
exponents in the range
+     * {@code [minExp, maxExp]}.
+     * @param len array length
+     * @param minExp minimum exponent; must be less than {@code maxExp}
+     * @param maxExp maximum exponent; must be greater than {@code minExp}
+     * @param rng random number generator
+     * @return array of random doubles
+     */
+    private static double[] randomDoubleArray(final int len, final int minExp, 
final int maxExp,
+            final UniformRandomProvider rng) {
+        final double[] arr = new double[len];
+        for (int i = 0; i < arr.length; ++i) {
+            arr[i] = randomDouble(minExp, maxExp, rng);
+        }
+        return arr;
+    }
+
+    /** Run a benchmark test on a function accepting a double argument.
+     * @param <T> function output type
+     * @param input double array
+     * @param bh jmh blackhole for consuming output
+     * @param fn function to call
+     */
+    private static <T> void runDoubleFunction(final DoubleInput input, final 
Blackhole bh,
+            final DoubleFunction<T> fn) {
+        for (final double d : input.getInput()) {
+            bh.consume(fn.apply(d));
+        }
+    }
+
+    /** Benchmark testing just the overhead of the benchmark harness.
+     * @param input benchmark state input
+     * @param bh jmh blackhole for consuming output
+     */
+    @Benchmark
+    public void baseline(final DoubleInput input, final Blackhole bh) {
+        runDoubleFunction(input, bh, d -> "0.0");
+    }
+
+    /** Benchmark testing the {@link Double#toString()} method.
+     * @param input benchmark state input
+     * @param bh jmh blackhole for consuming output
+     */
+    @Benchmark
+    public void doubleToString(final DoubleInput input, final Blackhole bh) {
+        runDoubleFunction(input, bh, Double::toString);
+    }
+
+    /** Benchmark testing the {@link String#format(String, Object...)} method.
+     * @param input benchmark state input
+     * @param bh jmh blackhole for consuming output
+     */
+    @Benchmark
+    public void stringFormat(final DoubleInput input, final Blackhole bh) {
+        runDoubleFunction(input, bh, d -> String.format("%f", d));
+    }
+
+    /** Benchmark testing the BigDecimal formatting performance.
+     * @param input benchmark state input
+     * @param bh jmh blackhole for consuming output
+     */
+    @Benchmark
+    public void bigDecimal(final DoubleInput input, final Blackhole bh) {
+        final DoubleFunction<String> fn = d -> BigDecimal.valueOf(d)
+                .setScale(3, RoundingMode.HALF_EVEN)
+                .stripTrailingZeros()
+                .toString();
+        runDoubleFunction(input, bh, fn);
+    }
+
+    /** Benchmark testing the {@link DecimalFormat} class.
+     * @param input benchmark state input
+     * @param bh jmh blackhole for consuming output
+     */
+    @Benchmark
+    public void decimalFormatPlain(final DoubleInput input, final Blackhole 
bh) {
+        final DecimalFormat fmt = new DecimalFormat(PLAIN_PATTERN);
+        runDoubleFunction(input, bh, fmt::format);
+    }
+
+    /** Benchmark testing the {@link DecimalFormat} class with thousands 
grouping.
+     * @param input benchmark state input
+     * @param bh jmh blackhole for consuming output
+     */
+    @Benchmark
+    public void decimalFormatPlainGrouped(final DoubleInput input, final 
Blackhole bh) {
+        final DecimalFormat fmt = new DecimalFormat(PLAIN_GROUPED_PATTERN);
+        runDoubleFunction(input, bh, fmt::format);
+    }
+
+    /** Benchmark testing the {@link DecimalFormat} class with scientific 
format.
+     * @param input benchmark state input
+     * @param bh jmh blackhole for consuming output
+     */
+    @Benchmark
+    public void decimalFormatScientific(final DoubleInput input, final 
Blackhole bh) {
+        final DecimalFormat fmt = new DecimalFormat(SCI_PATTERN);
+        runDoubleFunction(input, bh, fmt::format);
+    }
+
+    /** Benchmark testing the {@link DecimalFormat} class with engineering 
format.
+     * @param input benchmark state input
+     * @param bh jmh blackhole for consuming output
+     */
+    @Benchmark
+    public void decimalFormatEngineering(final DoubleInput input, final 
Blackhole bh) {
+        final DecimalFormat fmt = new DecimalFormat(ENG_PATTERN);
+        runDoubleFunction(input, bh, fmt::format);
+    }
+
+    /** Benchmark testing the {@link DoubleFormat#PLAIN} format.
+     * @param input benchmark state input
+     * @param bh jmh blackhole for consuming output
+     */
+    @Benchmark
+    public void doubleFormatPlain(final DoubleInput input, final Blackhole bh) 
{
+        final DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder()
+                .minDecimalExponent(-3)
+                .build();
+        runDoubleFunction(input, bh, fmt);
+    }
+
+    /** Benchmark testing the {@link DoubleFormat#PLAIN} format with
+     * thousands grouping.
+     * @param input benchmark state input
+     * @param bh jmh blackhole for consuming output
+     */
+    @Benchmark
+    public void doubleFormatPlainGrouped(final DoubleInput input, final 
Blackhole bh) {
+        final DoubleFunction<String> fmt = DoubleFormat.PLAIN.builder()
+                .minDecimalExponent(-3)
+                .groupThousands(true)
+                .build();
+        runDoubleFunction(input, bh, fmt);
+    }
+
+    /** Benchmark testing the {@link DoubleFormat#SCIENTIFIC} format.
+     * @param input benchmark state input
+     * @param bh jmh blackhole for consuming output
+     */
+    @Benchmark
+    public void doubleFormatScientific(final DoubleInput input, final 
Blackhole bh) {
+        final DoubleFunction<String> fmt = DoubleFormat.SCIENTIFIC.builder()
+                .maxPrecision(4)
+                .alwaysIncludeExponent(true)
+                .build();
+        runDoubleFunction(input, bh, fmt);
+    }
+
+    /** Benchmark testing the {@link DoubleFormat#ENGINEERING} format.
+     * @param input benchmark state input
+     * @param bh jmh blackhole for consuming output
+     */
+    @Benchmark
+    public void doubleFormatEngineering(final DoubleInput input, final 
Blackhole bh) {
+        final DoubleFunction<String> fmt = DoubleFormat.ENGINEERING.builder()
+                .maxPrecision(6)
+                .alwaysIncludeExponent(true)
+                .build();
+        runDoubleFunction(input, bh, fmt);
+    }
+}
diff --git 
a/src/test/java/org/apache/commons/text/numbers/DoubleFormatTest.java 
b/src/test/java/org/apache/commons/text/numbers/DoubleFormatTest.java
new file mode 100644
index 0000000..f144725
--- /dev/null
+++ b/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 format type
+     */
+    private static void checkFormatAccuracyWithDefaults(final DoubleFormat 
type) {
+        final DoubleFunction<String> fmt = type.builder().build();
+
+        checkDefaultFormatSpecial(fmt);
+
+        checkFormatAccuracy(fmt, Double.MIN_VALUE);
+        checkFormatAccuracy(fmt, -Double.MIN_VALUE);
+
+        checkFormatAccuracy(fmt, Double.MIN_NORMAL);
+        checkFormatAccuracy(fmt, -Double.MIN_NORMAL);
+
+        checkFormatAccuracy(fmt, Double.MAX_VALUE);
+        checkFormatAccuracy(fmt, -Double.MAX_VALUE);
+
+        checkFormatAccuracy(fmt, Math.PI);
+        checkFormatAccuracy(fmt, Math.E);
+
+        final Random rnd = new Random(10L);
+        final int cnt = 1000;
+        for (int i = 0; i < cnt; ++i) {
+            checkFormatAccuracy(fmt, randomDouble(rnd));
+        }
+    }
+
+    /** Check that the given double value can be exactly recovered from 
formatted string representation
+     * produced by the format instance.
+     * @param fmt format instance
+     * @param d input double value
+     */
+    private static void checkFormatAccuracy(final DoubleFunction<String> fmt, 
final double d) {
+        final String str = fmt.apply(d);
+        final double parsed = Double.parseDouble(str);
+        Assertions.assertEquals(d, parsed, () -> "Formatted double string [" + 
str + "] did not match input value");
+    }
+
+    private static void checkFormat(final DoubleFunction<String> fmt, final 
double d, final String str) {
+        Assertions.assertEquals(str, fmt.apply(d));
+    }
+
+    private static void checkDefaultFormatSpecial(final DoubleFunction<String> 
fmt) {
+        checkFormat(fmt, 0.0, "0.0");
+        checkFormat(fmt, -0.0, "-0.0");
+        checkFormat(fmt, Double.NaN, "NaN");
+        checkFormat(fmt, Double.POSITIVE_INFINITY, "Infinity");
+        checkFormat(fmt, Double.NEGATIVE_INFINITY, "-Infinity");
+    }
+
+    private static void checkLocalizedFormats(final String pattern,
+            final Function<Locale, DoubleFunction<String>> factory) {
+        for (final Locale loc : Locale.getAvailableLocales()) {
+            checkLocalizedFormat(loc, pattern, factory);
+        }
+    }
+
+    private static void checkLocalizedFormat(final Locale loc, final String 
pattern,
+            final Function<Locale, DoubleFunction<String>> factory) {
+        // arrange
+        final DecimalFormat df = new DecimalFormat(pattern, 
DecimalFormatSymbols.getInstance(loc));
+        final DoubleFunction<String> fmt = factory.apply(loc);
+
+        // act/assert
+        assertLocalizedFormatsAreEqual(0.0, df, fmt, loc);
+        assertLocalizedFormatsAreEqual(Double.POSITIVE_INFINITY, df, fmt, loc);
+        assertLocalizedFormatsAreEqual(Double.NEGATIVE_INFINITY, df, fmt, loc);
+        assertLocalizedFormatsAreEqual(Double.NaN, df, fmt, loc);
+
+        assertLocalizedFormatsAreEqual(1.0, df, fmt, loc);
+        assertLocalizedFormatsAreEqual(-1.0, df, fmt, loc);
+        assertLocalizedFormatsAreEqual(Math.PI, df, fmt, loc);
+        assertLocalizedFormatsAreEqual(Math.E, df, fmt, loc);
+
+        final Random rnd = new Random(12L);
+        final int minExp = -100;
+        final int maxExp = 100;
+        final int cnt = 1000;
+        for (int i = 0; i < cnt; ++i) {
+            assertLocalizedFormatsAreEqual(randomDouble(minExp, maxExp, rnd), 
df, fmt, loc);
+        }
+    }
+
+    private static void assertLocalizedFormatsAreEqual(final double d, final 
DecimalFormat df,
+            final DoubleFunction<String> fmt, final Locale loc) {
+        // NOTE: Perform the string comparison only on non-format characters. 
This is required because
+        // JDK 16 adds directionality characters to strings for certain 
locales, such as Arabic, whereas
+        // previous JDKs do not. We will match the behavior of the previous 
versions here and ignore formatting
+        // for test purposes.
+        String dfStr = trimFormatChars(df.format(d));
+        String fmtStr = trimFormatChars(fmt.apply(d));
+
+        Assertions.assertEquals(dfStr, fmtStr,
+                () -> "Unexpected output for locale [" + loc.toLanguageTag() + 
"] and double value " + d);
+    }
+
+    /** Remove Unicode {@link Character#FORMAT format} characters from the 
given string.
+     * @param str input string
+     * @return input string with format characters removed
+     */
+    private static String trimFormatChars(final String str) {
+        final StringBuilder sb = new StringBuilder();
+        for (final char c : str.toCharArray()) {
+            if (Character.getType(c) != Character.FORMAT) {
+                sb.append(c);
+            }
+        }
+        return sb.toString();
+    }
+
+    /** Create a random double value using the full range of exponent values.
+     * @param rnd random number generator
+     * @return random double
+     */
+    private static double randomDouble(final Random rnd) {
+        return randomDouble(Double.MIN_EXPONENT, Double.MAX_EXPONENT, rnd);
+    }
+
+    /** Create a random double value with exponent in the range {@code 
[minExp, maxExp]}.
+     * @param minExp minimum exponent; must be less than {@code maxExp}
+     * @param maxExp maximum exponent; must be greater than {@code minExp}
+     * @param rnd random number generator
+     * @return random double
+     */
+    private static double randomDouble(final int minExp, final int maxExp, 
final Random rnd) {
+        // Create random doubles using random bits in the sign bit and the 
mantissa.
+        final long mask = ((1L << 52) - 1) | 1L << 63;
+        final long bits = rnd.nextLong() & mask;
+        // The exponent must be unsigned so + 1023 to the signed exponent
+        final long exp = rnd.nextInt(maxExp - minExp + 1) + minExp + 1023;
+        return Double.longBitsToDouble(bits | (exp << 52));
+    }
+}
diff --git 
a/src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.java 
b/src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.java
new file mode 100644
index 0000000..3bd7ca8
--- /dev/null
+++ b/src/test/java/org/apache/commons/text/numbers/ParsedDecimalTest.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.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(dec.toScientificString(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", 
ParsedDecimal.from(1.0).toPlainString(opts));
+        Assertions.assertEquals("-a.abcd", 
ParsedDecimal.from(-0.0123).toPlainString(opts));
+        Assertions.assertEquals("bc.de", 
ParsedDecimal.from(12.34).toPlainString(opts));
+        Assertions.assertEquals("baaaa.a", 
ParsedDecimal.from(10000).toPlainString(opts));
+        Assertions.assertEquals("jihgfedcba.a", 
ParsedDecimal.from(9876543210d).toPlainString(opts));
+
+        Assertions.assertEquals("b.a", 
ParsedDecimal.from(1.0).toScientificString(opts));
+        Assertions.assertEquals("-b.cdE-c", 
ParsedDecimal.from(-0.0123).toScientificString(opts));
+        Assertions.assertEquals("b.cdeEb", 
ParsedDecimal.from(12.34).toScientificString(opts));
+        Assertions.assertEquals("b.aEe", 
ParsedDecimal.from(10000).toScientificString(opts));
+        Assertions.assertEquals("j.ihgfedcbEj", 
ParsedDecimal.from(9876543210d).toScientificString(opts));
+
+        Assertions.assertEquals("b.a", 
ParsedDecimal.from(1.0).toEngineeringString(opts));
+        Assertions.assertEquals("-bc.dE-d", 
ParsedDecimal.from(-0.0123).toEngineeringString(opts));
+        Assertions.assertEquals("bc.de", 
ParsedDecimal.from(12.34).toEngineeringString(opts));
+        Assertions.assertEquals("ba.aEd", 
ParsedDecimal.from(10000).toEngineeringString(opts));
+        Assertions.assertEquals("j.ihgfedcbEj", 
ParsedDecimal.from(9876543210d).toEngineeringString(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(ParsedDecimal.from(10.0).toScientificString(stdOpts)));
+
+        for (double d = min; d <= max; d += delta) {
+            // act/assert
+            Assertions.assertEquals(d, 
Double.parseDouble(ParsedDecimal.from(d).toScientificString(stdOpts)));
+            Assertions.assertEquals(d, 
Double.parseDouble(ParsedDecimal.from(d).toScientificString(altOpts)));
+
+            Assertions.assertEquals(d, 
Double.parseDouble(ParsedDecimal.from(d).toEngineeringString(stdOpts)));
+            Assertions.assertEquals(d, 
Double.parseDouble(ParsedDecimal.from(d).toEngineeringString(altOpts)));
+
+            Assertions.assertEquals(d, 
Double.parseDouble(ParsedDecimal.from(d).toPlainString(stdOpts)));
+            Assertions.assertEquals(d, 
Double.parseDouble(ParsedDecimal.from(d).toPlainString(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(ParsedDecimal.from(d).toScientificString(stdOpts)));
+            Assertions.assertEquals(d, 
Double.parseDouble(ParsedDecimal.from(d).toScientificString(altOpts)));
+
+            Assertions.assertEquals(d, 
Double.parseDouble(ParsedDecimal.from(d).toEngineeringString(stdOpts)));
+            Assertions.assertEquals(d, 
Double.parseDouble(ParsedDecimal.from(d).toEngineeringString(altOpts)));
+
+            Assertions.assertEquals(d, 
Double.parseDouble(ParsedDecimal.from(d).toPlainString(stdOpts)));
+            Assertions.assertEquals(d, 
Double.parseDouble(ParsedDecimal.from(d).toPlainString(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, ParsedDecimal::toPlainString, opts);
+    }
+
+    private static void checkToScientificString(final double d, final String 
expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimal::toScientificString, 
opts);
+    }
+
+    private static void checkToEngineeringString(final double d, final String 
expected,
+            final ParsedDecimal.FormatOptions opts) {
+        checkToStringMethod(d, expected, ParsedDecimal::toEngineeringString, 
opts);
+
+        // check the exponent value to make sure it is a multiple of 3
+        final String pos = ParsedDecimal.from(d).toEngineeringString(opts);
+        Assertions.assertEquals(0, parseExponent(pos, opts) % 3);
+
+        final String neg = ParsedDecimal.from(-d).toEngineeringString(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;
+                neg = true;
+            }
+
+            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 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 = '-';
+
+        private String exponentSeparator = "E";
+
+        private boolean alwaysIncludeExponent = false;
+
+        @Override
+        public boolean isIncludeFractionPlaceholder() {
+            return includeFractionPlaceholder;
+        }
+
+        public void setIncludeFractionPlaceholder(final boolean 
includeFractionPlaceholder) {
+            this.includeFractionPlaceholder = includeFractionPlaceholder;
+        }
+
+        @Override
+        public boolean isSignedZero() {
+            return signedZero;
+        }
+
+        public void setSignedZero(final boolean signedZero) {
+            this.signedZero = signedZero;
+        }
+
+        @Override
+        public char[] getDigits() {
+            return digits;
+        }
+
+        public void setDigitsFromString(final String digits) {
+            this.digits = digits.toCharArray();
+        }
+
+        @Override
+        public char getDecimalSeparator() {
+            return decimalSeparator;
+        }
+
+        public void setDecimalSeparator(final char decimalSeparator) {
+            this.decimalSeparator = decimalSeparator;
+        }
+
+        @Override
+        public char getGroupingSeparator() {
+            return thousandsGroupingSeparator;
+        }
+
+        public void setThousandsGroupingSeparator(final char 
thousandsGroupingSeparator) {
+            this.thousandsGroupingSeparator = thousandsGroupingSeparator;
+        }
+
+        @Override
+        public boolean isGroupThousands() {
+            return groupThousands;
+        }
+
+        public void setGroupThousands(final boolean groupThousands) {
+            this.groupThousands = groupThousands;
+        }
+
+        @Override
+        public char getMinusSign() {
+            return minusSign;
+        }
+
+        public void setMinusSign(final char minusSign) {
+            this.minusSign = minusSign;
+        }
+
+        @Override
+        public char[] getExponentSeparatorChars() {
+            return exponentSeparator.toCharArray();
+        }
+
+        public void setExponentSeparator(final String exponentSeparator) {
+            this.exponentSeparator = exponentSeparator;
+        }
+
+        @Override
+        public boolean isAlwaysIncludeExponent() {
+            return alwaysIncludeExponent;
+        }
+
+        public void setAlwaysIncludeExponent(final boolean 
alwaysIncludeExponent) {
+            this.alwaysIncludeExponent = alwaysIncludeExponent;
+        }
+    }
+}

Reply via email to