You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2020/11/09 23:16:11 UTC

[sis] branch geoapi-4.0 updated (4fe1348 -> 5e13b7f)

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

desruisseaux pushed a change to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git.


    from 4fe1348  Rename the `list` field as `children` and make the code more null-safe by replacing the null value by a more explicit `isEnumeration` flag.
     new 606b226  Clarify the behavior of `WKTFormat.clone()` and avoid cloning the `fragments` map if possible. The intent is to allow more efficient `WKTFormat` cloning for parsing many WKT strings in parallel.
     new 847ab7e  Clarify which locale is used in error messages.
     new 5e13b7f  Initial version of `WKTDictionary` for allowing users to define CRS for custom codes. https://issues.apache.org/jira/browse/SIS-502

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../apache/sis/internal/referencing/Resources.java |  22 +-
 .../sis/internal/referencing/Resources.properties  |   6 +-
 .../internal/referencing/Resources_fr.properties   |   6 +-
 .../java/org/apache/sis/io/wkt/AbstractParser.java |  75 +-
 .../main/java/org/apache/sis/io/wkt/Element.java   | 131 ++-
 .../main/java/org/apache/sis/io/wkt/Formatter.java |  36 +-
 .../java/org/apache/sis/io/wkt/WKTDictionary.java  | 884 +++++++++++++++++++++
 .../main/java/org/apache/sis/io/wkt/WKTFormat.java | 205 +++--
 .../java/org/apache/sis/io/wkt/doc-files/ESRI.txt  |  69 ++
 .../java/org/apache/sis/io/wkt/package-info.java   |   4 +-
 .../org/apache/sis/io/wkt/WKTDictionaryTest.java   | 104 +++
 .../java/org/apache/sis/io/wkt/WKTFormatTest.java  |   3 +-
 .../sis/test/suite/ReferencingTestSuite.java       |   1 +
 .../resources/org/apache/sis/io/wkt/ExtraCRS.txt   |  65 ++
 .../java/org/apache/sis/internal/jdk9/JDK9.java    |  14 +-
 .../java/org/apache/sis/internal/util/Strings.java |  25 +
 .../java/org/apache/sis/math/StatisticsFormat.java |   3 +-
 .../sis/util/collection/TreeTableFormat.java       |   4 +-
 18 files changed, 1545 insertions(+), 112 deletions(-)
 create mode 100644 core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTDictionary.java
 create mode 100644 core/sis-referencing/src/main/java/org/apache/sis/io/wkt/doc-files/ESRI.txt
 create mode 100644 core/sis-referencing/src/test/java/org/apache/sis/io/wkt/WKTDictionaryTest.java
 create mode 100644 core/sis-referencing/src/test/resources/org/apache/sis/io/wkt/ExtraCRS.txt


[sis] 01/03: Clarify the behavior of `WKTFormat.clone()` and avoid cloning the `fragments` map if possible. The intent is to allow more efficient `WKTFormat` cloning for parsing many WKT strings in parallel.

Posted by de...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit 606b2262fa424d17cd9ce47111dcb3ecb7640c7e
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Wed Nov 4 12:53:13 2020 +0100

    Clarify the behavior of `WKTFormat.clone()` and avoid cloning the `fragments` map if possible.
    The intent is to allow more efficient `WKTFormat` cloning for parsing many WKT strings in parallel.
---
 .../main/java/org/apache/sis/io/wkt/WKTFormat.java | 102 ++++++++++++++++-----
 1 file changed, 78 insertions(+), 24 deletions(-)

diff --git a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTFormat.java b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTFormat.java
index b591022..9c53c96 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTFormat.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTFormat.java
@@ -23,6 +23,7 @@ import java.util.Set;
 import java.util.Map;
 import java.util.HashMap;
 import java.util.TreeMap;
+import java.util.Collections;
 import java.io.IOException;
 import java.text.Format;
 import java.text.NumberFormat;
@@ -107,7 +108,7 @@ import org.apache.sis.internal.referencing.ReferencingFactoryContainer;
  *
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Rémi Eve (IRD)
- * @version 1.0
+ * @version 1.1
  *
  * @see <a href="http://docs.opengeospatial.org/is/12-063r5/12-063r5.html">WKT 2 specification</a>
  * @see <a href="http://www.geoapi.org/3.0/javadoc/org/opengis/referencing/doc-files/WKT.html">Legacy WKT 1</a>
@@ -132,16 +133,21 @@ public class WKTFormat extends CompoundFormat<Object> {
     public static final int SINGLE_LINE = -1;
 
     /**
-     * The symbols to use for this formatter.
+     * The {@linkplain Symbols#immutable() immutable} set of symbols to use for this formatter.
      * The same object is also referenced in the {@linkplain #parser} and {@linkplain #formatter}.
      * It appears here for serialization purpose.
+     *
+     * @see #setSymbols(Symbols)
      */
     private Symbols symbols;
 
     /**
-     * The colors to use for this formatter, or {@code null} for no syntax coloring.
+     * The {@linkplain Colors#immutable() immutable} set of colors to use for this formatter,
+     * or {@code null} for no syntax coloring. The default value is {@code null}.
      * The same object is also referenced in the {@linkplain #formatter}.
      * It appears here for serialization purpose.
+     *
+     * @see #setColors(Colors)
      */
     private Colors colors;
 
@@ -196,12 +202,27 @@ public class WKTFormat extends CompoundFormat<Object> {
     /**
      * WKT fragments that can be inserted in longer WKT strings, or {@code null} if none. Keys are short identifiers
      * and values are WKT subtrees to substitute to the identifiers when they are found in a WKT to parse.
+     * The same map instance may be shared by different {@linkplain #clone() clones}.
      *
-     * @see #fragments()
+     * @see #fragments(boolean)
      */
     private Map<String,Element> fragments;
 
     /**
+     * {@code true} if the {@link #fragments} map is shared by two or more {@code WKTFormat} instances.
+     * In such case, the map shall not be modified; instead it must be copied before any modification.
+     *
+     * <h4>Use case</h4>
+     * This flag allows to clone the {@link #fragments} map only when first needed. In use cases where
+     * {@code WKTFormat} is cloned for multi-threading purposes without change in its configuration,
+     * this flag avoids completely the need to clone the {@link #fragments} map.
+     *
+     * @see #clone()
+     * @see #fragments(boolean)
+     */
+    private transient boolean isCloned;
+
+    /**
      * Temporary map used by {@link #addFragment(String, String)} for reusing existing instances when possible.
      * Keys and values are the same {@link String}, {@link Boolean}, {@link Number} or {@link Date} instances.
      *
@@ -258,11 +279,22 @@ public class WKTFormat extends CompoundFormat<Object> {
 
     /**
      * Returns the {@link #fragments} map, creating it when first needed.
+     * Caller shall not modify the returned map, unless the {@code modifiable} parameter is {@code true}.
+     *
+     * @param  modifiable  whether the caller intents to modify the map.
      */
     @SuppressWarnings("ReturnOfCollectionOrArrayField")
-    private Map<String,Element> fragments() {
+    private Map<String,Element> fragments(final boolean modifiable) {
         if (fragments == null) {
+            if (!modifiable) {
+                // Most common cases: invoked before to parse a WKT and no fragments specified.
+                return Collections.emptyMap();
+            }
             fragments = new TreeMap<>();
+            isCloned  = false;
+        } else if (isCloned & modifiable) {
+            fragments = new TreeMap<>(fragments);
+            isCloned  = false;
         }
         return fragments;
     }
@@ -301,7 +333,8 @@ public class WKTFormat extends CompoundFormat<Object> {
     }
 
     /**
-     * Returns the symbols used for parsing and formatting WKT.
+     * Returns the symbols used for parsing and formatting WKT. This method returns an unmodifiable instance.
+     * Modifications, if desired, should be applied on a {@linkplain Symbols#clone() clone} of the returned object.
      *
      * @return the current set of symbols used for parsing and formatting WKT.
      */
@@ -412,6 +445,8 @@ public class WKTFormat extends CompoundFormat<Object> {
 
     /**
      * Returns the colors to use for syntax coloring, or {@code null} if none.
+     * This method returns an unmodifiable instance. Modifications, if desired,
+     * should be applied on a {@linkplain Colors#clone() clone} of the returned object.
      * By default there is no syntax coloring.
      *
      * @return the colors for syntax coloring, or {@code null} if none.
@@ -677,7 +712,7 @@ public class WKTFormat extends CompoundFormat<Object> {
      * @return the name of all fragments known to this {@code WKTFormat}.
      */
     public Set<String> getFragmentNames() {
-        return fragments().keySet();
+        return fragments(true).keySet();
     }
 
     /**
@@ -701,9 +736,10 @@ public class WKTFormat extends CompoundFormat<Object> {
      *
      * For removing a fragment, use <code>{@linkplain #getFragmentNames()}.remove(name)</code>.
      *
-     * @param  name  the name to assign to the WKT fragment. Identifiers are case-sensitive.
+     * @param  name  the name to assign to the WKT fragment (case-sensitive). Must be a valid Unicode identifier.
      * @param  wkt   the Well Know Text (WKT) fragment represented by the given identifier.
-     * @throws IllegalArgumentException if the name is invalid or if a fragment is already present for that name.
+     * @throws IllegalArgumentException if the given name is not a valid Unicode identifier
+     *         or if a fragment is already associated to that name.
      * @throws ParseException if an error occurred while parsing the given WKT.
      */
     public void addFragment(final String name, final String wkt) throws IllegalArgumentException, ParseException {
@@ -711,17 +747,14 @@ public class WKTFormat extends CompoundFormat<Object> {
         ArgumentChecks.ensureNonEmpty("name", name);
         short error = Errors.Keys.NotAUnicodeIdentifier_1;
         if (CharSequences.isUnicodeIdentifier(name)) {
-            if (sharedValues == null) {
-                sharedValues = new HashMap<>();
-            }
             final ParsePosition pos = new ParsePosition(0);
-            final Element element = new Element(parser(), wkt, pos, sharedValues);
+            final Element element = parseFragment(wkt, pos);
             final int index = CharSequences.skipLeadingWhitespaces(wkt, pos.getIndex(), wkt.length());
             if (index < wkt.length()) {
                 throw new UnparsableObjectException(getLocale(), Errors.Keys.UnexpectedCharactersAfter_2,
                         new Object[] {name + " = " + element.keyword + "[…]", CharSequences.token(wkt, index)}, index);
             }
-            // 'fragments' map has been created by 'parser()'.
+            // `fragments` map has been created by `parser(true)`.
             if (fragments.putIfAbsent(name, element) == null) {
                 return;
             }
@@ -731,6 +764,20 @@ public class WKTFormat extends CompoundFormat<Object> {
     }
 
     /**
+     * Parses a fragment of Well Know Text (WKT).
+     *
+     * @param  wkt  the Well Know Text (WKT) fragment to parse.
+     * @param  pos  index of the first character to parse (on input) or after last parsed character (on output).
+     * @return root of the tree of elements.
+     */
+    final Element parseFragment(final String wkt, final ParsePosition pos) throws ParseException {
+        if (sharedValues == null) {
+            sharedValues = new HashMap<>();
+        }
+        return new Element(parser(true), wkt, pos, sharedValues);
+    }
+
+    /**
      * Creates an object from the given character sequence.
      * The parsing begins at the index given by the {@code pos} argument.
      * After successful parsing, {@link ParsePosition#getIndex()} gives the position after the last parsed character.
@@ -747,7 +794,7 @@ public class WKTFormat extends CompoundFormat<Object> {
         sharedValues = null;
         ArgumentChecks.ensureNonEmpty("wkt", wkt);
         ArgumentChecks.ensureNonNull ("pos", pos);
-        final AbstractParser parser = parser();
+        final AbstractParser parser = parser(false);
         Object object = null;
         try {
             return object = parser.parseObject(wkt.toString(), pos);
@@ -758,11 +805,13 @@ public class WKTFormat extends CompoundFormat<Object> {
 
     /**
      * Returns the parser, created when first needed.
+     *
+     * @param  modifiable  whether the caller intents to modify the {@link #fragments} map.
      */
-    private AbstractParser parser() {
+    private AbstractParser parser(final boolean modifiable) {
         AbstractParser parser = this.parser;
-        if (parser == null) {
-            this.parser = parser = new Parser(symbols, fragments(),
+        if (parser == null || (isCloned & modifiable)) {
+            this.parser = parser = new Parser(symbols, fragments(modifiable),
                     (NumberFormat) getFormat(Number.class),
                     (DateFormat)   getFormat(Date.class),
                     (UnitFormat)   getFormat(Unit.class),
@@ -775,8 +824,8 @@ public class WKTFormat extends CompoundFormat<Object> {
     }
 
     /**
-     * The parser created by {@link #parser()}, identical to {@link GeodeticObjectParser} except for
-     * the source of logging messages which is the enclosing {@code WKTParser} instead than a factory.
+     * The parser created by {@link #parser(boolean)}, identical to {@link GeodeticObjectParser} except
+     * for the source of logging messages which is the enclosing {@code WKTParser} instead than a factory.
      */
     private static final class Parser extends GeodeticObjectParser {
         Parser(final Symbols symbols, final Map<String,Element> fragments,
@@ -907,16 +956,21 @@ public class WKTFormat extends CompoundFormat<Object> {
     }
 
     /**
-     * Returns a clone of this format.
+     * Returns a clone of this format. The clone has the same configuration (including any added
+     * {@linkplain #addFragment fragments}), except the {@linkplain #getWarnings() warnings}.
      *
      * @return a clone of this format.
      */
     @Override
     public WKTFormat clone() {
         final WKTFormat clone = (WKTFormat) super.clone();
-        clone.formatter = null;                                 // Do not share the formatter.
-        clone.parser    = null;
-        clone.warnings  = null;
+        clone.sharedValues = null;
+        clone.factories    = null;                              // Not thread-safe; clone needs its own.
+        clone.formatter    = null;                              // Do not share the formatter.
+        clone.parser       = null;
+        clone.warnings     = null;
+        clone.isCloned = isCloned = true;
+        // Symbols and Colors do not need to be cloned because they are flagged as immutable.
         return clone;
     }
 }


[sis] 02/03: Clarify which locale is used in error messages.

Posted by de...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit 847ab7ed972ce1cc0a1f765d4583fa19aa04dcc7
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Fri Nov 6 11:09:59 2020 +0100

    Clarify which locale is used in error messages.
---
 .../main/java/org/apache/sis/io/wkt/Element.java   | 34 ++++++++++----------
 .../main/java/org/apache/sis/io/wkt/Formatter.java | 36 +++++++++++++++++-----
 .../main/java/org/apache/sis/io/wkt/WKTFormat.java | 33 +++++++++++++++-----
 .../java/org/apache/sis/io/wkt/WKTFormatTest.java  |  3 +-
 .../java/org/apache/sis/math/StatisticsFormat.java |  3 +-
 .../sis/util/collection/TreeTableFormat.java       |  4 +--
 6 files changed, 76 insertions(+), 37 deletions(-)

diff --git a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/Element.java b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/Element.java
index a1e66d1..137203e 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/Element.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/Element.java
@@ -111,7 +111,7 @@ final class Element implements Serializable {
      * The locale to be used for formatting an error message if the parsing fails, or {@code null} for
      * the system default. This is <strong>not</strong> the locale for parting number or date values.
      */
-    private final Locale locale;
+    private final Locale errorLocale;
 
     /**
      * Constructs a root element.
@@ -120,10 +120,10 @@ final class Element implements Serializable {
      * @param singleton  the only child for this root.
      */
     Element(final String name, final Element singleton) {
-        keyword  = name;
-        offset   = singleton.offset;
-        locale   = singleton.locale;
-        children = new LinkedList<>();                          // Needs to be a modifiable collection.
+        keyword     = name;
+        offset      = singleton.offset;
+        errorLocale = singleton.errorLocale;
+        children    = new LinkedList<>();                       // Needs to be a modifiable collection.
         children.add(singleton);
         isEnumeration = false;
     }
@@ -135,7 +135,7 @@ final class Element implements Serializable {
         offset        = toCopy.offset;
         keyword       = toCopy.keyword;
         isEnumeration = toCopy.isEnumeration;
-        locale        = toCopy.locale;
+        errorLocale   = toCopy.errorLocale;
         children      = new LinkedList<>(toCopy.children);      // Needs to be a modifiable collection.
         final ListIterator<Object> it = children.listIterator();
         while (it.hasNext()) {
@@ -175,7 +175,7 @@ final class Element implements Serializable {
          * Find the first keyword in the specified string. If a keyword is found, then
          * the position is set to the index of the first character after the keyword.
          */
-        locale = parser.errorLocale;
+        errorLocale = parser.errorLocale;
         offset = position.getIndex();
         final int length = text.length();
         int lower = skipLeadingWhitespaces(text, offset, length);
@@ -238,7 +238,7 @@ final class Element implements Serializable {
                 if (fragment == null) {
                     position.setIndex(offset);
                     position.setErrorIndex(lower);
-                    throw new UnparsableObjectException(locale, Errors.Keys.NoSuchValue_1, new Object[] {id}, lower);
+                    throw new UnparsableObjectException(errorLocale, Errors.Keys.NoSuchValue_1, new Object[] {id}, lower);
                 }
                 if (!fragment.isEnumeration) {
                     fragment = new Element(fragment);
@@ -388,8 +388,8 @@ final class Element implements Serializable {
      * @return the exception to be thrown.
      */
     final ParseException parseFailed(final Exception cause) {
-        return new UnparsableObjectException(locale, Errors.Keys.ErrorIn_2,
-                new String[] {keyword, Exceptions.getLocalizedMessage(cause, locale)}, offset).initCause(cause);
+        return new UnparsableObjectException(errorLocale, Errors.Keys.ErrorIn_2,
+                new String[] {keyword, Exceptions.getLocalizedMessage(cause, errorLocale)}, offset).initCause(cause);
     }
 
     /**
@@ -415,7 +415,7 @@ final class Element implements Serializable {
             arguments = new CharSequence[] {keyword, CharSequences.token(text, errorIndex)};
         }
         position.setIndex(offset);
-        return new UnparsableObjectException(locale, errorKey, arguments, errorIndex);
+        return new UnparsableObjectException(errorLocale, errorKey, arguments, errorIndex);
     }
 
     /**
@@ -429,7 +429,7 @@ final class Element implements Serializable {
         position.setIndex(offset);
         position.setErrorIndex(errorIndex);
         final StringBuilder buffer = new StringBuilder(2).appendCodePoint(c);
-        return new UnparsableObjectException(locale, Errors.Keys.MissingCharacterInElement_2,
+        return new UnparsableObjectException(errorLocale, Errors.Keys.MissingCharacterInElement_2,
                 new CharSequence[] {keyword, buffer}, errorIndex);
     }
 
@@ -443,7 +443,7 @@ final class Element implements Serializable {
         if (keyword != null) {
             error += keyword.length();
         }
-        return new UnparsableObjectException(locale, Errors.Keys.MissingComponentInElement_2,
+        return new UnparsableObjectException(errorLocale, Errors.Keys.MissingComponentInElement_2,
                 new String[] {keyword, key}, error);
     }
 
@@ -472,7 +472,7 @@ final class Element implements Serializable {
             res  = Errors.Keys.MissingComponentInElement_2;
             args = new String[] {keyword, expected};
         }
-        return new UnparsableObjectException(locale, res, args, offset);
+        return new UnparsableObjectException(errorLocale, res, args, offset);
     }
 
     /**
@@ -496,7 +496,7 @@ final class Element implements Serializable {
             key   = Errors.Keys.IllegalCoordinateSystem_1;
             value = cs.getName().getCode();
         }
-        return new UnparsableObjectException(locale, key, new String[] {value}, offset);
+        return new UnparsableObjectException(errorLocale, key, new String[] {value}, offset);
     }
 
 
@@ -577,7 +577,7 @@ final class Element implements Serializable {
                 iterator.remove();
                 final Number number = (Number) object;
                 if (number instanceof Float || number instanceof Double) {
-                    throw new UnparsableObjectException(locale, Errors.Keys.UnparsableStringForClass_2,
+                    throw new UnparsableObjectException(errorLocale, Errors.Keys.UnparsableStringForClass_2,
                             new Object[] {Integer.class, number}, offset);
                 }
                 return number.intValue();
@@ -764,7 +764,7 @@ final class Element implements Serializable {
             if (value instanceof Element) {
                 CollectionsExt.addToMultiValuesMap(ignoredElements, ((Element) value).keyword, keyword);
             } else {
-                throw new UnparsableObjectException(locale, Errors.Keys.UnexpectedValueInElement_2,
+                throw new UnparsableObjectException(errorLocale, Errors.Keys.UnexpectedValueInElement_2,
                         new Object[] {keyword, value}, offset + keyword.length());
             }
         }
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/Formatter.java b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/Formatter.java
index 5f28fbd..11a1d0c 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/Formatter.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/Formatter.java
@@ -105,7 +105,7 @@ import org.apache.sis.math.Vector;
  * </ul>
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 1.0
+ * @version 1.1
  *
  * @see <a href="http://docs.opengeospatial.org/is/12-063r5/12-063r5.html">WKT 2 specification</a>
  * @see <a href="http://www.geoapi.org/3.0/javadoc/org/opengis/referencing/doc-files/WKT.html">Legacy WKT 1</a>
@@ -141,6 +141,8 @@ public class Formatter implements Localized {
     /**
      * The locale for the localization of international strings.
      * This is not the same than {@link Symbols#getLocale()}.
+     *
+     * @see #errorLocale
      */
     private final Locale locale;
 
@@ -345,6 +347,13 @@ public class Formatter implements Localized {
     private Warnings warnings;
 
     /**
+     * The locale for error messages (not for formatting).
+     *
+     * @see #locale
+     */
+    private final Locale errorLocale;
+
+    /**
      * Creates a new formatter instance with the default configuration.
      */
     public Formatter() {
@@ -364,6 +373,7 @@ public class Formatter implements Localized {
         ArgumentChecks.ensureNonNull("symbols",     symbols);
         ArgumentChecks.ensureBetween("indentation", WKTFormat.SINGLE_LINE, Byte.MAX_VALUE, indentation);
         this.locale           = Locale.getDefault(Locale.Category.DISPLAY);
+        this.errorLocale      = locale;
         this.convention       = convention;
         this.authority        = convention.getNameAuthority();
         this.symbols          = symbols.immutable();
@@ -383,11 +393,19 @@ public class Formatter implements Localized {
     /**
      * Constructor for private use by {@link WKTFormat} only. This allows to use the number format
      * created by {@link WKTFormat#createFormat(Class)}, which may be overridden by the user.
+     *
+     * @param  locale        the locale for the localization of international strings.
+     * @param  errorLocale   the locale for error messages (not for parsing), or {@code null} for the system default.
+     * @param  symbols       the symbols to use for this formatter.
+     * @param  numberFormat  the object to use for formatting numbers.
+     * @param  dateFormat    the object to use for formatting dates.
+     * @param  unitFormat    the object to use for formatting unit symbols.
      */
-    Formatter(final Locale locale, final Symbols symbols, final NumberFormat numberFormat,
-            final DateFormat dateFormat, final UnitFormat unitFormat)
+    Formatter(final Locale locale, final Locale errorLocale, final Symbols symbols,
+              final NumberFormat numberFormat, final DateFormat dateFormat, final UnitFormat unitFormat)
     {
         this.locale           = locale;
+        this.errorLocale      = errorLocale;
         this.convention       = Convention.DEFAULT;
         this.authority        = Convention.DEFAULT.getNameAuthority();
         this.symbols          = symbols;
@@ -662,12 +680,14 @@ public class Formatter implements Localized {
         final int stackDepth = enclosingElements.size();
         for (int i=stackDepth; --i >= 0;) {
             if (enclosingElements.get(i) == object) {
-                throw new IllegalStateException(Errors.getResources(locale).getString(Errors.Keys.CircularReference));
+                throw new IllegalStateException(Errors.getResources(errorLocale)
+                            .getString(Errors.Keys.CircularReference));
             }
         }
         enclosingElements.add(object);
         if (hasContextualUnit < 0) {                            // Test if leftmost bit is set to 1.
-            throw new IllegalStateException(Errors.getResources(locale).getString(Errors.Keys.TreeDepthExceedsMaximum));
+            throw new IllegalStateException(Errors.getResources(errorLocale)
+                        .getString(Errors.Keys.TreeDepthExceedsMaximum));
         }
         hasContextualUnit <<= 1;
         /*
@@ -1741,7 +1761,7 @@ public class Formatter implements Localized {
      */
     private Warnings warnings() {
         if (warnings == null) {
-            warnings = new Warnings(locale, false, Collections.emptyMap());
+            warnings = new Warnings(errorLocale, false, Collections.emptyMap());
         }
         return warnings;
     }
@@ -1825,7 +1845,7 @@ public class Formatter implements Localized {
             if (colors != null) {
                 buffer.append(X364.BACKGROUND_RED.sequence()).append(X364.BOLD.sequence()).append(' ');
             }
-            Vocabulary.getResources(locale).appendLabel(Vocabulary.Keys.Warnings, buffer);
+            Vocabulary.getResources(errorLocale).appendLabel(Vocabulary.Keys.Warnings, buffer);
             if (colors != null) {
                 buffer.append(' ').append(X364.RESET.sequence()).append(X364.FOREGROUND_RED.sequence());
             }
@@ -1833,7 +1853,7 @@ public class Formatter implements Localized {
             final int n = warnings.getNumMessages();
             final Set<String> done = new HashSet<>();
             for (int i=0; i<n; i++) {
-                String message = Exceptions.getLocalizedMessage(warnings.getException(i), locale);
+                String message = Exceptions.getLocalizedMessage(warnings.getException(i), errorLocale);
                 if (message == null) {
                     message = warnings.getMessage(i);
                 }
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTFormat.java b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTFormat.java
index 9c53c96..f9ab3e9 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTFormat.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTFormat.java
@@ -333,6 +333,19 @@ public class WKTFormat extends CompoundFormat<Object> {
     }
 
     /**
+     * Returns the locale to use for error messages. Other {@link CompoundFormat} classes use the system default.
+     * But this class uses a compromise: not exactly the locale used for {@link InternationalString} because that
+     * locale is often fixed to English, and not exactly the system default neither because this "error locale"
+     * is also used for warnings. The compromise implemented in this method may change in any future version.
+     *
+     * @see #errors()
+     */
+    private Locale getErrorLocale() {
+        final Locale locale = getLocale(Locale.Category.DISPLAY);
+        return (locale != null && locale != Locale.ROOT) ? locale : Locale.getDefault(Locale.Category.DISPLAY);
+    }
+
+    /**
      * Returns the symbols used for parsing and formatting WKT. This method returns an unmodifiable instance.
      * Modifications, if desired, should be applied on a {@linkplain Symbols#clone() clone} of the returned object.
      *
@@ -634,8 +647,7 @@ public class WKTFormat extends CompoundFormat<Object> {
             type != MathTransformFactory.class  &&
             type != CoordinateOperationFactory.class)
         {
-            throw new IllegalArgumentException(Errors.getResources(getLocale())
-                    .getString(Errors.Keys.IllegalArgumentValue_2, "type", type));
+            throw new IllegalArgumentException(errors().getString(Errors.Keys.IllegalArgumentValue_2, "type", type));
         }
     }
 
@@ -751,7 +763,7 @@ public class WKTFormat extends CompoundFormat<Object> {
             final Element element = parseFragment(wkt, pos);
             final int index = CharSequences.skipLeadingWhitespaces(wkt, pos.getIndex(), wkt.length());
             if (index < wkt.length()) {
-                throw new UnparsableObjectException(getLocale(), Errors.Keys.UnexpectedCharactersAfter_2,
+                throw new UnparsableObjectException(getErrorLocale(), Errors.Keys.UnexpectedCharactersAfter_2,
                         new Object[] {name + " = " + element.keyword + "[…]", CharSequences.token(wkt, index)}, index);
             }
             // `fragments` map has been created by `parser(true)`.
@@ -760,7 +772,7 @@ public class WKTFormat extends CompoundFormat<Object> {
             }
             error = Errors.Keys.ElementAlreadyPresent_1;
         }
-        throw new IllegalArgumentException(Errors.getResources(getLocale()).getString(error, name));
+        throw new IllegalArgumentException(errors().getString(error, name));
     }
 
     /**
@@ -817,7 +829,7 @@ public class WKTFormat extends CompoundFormat<Object> {
                     (UnitFormat)   getFormat(Unit.class),
                     convention,
                     (transliterator != null) ? transliterator : Transliterator.DEFAULT,
-                    getLocale(),
+                    getErrorLocale(),
                     factories());
         }
         return parser;
@@ -879,7 +891,7 @@ public class WKTFormat extends CompoundFormat<Object> {
          */
         Formatter formatter = this.formatter;
         if (formatter == null) {
-            formatter = new Formatter(getLocale(), symbols,
+            formatter = new Formatter(getLocale(), getErrorLocale(), symbols,
                     (NumberFormat) getFormat(Number.class),
                     (DateFormat)   getFormat(Date.class),
                     (UnitFormat)   getFormat(Unit.class));
@@ -905,7 +917,7 @@ public class WKTFormat extends CompoundFormat<Object> {
                 warnings.setRoot(object);
             }
             if (!valid) {
-                throw new ClassCastException(Errors.getResources(getLocale()).getString(
+                throw new ClassCastException(errors().getString(
                         Errors.Keys.IllegalArgumentClass_2, "object", object.getClass()));
             }
             if (buffer != toAppendTo) {
@@ -956,6 +968,13 @@ public class WKTFormat extends CompoundFormat<Object> {
     }
 
     /**
+     * Convenience methods for resources for error message in the locale given by {@link #getLocale()}.
+     */
+    private Errors errors() {
+        return Errors.getResources(getErrorLocale());
+    }
+
+    /**
      * Returns a clone of this format. The clone has the same configuration (including any added
      * {@linkplain #addFragment fragments}), except the {@linkplain #getWarnings() warnings}.
      *
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/io/wkt/WKTFormatTest.java b/core/sis-referencing/src/test/java/org/apache/sis/io/wkt/WKTFormatTest.java
index 8af1cff..e81ed67 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/io/wkt/WKTFormatTest.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/io/wkt/WKTFormatTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.io.wkt;
 
+import java.util.Locale;
 import java.util.Collections;
 import java.text.ParseException;
 import org.opengis.referencing.crs.VerticalCRS;
@@ -381,7 +382,7 @@ public final strictfp class WKTFormatTest extends TestCase {
     public void testWarnings() throws ParseException {
         DefaultPrimeMeridian pm = new DefaultPrimeMeridian(Collections.singletonMap(
                 DefaultPrimeMeridian.NAME_KEY, "Invalid “$name” here"), -10, Units.DEGREE);
-        format = new WKTFormat(null, null);
+        format = new WKTFormat(Locale.US, null);
         final String   wkt      = format.format(pm);
         final Warnings warnings = format.getWarnings();
         assertNotNull("warnings", warnings);
diff --git a/core/sis-utility/src/main/java/org/apache/sis/math/StatisticsFormat.java b/core/sis-utility/src/main/java/org/apache/sis/math/StatisticsFormat.java
index 99f2bfe..e785fc4 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/math/StatisticsFormat.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/math/StatisticsFormat.java
@@ -226,8 +226,7 @@ public class StatisticsFormat extends TabularFormat<Statistics> {
      */
     @Override
     public Statistics parse(CharSequence text, ParsePosition pos) throws ParseException {
-        throw new ParseException(Errors.getResources(getLocale())
-                .getString(Errors.Keys.UnsupportedOperation_1, "parse"), pos.getIndex());
+        throw new ParseException(Errors.format(Errors.Keys.UnsupportedOperation_1, "parse"), pos.getIndex());
     }
 
     /**
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/collection/TreeTableFormat.java b/core/sis-utility/src/main/java/org/apache/sis/util/collection/TreeTableFormat.java
index c00e883..d06e8af 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/collection/TreeTableFormat.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/collection/TreeTableFormat.java
@@ -340,9 +340,9 @@ public class TreeTableFormat extends TabularFormat<TreeTable> {
     }
 
     /**
-     * Returns the locale to use for code lists, international strings and exception messages.
+     * Returns the locale to use for code lists, international strings and localized messages of exceptions.
      */
-    final Locale getDisplayLocale() {
+    private Locale getDisplayLocale() {
         return getLocale(Locale.Category.DISPLAY);
     }
 


[sis] 03/03: Initial version of `WKTDictionary` for allowing users to define CRS for custom codes. https://issues.apache.org/jira/browse/SIS-502

Posted by de...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit 5e13b7f7bad086a32e228cf9f7ce66d52d9adec9
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Tue Nov 10 00:11:53 2020 +0100

    Initial version of `WKTDictionary` for allowing users to define CRS for custom codes.
    https://issues.apache.org/jira/browse/SIS-502
---
 .../apache/sis/internal/referencing/Resources.java |  22 +-
 .../sis/internal/referencing/Resources.properties  |   6 +-
 .../internal/referencing/Resources_fr.properties   |   6 +-
 .../java/org/apache/sis/io/wkt/AbstractParser.java |  75 +-
 .../main/java/org/apache/sis/io/wkt/Element.java   | 105 ++-
 .../java/org/apache/sis/io/wkt/WKTDictionary.java  | 884 +++++++++++++++++++++
 .../main/java/org/apache/sis/io/wkt/WKTFormat.java |  90 ++-
 .../java/org/apache/sis/io/wkt/doc-files/ESRI.txt  |  69 ++
 .../java/org/apache/sis/io/wkt/package-info.java   |   4 +-
 .../org/apache/sis/io/wkt/WKTDictionaryTest.java   | 104 +++
 .../sis/test/suite/ReferencingTestSuite.java       |   1 +
 .../resources/org/apache/sis/io/wkt/ExtraCRS.txt   |  65 ++
 .../java/org/apache/sis/internal/jdk9/JDK9.java    |  14 +-
 .../java/org/apache/sis/internal/util/Strings.java |  25 +
 14 files changed, 1405 insertions(+), 65 deletions(-)

diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.java
index a71a1ea..5571276 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.java
@@ -127,6 +127,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short CanNotParseCombinedReference_2 = 78;
 
         /**
+         * Can not read Well-Known Text at line {0}. Caused by: {1}
+         */
+        public static final short CanNotParseWKT_2 = 96;
+
+        /**
          * Can not separate the “{0}” coordinate reference system into sub-components.
          */
         public static final short CanNotSeparateCRS_1 = 84;
@@ -158,7 +163,7 @@ public final class Resources extends IndexedResourceBundle {
         public static final short CanNotTransformGeometry = 86;
 
         /**
-         * Can not use the {0} geodetic parameters: {1}
+         * Can not use the {0} geodetic parameters. Caused by: {1}
          */
         public static final short CanNotUseGeodeticParameters_2 = 9;
 
@@ -346,6 +351,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short MisnamedParameter_1 = 38;
 
         /**
+         * Missing or empty “ID[…]” element for “{0}”.
+         */
+        public static final short MissingAuthorityCode_1 = 99;
+
+        /**
          * No authority was specified for code “{0}”. The expected syntax is “AUTHORITY:CODE”.
          */
         public static final short MissingAuthority_1 = 39;
@@ -507,6 +517,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short StartOrEndPointNotSet_1 = 88;
 
         /**
+         * Syntax error for Well-Known Text alias at line {0}.
+         */
+        public static final short SyntaxErrorForAlias_1 = 97;
+
+        /**
          * Combined URI contains unexpected components.
          */
         public static final short UnexpectedComponentInURI = 80;
@@ -517,6 +532,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short UnexpectedDimensionForCS_1 = 64;
 
         /**
+         * Unexpected text “{1}” at line {0}. WKT for new object should start with a non-indented line.
+         */
+        public static final short UnexpectedTextAtLine_2 = 98;
+
+        /**
          * Parameter “{0}” does not expect unit.
          */
         public static final short UnitlessParameter_1 = 65;
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.properties b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.properties
index 338d505..7432bcc 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.properties
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.properties
@@ -59,10 +59,11 @@ CanNotParseCombinedReference_2    = Can not parse component {1} in the combined
 CanNotSeparateCRS_1               = Can not separate the \u201c{0}\u201d coordinate reference system into sub-components.
 CanNotSeparateTransform_3         = Can not separate the transform because result would have {2} {0,choice,0#source|1#target} dimension{2,choice,1#|2#s} instead of {1}.
 CanNotSeparateTargetDimension_1   = Target dimension {0} depends on excluded source dimensions.
+CanNotParseWKT_2                  = Can not read Well-Known Text at line {0}. Caused by: {1}
 CanNotTransformCoordinates_2      = Can not transform the ({0,number}, {1,number}) coordinates.
 CanNotTransformEnvelopeToGeodetic = Can not transform envelope to a geodetic reference system.
 CanNotTransformGeometry           = Can not transform the given geometry.
-CanNotUseGeodeticParameters_2     = Can not use the {0} geodetic parameters: {1}
+CanNotUseGeodeticParameters_2     = Can not use the {0} geodetic parameters. Caused by: {1}
 ColinearAxisDirections_2          = Axis directions {0} and {1} are colinear.
 CoordinateOperationNotFound_2     = Coordinate conversion of transformation from system \u201c{0}\u201d to \u201c{1}\u201d has not been found.
 DatumChangesDirectory_1           = Datum shift files are searched in the \u201c{0}\u201d directory.
@@ -86,6 +87,7 @@ MismatchedParameterDescriptor_1   = Mismatched descriptor for \u201c{0}\u201d pa
 MismatchedPrimeMeridian_2         = Expected the \u201c{0}\u201d prime meridian but found \u201c{1}\u201d.
 MismatchedTransformDimension_3    = The transform has {2} {0,choice,0#source|1#target} dimension{2,choice,1#|2#s}, while {1} was expected.
 MissingAuthority_1                = No authority was specified for code \u201c{0}\u201d. The expected syntax is \u201cAUTHORITY:CODE\u201d.
+MissingAuthorityCode_1            = Missing or empty \u201cID[\u2026]\u201d element for \u201c{0}\u201d.
 MissingInterpolationOrdinates     = Not enough dimension in \u2018MathTransform\u2019 input or output coordinates for the interpolation points.
 MissingHorizontalDimension_1      = No horizontal dimension found in \u201c{0}\u201d.
 MissingVerticalDimension_1        = No vertical dimension found in \u201c{0}\u201d
@@ -113,8 +115,10 @@ ParameterNotFound_2               = No parameter named \u201c{1}\u201d has been
 RecursiveCreateCallForCode_2      = Recursive call while creating an object of type \u2018{0}\u2019 for code \u201c{1}\u201d.
 SingularMatrix                    = Matrix is singular.
 StartOrEndPointNotSet_1           = The {0,choice,0#start|1#end} point has not been specified.
+SyntaxErrorForAlias_1             = Syntax error for Well-Known Text alias at line {0}.
 UnexpectedComponentInURI          = Combined URI contains unexpected components.
 UnexpectedDimensionForCS_1        = Unexpected dimension for a coordinate system of type \u2018{0}\u2019.
+UnexpectedTextAtLine_2            = Unexpected text \u201c{1}\u201d at line {0}. WKT for new object should start with a non-indented line.
 UnitlessParameter_1               = Parameter \u201c{0}\u201d does not expect unit.
 UnknownAuthority_1                = Authority \u201c{0}\u201d is unknown.
 UnknownAxisDirection_1            = Axis direction \u201c{0}\u201d is unknown.
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources_fr.properties b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources_fr.properties
index 67abae0..3672c41 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources_fr.properties
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources_fr.properties
@@ -64,10 +64,11 @@ CanNotSeparateCRS_1               = Ne peut pas s\u00e9parer le syst\u00e8me de
 CanNotSeparateTransform_3         = Ne peut pas s\u00e9parer la transformation parce-que le r\u00e9sultat aurait {2} dimension{2,choice,1#|2#s} en {0,choice,0#entr\u00e9|1#sortie} au lieu de {1}.
 CanNotSeparateTargetDimension_1   = La dimension de destination {0} d\u00e9pend de dimensions sources qui ont \u00e9t\u00e9 exclues.
 CanNotParseCombinedReference_2    = Ne peut pas d\u00e9coder la composante {1} dans l\u2019{0,choice,0#URN|1#URL} combin\u00e9.
+CanNotParseWKT_2                  = Ne peut pas lire le \u00ab\u202fWell-Known Text\u202f\u00bb \u00e0 la ligne {0}. La cause est\u202f: {1}
 CanNotTransformCoordinates_2      = Ne peut pas transformer les coordonn\u00e9es ({0,number}; {1,number}).
 CanNotTransformEnvelopeToGeodetic = Ne peut pas transformer l\u2019enveloppe vers un r\u00e9f\u00e9rentiel g\u00e9od\u00e9sique.
 CanNotTransformGeometry           = Ne peut pas transformer la g\u00e9om\u00e9trie donn\u00e9e.
-CanNotUseGeodeticParameters_2     = Ne peut pas utiliser les param\u00e8tres g\u00e9od\u00e9siques {0}\u202f: {1}
+CanNotUseGeodeticParameters_2     = Ne peut pas utiliser les param\u00e8tres g\u00e9od\u00e9siques {0}. La cause est\u202f: {1}
 ColinearAxisDirections_2          = Les directions d\u2019axes {0} et {1} sont colin\u00e9aires.
 CoordinateOperationNotFound_2     = La conversion ou transformation des coordonn\u00e9es du syst\u00e8me \u00ab\u202f{0}\u202f\u00bb vers \u00ab\u202f{1}\u202f\u00bb n\u2019a pas \u00e9t\u00e9 trouv\u00e9e.
 DatumChangesDirectory_1           = Les fichiers de changements de r\u00e9f\u00e9rentiel sont cherch\u00e9s dans le dossier \u00ab\u202f{0}\u202f\u00bb.
@@ -91,6 +92,7 @@ MismatchedParameterDescriptor_1   = Le descripteur du param\u00e8tre \u00ab\u202
 MismatchedPrimeMeridian_2         = Le m\u00e9ridien d\u2019origine \u00ab\u202f{0}\u202f\u00bb \u00e9tait attendu, mais \u00ab\u202f{1}\u202f\u00bb a \u00e9t\u00e9 trouv\u00e9.
 MismatchedTransformDimension_3    = La {0,choice,0#source|1#destination} de la transformation a {2} dimension{2,choice,1#|2#s}, alors qu\u2019on en attendait {1}.
 MissingAuthority_1                = Aucune autorit\u00e9 n\u2019a \u00e9t\u00e9 sp\u00e9cifi\u00e9e pour le code \u00ab\u202f{0}\u202f\u00bb. Le format attendu est \u00ab\u202fAUTORIT\u00c9:CODE\u202f\u00bb.
+MissingAuthorityCode_1            = L\u2019\u00e9l\u00e9ment \u00ab\u202fID[\u2026]\u202f\u00bb est manquant ou vide pour \u00ab\u202f{0}\u202f\u00bb.
 MissingInterpolationOrdinates     = La dimension des coordonn\u00e9es en entr\u00e9 ou en sortie du \u2018MathTransform\u2019 n\u2019est pas suffisante pour contenir les points d\u2019interpolation.
 MissingHorizontalDimension_1      = Aucune dimension horizontale n\u2019a \u00e9t\u00e9 trouv\u00e9e dans \u00ab\u202f{0}\u202f\u00bb.
 MissingVerticalDimension_1        = Aucune dimension verticale n\u2019a \u00e9t\u00e9 trouv\u00e9e dans \u00ab\u202f{0}\u202f\u00bb.
@@ -118,8 +120,10 @@ ParameterNotFound_2               = Aucun param\u00e8tre nomm\u00e9 \u00ab\u202f
 RecursiveCreateCallForCode_2      = Appels r\u00e9cursifs lors de la cr\u00e9ation d\u2019un objet de type \u2018{0}\u2019 pour le code \u00ab\u202f{1}\u202f\u00bb.
 SingularMatrix                    = La matrice est singuli\u00e8re.
 StartOrEndPointNotSet_1           = Le point {0,choice,0#de d\u00e9part|1#d\u2019arriv\u00e9} n\u2019a pas \u00e9t\u00e9 d\u00e9fini.
+SyntaxErrorForAlias_1             = Erreur de syntaxe pour l\u2019alias de \u00ab\u202fWell-Known Text\u202f\u00bb \u00e0 la ligne {0}.
 UnexpectedComponentInURI          = L\u2019URI combin\u00e9 contient des composantes qui n\u2019\u00e9taient pas attendues.
 UnexpectedDimensionForCS_1        = Dimension inattendue pour un syst\u00e8me de coordonn\u00e9es de type \u2018{0}\u2019.
+UnexpectedTextAtLine_2            = Le texte \u00ab\u202f{1}\u202f\u00bb \u00e0 la ligne {0} est inattendu. Le WKT d\u2019un nouvel objet doit commencer par une ligne non-indent\u00e9e.
 UnitlessParameter_1               = Le param\u00e8tre \u00ab\u202f{0}\u202f\u00bb n\u2019attend pas d\u2019unit\u00e9.
 UnknownAuthority_1                = L\u2019autorit\u00e9 \u00ab\u202f{0}\u202f\u00bb n\u2019est pas reconnue.
 UnknownAxisDirection_1            = La direction d\u2019axe \u00ab\u202f{0}\u202f\u00bb n\u2019est pas reconnue.
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/AbstractParser.java b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/AbstractParser.java
index 85596cd..b255587 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/AbstractParser.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/AbstractParser.java
@@ -254,40 +254,87 @@ abstract class AbstractParser implements Parser {
     }
 
     /**
-     * Parses a <cite>Well Know Text</cite> (WKT).
+     * Parses a <cite>Well Know Text</cite> (WKT) as a tree of {@link Element}s.
+     * This tree can be given to {@link #buildFromTree(Element)} for producing a geodetic object.
      *
-     * @param  text      the text to be parsed.
-     * @param  position  the position to start parsing from.
-     * @return the parsed object.
+     * @param  text          the text to be parsed.
+     * @param  position      the position to start parsing from.
+     * @param  sharedValues  non-null if parsing a WKT tree to be kept for a long time.
+     *                       In such case, contains values found during parsing of other elements.
+     * @return the parsed object as a tree of {@link Element}s.
      * @throws ParseException if the string can not be parsed.
+     *
+     * @see WKTFormat#textToTree(String, ParsePosition)
      */
-    public Object parseObject(final String text, final ParsePosition position) throws ParseException {
-        warnings = null;
-        ignoredElements.clear();
-        ArgumentChecks.ensureNonEmpty("text", text);
+    final Element textToTree(final String text, final ParsePosition position, final Map<Object,Object> sharedValues)
+            throws ParseException
+    {
+        /*
+         * Aliases for fragments (e.g. "$Foo" in ProjectedCRS["something", $Foo]) are expanded by
+         * the `Element` constructor, except if the alias appears at the begining of the text.
+         * In such case the alias is the whole text and we need a different constructor.
+         */
         Element fragment;
         int lower = CharSequences.skipLeadingWhitespaces(text, position.getIndex(), text.length());
         if (lower < text.length() && text.charAt(lower) == Symbols.FRAGMENT_VALUE) {
             final int upper = endOfFragmentName(text, ++lower);
             final String id = text.substring(lower, upper);
-            fragment = fragments.get(id);
+            fragment = fragments.get(id);                       // Should be immutable.
             if (fragment == null) {
                 position.setErrorIndex(lower);
                 throw new UnparsableObjectException(errorLocale, Errors.Keys.NoSuchValue_1, new Object[] {id}, lower);
             }
             position.setIndex(upper);
-            fragment = new Element(fragment);
+            if (sharedValues == null) {                         // `true` if invoked for immediate parsing.
+                fragment = fragment.modifiable();               // Parsing requires a modifiable copy.
+            }
         } else {
-            fragment = new Element(this, text, position, null);
+            fragment = new Element(this, text, position, sharedValues);
         }
-        final Element element = new Element("<root>", fragment);
-        final Object object = parseObject(element);
-        element.close(ignoredElements);
+        return fragment;
+    }
+
+    /**
+     * Parses a tree of {@link Element}s to produce a geodetic object.
+     * The {@code root} argument should be a value returned by
+     * {@link #textToTree(String, ParsePosition, Map)}.
+     *
+     * @param  root  the tree of WKT elements.
+     * @return the parsed object.
+     * @throws ParseException if the tree can not be parsed.
+     */
+    final Object buildFromTree(Element root) throws ParseException {
+        warnings = null;
+        ignoredElements.clear();
+        root = new Element("<root>", root);
+        final Object object = parseObject(root);
+        root.close(ignoredElements);
+        return object;
+    }
+
+    /**
+     * Parses a <cite>Well Know Text</cite> (WKT) as a geodetic object.
+     *
+     * @param  text      the text to be parsed.
+     * @param  position  the position to start parsing from.
+     * @return the parsed object.
+     * @throws ParseException if the string can not be parsed.
+     */
+    public Object parseObject(final String text, final ParsePosition position) throws ParseException {
+        warnings = null;
+        ignoredElements.clear();
+        ArgumentChecks.ensureNonEmpty("text", text);
+        Element root = textToTree(text, position, null);
+        root = new Element("<root>", root);
+        final Object object = parseObject(root);
+        root.close(ignoredElements);
         return object;
     }
 
     /**
      * Parses the next element in the specified <cite>Well Know Text</cite> (WKT) tree.
+     * Subclasses will typically get the name of the first element and delegate to a specialized method
+     * such as {@code parseAxis(…)}, {@code parseEllipsoid(…)}, {@code parseTimeDatum(…)}, <i>etc</i>.
      *
      * @param  element  the element to be parsed.
      * @return the parsed object.
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/Element.java b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/Element.java
index 137203e..938dcce 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/Element.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/Element.java
@@ -62,7 +62,7 @@ final class Element implements Serializable {
     /**
      * Indirectly for {@link WKTFormat} serialization compatibility.
      */
-    private static final long serialVersionUID = 4048095121452884024L;
+    private static final long serialVersionUID = -7345192763818308443L;
 
     /**
      * Kind of value expected in the element. Value 0 means "not yet determined".
@@ -97,11 +97,16 @@ final class Element implements Serializable {
 
     /**
      * {@code true} if the keyword was not followed by a pair of brackets (e.g. "north").
-     * If {@code true}, then {@link #children} shall be an empty list.
+     * If {@code true}, then {@link #children} shall be an empty list and {@link #isImmutable} should be {@code true}.
      */
     private final boolean isEnumeration;
 
     /**
+     * Whether this element is immutable.
+     */
+    private final boolean isImmutable;
+
+    /**
      * An ordered sequence of {@link String}s, {@link Number}s and other {@link Element}s.
      * Access to this collection should be done using the iterator, not by random access.
      */
@@ -120,29 +125,34 @@ final class Element implements Serializable {
      * @param singleton  the only child for this root.
      */
     Element(final String name, final Element singleton) {
-        keyword     = name;
-        offset      = singleton.offset;
-        errorLocale = singleton.errorLocale;
-        children    = new LinkedList<>();                       // Needs to be a modifiable collection.
-        children.add(singleton);
+        keyword       = name;
+        offset        = singleton.offset;
+        errorLocale   = singleton.errorLocale;
         isEnumeration = false;
+        isImmutable   = false;
+        children      = new LinkedList<>();                     // Needs to be a modifiable collection.
+        children.add(singleton.modifiable());
     }
 
     /**
      * Creates a modifiable copy of the given element.
+     * Modifiable instances are needed by the WKT parser.
+     *
+     * @see #modifiable()
      */
-    Element(final Element toCopy) {
+    private Element(final Element toCopy) {
         offset        = toCopy.offset;
         keyword       = toCopy.keyword;
-        isEnumeration = toCopy.isEnumeration;
         errorLocale   = toCopy.errorLocale;
+        isEnumeration = toCopy.isEnumeration;                   // Should always be `false`.
+        isImmutable   = isEnumeration;
         children      = new LinkedList<>(toCopy.children);      // Needs to be a modifiable collection.
         final ListIterator<Object> it = children.listIterator();
         while (it.hasNext()) {
             final Object value = it.next();
             if (value instanceof Element) {
                 final Element fragment = (Element) value;
-                if (!fragment.isEnumeration) {
+                if (fragment.isImmutable) {
                     it.set(new Element(fragment));
                 }
             }
@@ -150,6 +160,15 @@ final class Element implements Serializable {
     }
 
     /**
+     * Returns a mutable instance of this {@code Element}.
+     * If this element is already modifiable, then it is returned as-is.
+     * If this element is unmodifiable, then a modifiable copy is created.
+     */
+    final Element modifiable() {
+        return isImmutable ? new Element(this) : this;
+    }
+
+    /**
      * Constructs a new {@code Element}.
      * The {@code sharedValues} argument have two meanings:
      *
@@ -166,7 +185,8 @@ final class Element implements Serializable {
      * @param text          the text to parse.
      * @param position      on input, the position where to start parsing from.
      *                      On output, the first character after the separator.
-     * @param sharedValues  if parsing a fragment, a map with the values found in other elements. Otherwise {@code null}.
+     * @param sharedValues  non-null if parsing a WKT tree to be kept for a long time.
+     *                      In such case, contains values found during parsing of other elements.
      */
     Element(final AbstractParser parser, final String text, final ParsePosition position,
             final Map<Object,Object> sharedValues) throws ParseException
@@ -207,8 +227,9 @@ final class Element implements Serializable {
                                 openingBracket = text.codePointAt(lower))) < 0)
         {
             position.setIndex(lower);
-            children = Collections.emptyList();
+            this.children = Collections.emptyList();
             isEnumeration = true;
+            isImmutable   = true;
             return;
         }
         lower = skipLeadingWhitespaces(text, lower + Character.charCount(openingBracket), length);
@@ -240,8 +261,8 @@ final class Element implements Serializable {
                     position.setErrorIndex(lower);
                     throw new UnparsableObjectException(errorLocale, Errors.Keys.NoSuchValue_1, new Object[] {id}, lower);
                 }
-                if (!fragment.isEnumeration) {
-                    fragment = new Element(fragment);
+                if (sharedValues == null) {                         // `true` if created for immediate parsing.
+                    fragment = fragment.modifiable();               // WKT parser needs modifiable elements.
                 }
                 children.add(fragment);
                 lower = upper;
@@ -319,7 +340,7 @@ final class Element implements Serializable {
                     lower = position.getIndex();
                 }
                 /*
-                 * Store the value, using shared instances if this Element may be stored for a long time.
+                 * Store the value, using shared instances if this `Element` may be stored for a long time.
                  */
                 if (sharedValues != null) {
                     final Object e = sharedValues.putIfAbsent(value, value);
@@ -341,12 +362,9 @@ final class Element implements Serializable {
                 final int c = text.codePointAt(lower);
                 if (c == closingBracket) {
                     position.setIndex(lower + Character.charCount(c));
-                    if (sharedValues != null) {
-                        this.children = UnmodifiableArrayList.wrap(children.toArray());
-                    } else {
-                        this.children = children;
-                    }
                     isEnumeration = false;
+                    isImmutable   = (sharedValues != null);
+                    this.children = isImmutable ? UnmodifiableArrayList.wrap(children.toArray()) : children;
                     return;
                 }
                 position.setErrorIndex(lower);
@@ -509,6 +527,34 @@ final class Element implements Serializable {
     //////////////////////////////////////////////////////////////////////////////////////
 
     /**
+     * Returns the last element of the given names without removing it.
+     * This method searches only in children of this element.
+     * It does not search recursively in children of children.
+     *
+     * @param  keys  the element names (e.g. {@code "ID"}).
+     * @return the last {@link Element} of the given names found in the children, or {@code null} if none.
+     *
+     * @see #pullElement(int, String...)
+     */
+    public Element peekLastElement(final String... keys) {
+        final ListIterator<Object> iterator = children.listIterator(children.size());
+        while (iterator.hasPrevious()) {
+            final Object object = iterator.previous();
+            if (object instanceof Element) {
+                final Element element = (Element) object;
+                if (!element.isEnumeration) {
+                    for (int i=0; i<keys.length; i++) {
+                        if (element.keyword.equalsIgnoreCase(keys[i])) {
+                            return element;
+                        }
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
      * Returns the next value (not a child element) without removing it.
      *
      * @return the next value, or {@code null} if none.
@@ -525,6 +571,25 @@ final class Element implements Serializable {
     }
 
     /**
+     * Returns the next values (not child elements) without removing them.
+     * The maximum number of values fetched is the length of the given array.
+     * If there is less WKT elements, remaining array elements are unchanged.
+     *
+     * @param  addTo  non-empty array where to store the values.
+     */
+    public void peekValues(final Object[] addTo) {
+        int count = 0;
+        final Iterator<Object> iterator = children.iterator();
+        while (iterator.hasNext()) {
+            final Object object = iterator.next();
+            if (!(object instanceof Element)) {
+                addTo[count] = object;
+                if (++count >= addTo.length) break;
+            }
+        }
+    }
+
+    /**
      * Removes the next {@link Date} from the children and returns it.
      *
      * @param  key  the parameter name. Used for formatting an error message if no date is found.
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTDictionary.java b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTDictionary.java
new file mode 100644
index 0000000..0f69a1f
--- /dev/null
+++ b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTDictionary.java
@@ -0,0 +1,884 @@
+/*
+ * 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.sis.io.wkt;
+
+import java.util.Locale;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Set;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.stream.Stream;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.io.LineNumberReader;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.text.ParseException;
+import java.text.ParsePosition;
+import org.opengis.util.FactoryException;
+import org.opengis.metadata.Identifier;
+import org.opengis.metadata.citation.Citation;
+import org.opengis.referencing.IdentifiedObject;
+import org.opengis.referencing.NoSuchAuthorityCodeException;
+import org.apache.sis.referencing.factory.GeodeticAuthorityFactory;
+import org.apache.sis.referencing.factory.FactoryDataException;
+import org.apache.sis.internal.referencing.Resources;
+import org.apache.sis.internal.referencing.WKTKeywords;
+import org.apache.sis.internal.util.CollectionsExt;
+import org.apache.sis.internal.util.Constants;
+import org.apache.sis.internal.util.Strings;
+import org.apache.sis.internal.jdk9.JDK9;
+import org.apache.sis.metadata.iso.citation.Citations;
+import org.apache.sis.util.CharSequences;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.Exceptions;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.collection.Containers;
+import org.apache.sis.util.collection.FrequencySortedSet;
+
+
+/**
+ * A factory providing CRS objects parsed from WKT definitions associated to authority codes.
+ * Each WKT definition is associated to a key according the <var>authority:version:code</var>
+ * pattern where <var>code</var> is mandatory and <var>authority:version</var> are optional.
+ * Coordinate Reference Systems or other kinds of objects are created from WKT definitions
+ * when a {@code create(…)} method is invoked for the first time for a given key.
+ *
+ * <p>Newly constructed {@code WKTDictionary} are initially empty. For populating the factory,
+ * the {@link #load(BufferedReader)} or {@link #addDefinitions(Stream)} methods must be invoked.</p>
+ *
+ * <h2>Errors management</h2>
+ * Well-Known Text parsing is performed in two steps, each of them executed at a different time:
+ *
+ * <h3>Early validation</h3>
+ * WKT strings added by {@code load(…)} or {@code addDefinitions(…)} methods are verified
+ * for matching quotes, balanced parenthesis or brackets, and valid number or date formats.
+ * If a syntax error is detected, the loading process is interrupted at the point the error occurred;
+ * CRS definitions after the error location are not loaded.
+ * However WKT keywords and geodetic parameters (e.g. map projections) are not validated at this stage.
+ *
+ * <h3>Late validation</h3>
+ * WKT keywords and geodetic parameters inside WKT elements are validated only when {@link #createObject(String)}
+ * is invoked. If an error occurs at this stage, only the CRS (or other geodetic object) for the code given to
+ * the {@code createFoo(…)} method become invalid. Objects associated to other codes are not impacted.
+ *
+ * <h2>Multi-threading</h2>
+ * This class is thread-safe but not necessarily concurrent.
+ * This class is designed for a relatively small amount of WKT;
+ * it is not a replacement for database-backed factory such as
+ * {@link org.apache.sis.referencing.factory.sql.EPSGFactory}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public class WKTDictionary extends GeodeticAuthorityFactory {
+    /**
+     * The organization or specification that defines the codes recognized by this factory.
+     * May be {@code null} if not yet determined.
+     *
+     * @see #updateAuthority()
+     * @see #getAuthority()
+     */
+    private Citation authority;
+
+    /**
+     * Authorities declared in all {@code "ID[CITATION[…]]"} elements found in WKT definitions.
+     * This set is {@code null} if an {@link #authority} value has been explicitly specified at
+     * construction time. If non-null, this is used for creating a default {@link #authority}.
+     */
+    private final Set<String> authorities;
+
+    /**
+     * Code spaces of authority codes recognized by this factory.
+     * This set is computed from the {@code "ID[…]"} elements found in WKT definitions.
+     *
+     * @see #getCodeSpaces()
+     */
+    private final Set<String> codespaces;
+
+    /**
+     * The parser to use for creating geodetic objects from WKT definitions.
+     * All uses of this parser shall be synchronized by the <code>{@linkplain #lock}.writeLock()</code>.
+     */
+    private final WKTFormat parser;
+
+    /**
+     * The write lock for {@link #parser} and the read/write locks for {@link #definitions} accesses.
+     *
+     * <div class="note"><b>Implementation note:</b>
+     * we manage the locks ourselves instead than using a {@link java.util.concurrent.ConcurrentHashMap}
+     * because if a {@link #definitions} value needs to be computed, then we need to block all other
+     * threads anyway since {@link #parser} is not thread-safe. Consequently the high concurrency
+     * capability provided by {@code ConcurrentHashMap} does not help us in this case.</div>
+     */
+    private final ReadWriteLock lock;
+
+    /**
+     * CRS definitions associated to <var>authority:version:code</var> keys.
+     * Keys are authority codes, ignoring code space (authority) and version.
+     * For example in "EPSG:9.1:4326" the key would be only "4326".
+     * Values can be one of the following 4 types:
+     *
+     * <ol>
+     *   <li>{@link Element}: this is the initial state when there is no duplicated codes.
+     *       This is the root of a tree of WKT keywords with their values as children.
+     *       A tree can be parsed later as an {@link IdentifiedObject} when first requested.</li>
+     *   <li>{@link IdentifiedObject}: the result of parsing the root {@link Element}
+     *       when {@link #createObject(String)} is invoked for a given authority code.
+     *       The parsing result replaces the previous {@link Element} value.</li>
+     *   <li>{@link Disambiguation}: if the same code is used by two or more authorities or versions,
+     *       then above-cited {@link Element} or {@link IdentifiedObject} alternatives are wrapped
+     *       in a {@link Disambiguation} object.</li>
+     *   <li>{@link String} if parsing failed, in which case the string is the error message.</li>
+     * </ol>
+     *
+     * <h4>Synchronization</h4>
+     * All read operations in this map shall be synchronized by the <code>{@linkplain #lock}.readLock()</code>
+     * and write operations synchronized by the <code>{@linkplain #lock}.writeLock()</code>.
+     *
+     * @see #addDefinition(Element)
+     * @see #createObject(String)
+     */
+    private final Map<String,Object> definitions;
+
+    /**
+     * A special kind of value used in the {@link #definitions} map when the same code is used by more
+     * than one authority and version. In the common case where a {@link WKTDictionary} instance
+     * contains definitions for only one namespace and version, this class will never be instantiated.
+     */
+    private static final class Disambiguation {
+        /**
+         * The previous {@code Disambiguation} in a linked list, or {@code null} if we reached the end of list.
+         * The use of a linked list should be efficient enough if the amount of {@code Disambiguation}s for a
+         * given code is small.
+         */
+        private final Disambiguation previous;
+
+        /**
+         * The authority (or other kind of code space) providing CRS definitions.
+         */
+        private final String codespace;
+
+        /**
+         * Version of the CRS definition, or {@code null} if unspecified.
+         */
+        private final String version;
+
+        /**
+         * The value as an {@link Element} before parsing or an {@link IdentifiedObject} after parsing.
+         * They are the kind of types documented in {@link WKTDictionary#definitions}, excluding
+         * other {@code Disambiguation} instances.
+         */
+        Object value;
+
+        /**
+         * Creates a new {@code Disambiguation} instance as a wrapper around the given identifier object.
+         * This constructor may be invoked if {@link WKTDictionary} has been used for creating some
+         * objects before new definitions are added. It should rarely happen.
+         *
+         * @param  object  the CRS (or other geodetic object) to wrap.
+         */
+        Disambiguation(final IdentifiedObject object) {
+            /*
+             * Identifier should never be null because `WKTDictionary` accepts only definitions having
+             * an `ID[…]` or `AUTHORITY[…]` element. A WKT can contain at most one of those elements.
+             */
+            final Identifier id = CollectionsExt.first(object.getIdentifiers());
+            codespace = id.getCodeSpace();
+            version   = id.getVersion();
+            value     = object;
+            previous  = null;
+        }
+
+        /**
+         * Creates a new {@code Disambiguation} instance as a wrapper around the given identifier object.
+         *
+         * @param  object  definition in WKT of the CRS (or other geodetic object) to wrap.
+         * @param  fullId  an array of length 3 to be used for getting the {@code codespace:version:code} tuple.
+         */
+        Disambiguation(final Element object, final Object[] fullId) {
+            Arrays.fill(fullId, null);
+            object.peekLastElement(MathTransformParser.ID_KEYWORDS).peekValues(fullId);
+            codespace = trimOrNull(fullId[0]);
+            version   = trimOrNull(fullId[2]);
+            value     = object;
+            previous  = null;
+        }
+
+        /**
+         * Creates a new {@code Disambiguation} instance identified by {@code codespace:version:code}.
+         *
+         * @param  previous   previous disambiguation, or {@code null} if none.
+         * @param  codespace  the authority (or other kind of code space) providing CRS definitions.
+         * @param  version    version of the CRS definition, or {@code null} if unspecified.
+         * @param  code       code allocated by the authority for the CRS definition.
+         * @param  value      the CRS (or other geodetic object) definition.
+         * @throws IllegalArgumentException if <var>authority:version:code</var> identifier is already used.
+         *
+         * @see WKTDictionary#addDefinition(Element)
+         */
+        Disambiguation(Disambiguation previous, final String codespace, final String version,
+                       final String code, final Element value)
+        {
+            this.previous  = previous;
+            this.codespace = codespace;
+            this.version   = version;
+            this.value     = value;
+            while (previous != null) {
+                if (Strings.equalsIgnoreCase(codespace, previous.codespace) &&
+                    Strings.equalsIgnoreCase(version,   previous.version))
+                {
+                    throw new IllegalArgumentException(Errors.format(
+                            Errors.Keys.DuplicatedIdentifier_1, identifier(code)));
+                }
+                previous = previous.previous;
+            }
+        }
+
+        /**
+         * Finds the {@code Disambiguation} for the given authority and version.
+         *
+         * @param  choices    end of a linked list of {@code Disambiguation}s, or {@code null} if none.
+         * @param  codespace  the authority providing CRS definitions, or {@code null} if unspecified.
+         * @param  version    version of the CRS definition, or {@code null} if unspecified.
+         * @param  code       code allocated by the authority for the CRS definition.
+         * @return container for the given authority and version, or {@code null} if none.
+         * @throws NoSuchAuthorityCodeException if the given authority and version are ambiguous.
+         */
+        static Disambiguation find(Disambiguation choices, final String codespace, final String version, final String code)
+                throws NoSuchAuthorityCodeException
+        {
+            Disambiguation found = null;
+            for (boolean isExact = false; choices != null; choices = choices.previous) {
+                if (codespace == null || codespace.equalsIgnoreCase(choices.codespace)) {
+                    if (Strings.equalsIgnoreCase(version, choices.version)) {
+                        if (!isExact) {
+                            isExact = true;
+                            found = choices;        // Silently discard previous value since we have a better match.
+                            continue;
+                        }
+                    } else if (isExact) {
+                        continue;                   // Ignore this value since previous one was a better match.
+                    }
+                    if (isExact && found != null) {
+                        final String identifier = identifier(codespace, version, code);
+                        throw new NoSuchAuthorityCodeException(Errors.format(Errors.Keys.AmbiguousName_3,
+                                choices.identifier(code), found.identifier(code), identifier),
+                                codespace, code, identifier);
+                    }
+                    found = choices;
+                }
+            }
+            return found;
+        }
+
+        /**
+         * Adds all authority codes to the given set.
+         *
+         * @param  choices  end of a linked list of {@code Disambiguation}s.
+         * @param  code     authority code (code space and version may vary).
+         * @param  addTo    where to add the {@code codespace:version:code} tuples.
+         *
+         * @see WKTDictionary#getAuthorityCodes(Class)
+         */
+        static void list(Disambiguation choices, final String code, final Set<String> addTo) {
+            do {
+                addTo.add(choices.identifier(code));
+                choices = choices.previous;
+            } while (choices != null);
+        }
+
+        /**
+         * Creates an <var>authority:version:code</var> identifier with the given code.
+         * This is used for formatting error messages.
+         */
+        private String identifier(final String code) {
+            return identifier(codespace, version, code);
+        }
+
+        /**
+         * Creates an <var>authority:version:code</var> identifier with the given code.
+         * This is used for formatting error messages.
+         */
+        private static String identifier(final String codespace, final String version, final String code) {
+            return Strings.orEmpty(codespace) + Constants.DEFAULT_SEPARATOR +
+                   Strings.orEmpty(version)   + Constants.DEFAULT_SEPARATOR +
+                   Strings.orEmpty(code);
+        }
+    }
+
+    /**
+     * Creates an initially empty factory. The authority can specified explicitly or inferred from the WKTs.
+     * In the later case (when the given authority is {@code null}), an authority will be inferred from all
+     * {@code ID[…]} or {@code AUTHORITY[…]} elements found in WKT strings as below, in preference order:
+     *
+     * <ol>
+     *   <li>Most frequent {@code CITATION[…]} value.</li>
+     *   <li>If there is no citation, then most frequent code space
+     *       in {@code ID[…]} or {@code AUTHORITY[…]} elements.</li>
+     * </ol>
+     *
+     * The WKT strings are specified by calls to {@link #load(BufferedReader)} or {@link #addDefinitions(Stream)}
+     * after construction.
+     *
+     * @param  authority  organization that defines the codes recognized by this factory, or {@code null}.
+     */
+    public WKTDictionary(final Citation authority) {
+        /*
+         * Note: we do not allow users to specify their own `WKTFormat` instance because current
+         * `WKTDictionary` implementation invokes package-private methods. If user supplies
+         * a `WKTFormat` with overridden public methods, (s)he may be surprised to see that those
+         * methods are not invoked.
+         */
+        definitions = new HashMap<>();
+        codespaces  = new FrequencySortedSet<>(true);
+        parser      = new WKTFormat(null, null);
+        lock        = new ReentrantReadWriteLock();
+        authorities = (authority != null) ? null : new FrequencySortedSet<>(true);
+        this.authority = authority;
+    }
+
+    /**
+     * If {@link #authority} is not yet defined, computes a value from {@code ID[…]} found
+     * in all WKT strings. This method should be invoked after new WKTs have been added.
+     */
+    private void updateAuthority() {
+        if (authorities != null) {
+            String name = CollectionsExt.first(authorities);        // Most frequently declared authority.
+            if (name == null) {
+                name = CollectionsExt.first(codespaces);            // Most frequently declared codespace.
+            }
+            authority = Citations.fromName(name);                   // May still be null.
+        }
+    }
+
+    /**
+     * Keyword recognized by {@link #load(BufferedReader)}.
+     */
+    private static final String SET = "SET";
+
+    /**
+     * Adds to this factory all definitions read from the given source.
+     * Each Coordinate Reference System (or other geodetic object) is defined by a string in WKT format.
+     * The key associated to each object is given by the {@code ID[…]} or {@code AUTHORITY[…]} element,
+     * which is typically the last element of a WKT string and is mandatory for definitions in this file.
+     *
+     * <p>WKT strings can span many lines. All lines after the first line shall be indented with at least
+     * one white space. Non-indented lines start new definitions.</p>
+     *
+     * <p>Blank lines and lines starting with the {@code #} character (ignoring white spaces) are ignored.</p>
+     *
+     * <h4>Aliases for WKT fragments</h4>
+     * Files with more than one WKT definition tend to repeat the same WKT fragments many times.
+     * For example the same {@code BaseGeogCRS[…]} element may be repeated in every {@code ProjectedCRS} definitions.
+     * Redundant fragments can be replaced by aliases for making the file more compact,
+     * easier to read, faster to parse and with smaller memory footprint.
+     *
+     * <p>Each line starting with "<code>SET &lt;<var>identifier</var>&gt;=&lt;<var>WKT</var>&gt;</code>"
+     * defines an alias for a fragment of WKT string. The WKT can span many lines as described above.
+     * Aliases are local to the file where they are defined.
+     * Aliases can be expanded in other WKT strings by "<code>$&lt;<var>identifier</var>&gt;</code>".</p>
+     *
+     * <h4>Validation</h4>
+     * This method verifies that definitions have matching quotes, balanced parenthesis or brackets,
+     * and valid number or date formats. It does not verify WKT keywords or geodetic parameters.
+     * See class javadoc for more details.
+     *
+     * <h4>Example</h4>
+     * An example is <a href="./doc-files/ESRI.txt">available here</a>.
+     *
+     * @param  source  the source of WKT definitions.
+     * @throws FactoryException if the definition file can not be read.
+     */
+    public void load(final BufferedReader source) throws FactoryException {
+        ArgumentChecks.ensureNonNull("source", source);
+        lock.writeLock().lock();
+        try {
+            final Loader loader = new Loader(source);
+            try {
+                loader.read();
+            } catch (IOException e) {
+                throw new FactoryException(loader.canNotRead(null, e), e);
+            } catch (ParseException | IllegalArgumentException e) {
+                throw new FactoryDataException(loader.canNotRead(null, e), e);
+            } finally {
+                loader.restore();
+                updateAuthority();
+            }
+        } finally {
+            lock.writeLock().unlock();
+        }
+    }
+
+    /**
+     * Implementation of {@link WKTDictionary#load(BufferedReader)} method.
+     * Caller must own the write lock before to instantiate and use this class.
+     */
+    private final class Loader {
+        /** The source of WKT definitions. */
+        private final BufferedReader source;
+
+        /** Temporary buffer where to put the WKT to parse. */
+        private final StringBuilder buffer;
+
+        /** If the WKT being parsed is an alias, the alias key. Otherwise {@code null}. */
+        private String aliasKey;
+
+        /** Argument for {@link #addAliasOrDefinition()}. */
+        private final ParsePosition pos;
+
+        /** Zero-based number of current line. Equivalent to {@link LineNumberReader#getLineNumber()}. */
+        private int lineNumber;
+
+        /** Aliases that existed before in {@link #parser} before loading started. */
+        private final Set<String> aliases;
+
+        /** Creates a new loader. */
+        Loader(final BufferedReader source) {
+            this.source = source;
+            buffer  = new StringBuilder(500);
+            pos     = new ParsePosition(0);
+            aliases = new HashSet<>(parser.getFragmentNames());
+        }
+
+        /**
+         * Restores {@link #parser} to its initial state. This method should be invoked
+         * in a finally block regardless if the parsing succeeded or failed.
+         */
+        final void restore() {
+            parser.getFragmentNames().retainAll(aliases);
+            parser.clear();
+        }
+
+        /**
+         * Returns an error message saying "Can not read WKT at line X". The message is followed
+         * by a "Caused by" phrase specified either as a string or an exception. At least one of
+         * {@code cause} and {@code e} shall be non-null.
+         */
+        final String canNotRead(String cause, final Exception e) {
+            final Locale locale = parser.getErrorLocale();
+            if (cause == null) {
+                cause = Exceptions.getLocalizedMessage(e, locale);
+            }
+            return Resources.forLocale(locale).getString(Resources.Keys.CanNotParseWKT_2, getLineNumber(), cause);
+        }
+
+        /**
+         * Returns the one-based line number of the last line read.
+         * Actually this method returns the zero-based line number of current position,
+         * but since current position is after the last line read, this is equivalent
+         * to line number of last line read + 1.
+         *
+         * @return one-based line number of current position.
+         */
+        private int getLineNumber() {
+            if (source instanceof LineNumberReader) {
+                // In case an unusual implementation counts lines in a different way than we do.
+                lineNumber = ((LineNumberReader) source).getLineNumber();
+            }
+            return lineNumber;
+        }
+
+        /**
+         * Adds to the enclosing factory all definitions read from the given source.
+         * See {@link WKTDictionary#load(BufferedReader)} for a format description.
+         *
+         * @throws IOException if an error occurred while reading lines.
+         * @throws ParseException if an error occurred while parsing a WKT.
+         * @throws FactoryDataException if the file has a syntax error.
+         * @throws IllegalArgumentException if a {@code codespace:version:code} tuple or an alias is assigned twice.
+         */
+        final void read() throws IOException, ParseException, FactoryDataException {
+            final String lineSeparator = System.lineSeparator();
+            int indentation = 0;
+            String line;
+            while ((line = source.readLine()) != null) {
+                lineNumber++;
+                final int length = line.length();
+                int defStart = CharSequences.skipLeadingWhitespaces(line, 0, length);
+                if (defStart < length && line.charAt(defStart) == '#') continue;        // Skip comment lines.
+                /*
+                 * If the line is indented compared to the first line, we presume that it is the continuation
+                 * of previous line and skip the check for "SET" keyword. If the line is not indented,
+                 * previous buffer content need to be parsed before we start a new WKT definition.
+                 */
+                if (defStart > indentation) {
+                    defStart = indentation;
+                } else {
+                    addAliasOrDefinition();
+                    indentation = defStart;
+                    if (line.regionMatches(true, defStart, SET, 0, SET.length())) {
+                        final int keyStart = CharSequences.skipLeadingWhitespaces(line, defStart + SET.length(), length);
+                        if (keyStart > defStart) {             // `true` if "SET" is followed by at least one white space.
+                            defStart = line.indexOf('=', keyStart);
+                            if (defStart <= keyStart) {
+                                throw new FactoryDataException(resources().getString(
+                                            Resources.Keys.SyntaxErrorForAlias_1, getLineNumber()));
+                            }
+                            final int keyEnd = CharSequences.skipTrailingWhitespaces(line, keyStart, defStart);
+                            defStart = CharSequences.skipLeadingWhitespaces(line, defStart + 1, length);
+                            final String key = line.substring(keyStart, keyEnd);
+                            if (!CharSequences.isUnicodeIdentifier(key)) {
+                                String c = parser.errors().getString(Errors.Keys.NotAUnicodeIdentifier_1, key);
+                                throw new FactoryDataException(canNotRead(c, null));
+                            }
+                            aliasKey = key;
+                        }
+                    }
+                }
+                /*
+                 * Copy non-empty lines in the buffer, omitting indentation and trailing spaces.
+                 * The leading spaces after indentation are kept in order to have a more readable
+                 * WKT string in error message if parsing fail.
+                 */
+                final int end = CharSequences.skipTrailingWhitespaces(line, defStart, length);
+                if (defStart < end) {
+                    if (buffer.length() != 0) buffer.append(lineSeparator);
+                    buffer.append(line, defStart, end);
+                }
+            }
+            addAliasOrDefinition();
+        }
+
+        /**
+         * Parses the current {@link #buffer} content as a WKT elements (possibly with children elements).
+         * This method does not build the full {@link IdentifiedObject}; this later part will be done only
+         * when first needed.
+         *
+         * <p>If {@link #aliasKey} is non-null, the first WKT is taken as a {@linkplain WKTFormat#addFragment
+         * fragment} associated to the given alias. All other WKT (if any) are taken as definitions of CRS or
+         * other objects.</p>
+         *
+         * @throws ParseException if an error occurred while parsing the WKT string.
+         * @throws FactoryDataException if there is unparsed text after the WKT.
+         * @throws IllegalArgumentException if a {@code codespace:version:code} tuple or an alias is assigned twice.
+         */
+        private void addAliasOrDefinition() throws ParseException, FactoryDataException {
+            if (buffer.length() != 0) {
+                pos.setIndex(0);
+                final String wkt = buffer.toString();
+                final Element root = parser.textToTree(wkt, pos);
+                final int end = pos.getIndex();
+                if (end < wkt.length()) {           // Trailing white spaces already removed by `read(…)`.
+                    throw new FactoryDataException(resources().getString(Resources.Keys.UnexpectedTextAtLine_2,
+                                getLineNumber(), CharSequences.token(wkt, end)));
+                }
+                if (aliasKey != null) {
+                    parser.addFragment(aliasKey, root);
+                    aliasKey = null;
+                } else {
+                    addDefinition(root);
+                }
+                buffer.setLength(0);
+            }
+        }
+    }
+
+    /**
+     * Adds the definition of a CRS (or other geodetic objects) from a tree of WKT elements.
+     * The authority code is inferred from the {@code ID[…]} or {@code AUTHORITY[…]} element.
+     * Caller must own the write lock before to invoke this method.
+     * {@link #updateAuthority()} should be invoked after this method.
+     *
+     * @param  root  root of a tree of WKT elements.
+     * @throws IllegalArgumentException if a {@code codespace:version:code} tuple is assigned twice.
+     * @throws FactoryDataException if the WKT does not have an {@code ID[…]} or {@code AUTHORITY[…]} element.
+     *
+     * @see #definitions
+     */
+    private void addDefinition(final Element root) throws FactoryDataException {
+        final Element ide = root.peekLastElement(MathTransformParser.ID_KEYWORDS);
+        if (ide != null) {
+            final Object[] fullId = new Object[3];                      // Codespace, code, version.
+            ide.peekValues(fullId);
+            final String codespace = trimOrNull(fullId[0]);
+            final String code      = trimOrNull(fullId[1]);
+            if (code != null) {                                         // Needs at least the code.
+                definitions.merge(code, root, (oldValue, newValue) -> {
+                    final String version = trimOrNull(fullId[2]);
+                    final Disambiguation previous;
+                    if (oldValue instanceof Disambiguation) {
+                        previous = (Disambiguation) oldValue;
+                    } else if (oldValue instanceof Element) {
+                        previous = new Disambiguation((Element) oldValue, fullId);
+                    } else if (oldValue instanceof IdentifiedObject) {
+                        previous = new Disambiguation((IdentifiedObject) oldValue);
+                    } else {
+                        previous = null;        // Discard previous parsing failure.
+                    }
+                    return new Disambiguation(previous, codespace, version, code, (Element) newValue);
+                });
+                codespaces.add(codespace);
+                if (authorities != null) {
+                    final Element citation = ide.peekLastElement(WKTKeywords.Citation);
+                    if (citation != null) {
+                        final String title = trimOrNull(citation.peekValue());
+                        if (title != null) {
+                            authorities.add(title);
+                        }
+                    }
+                }
+                return;
+            }
+        }
+        throw new FactoryDataException(resources().getString(Resources.Keys.MissingAuthorityCode_1, root.peekValue()));
+    }
+
+    /**
+     * Adds definitions of CRS (or other geodetic objects) from Well-Known Texts. Blank strings are ignored.
+     * Each non-blank {@link String} shall contain the complete definition of at least one geodetic object.
+     * More than one geodetic object can appear in the same {@link String} if they are separated by spaces
+     * or line separators. However the same geodetic object can not have its definition splitted in two or
+     * more {@link String}s.
+     *
+     * <p>The key associated to each object is given by the {@code ID[…]} or {@code AUTHORITY[…]} element,
+     * which is typically the last element of a WKT string and is mandatory. WKT strings can contain line
+     * separators for human readability.</p>
+     *
+     * @param  objects  CRS (or other geodetic objects) definitions as WKT strings.
+     * @throws FactoryException if a WKT can not be parsed, or does not contain an {@code ID[…]} or
+     *         {@code AUTHORITY[…]} element, or if the same {@code codespace:version:code} tuple is
+     *         used for two objects.
+     */
+    public void addDefinitions(final Stream<String> objects) throws FactoryException {
+        ArgumentChecks.ensureNonNull("objects", objects);
+        /*
+         * We work with iterator because we do not support parallelism yet.
+         * However a future version may support that, which is why argument
+         * type is a `Stream`.
+         */
+        final Iterator<String> it = objects.iterator();
+        final ParsePosition pos = new ParsePosition(0);
+        lock.writeLock().lock();
+        try {
+            int lineNumber = 1;
+            try {
+                while (it.hasNext()) {
+                    final String wkt = it.next();
+                    final int length = CharSequences.skipTrailingWhitespaces(wkt, 0, wkt.length());
+                    while (pos.getIndex() < length) {
+                        addDefinition(parser.textToTree(wkt, pos));
+                    }
+                    pos.setIndex(0);
+                    lineNumber++;
+                }
+            } catch (ParseException | IllegalArgumentException e) {
+                throw new FactoryDataException(resources().getString(
+                        Resources.Keys.CanNotParseWKT_2, lineNumber, e.getLocalizedMessage()));
+            } finally {
+                updateAuthority();
+            }
+        } finally {
+            lock.writeLock().unlock();
+        }
+    }
+
+    /**
+     * Convenience methods for resources in the language used for error messages.
+     */
+    private Resources resources() {
+        return Resources.forLocale(parser.getErrorLocale());
+    }
+
+    /**
+     * Trims the leading and trailing spaces of the string representation of given object.
+     * If null, empty or contains only spaces, then this method returns {@code null}.
+     */
+    private static String trimOrNull(final Object value) {
+        return (value != null) ? Strings.trimOrNull(value.toString()) : null;
+    }
+
+    /**
+     * Returns the authority or specification that defines the codes recognized by this factory.
+     * This is the first of the following values, in preference order:
+     *
+     * <ol>
+     *   <li>The authority explicitly specified at construction time.</li>
+     *   <li>A citation built from the most frequent value found in {@code CITATION} elements.</li>
+     *   <li>A citation built from the most frequent value found in {@code ID} or {@code AUTHORITY} elements.</li>
+     * </ol>
+     *
+     * @return the organization responsible for CRS definitions, or {@code null} if unknown.
+     */
+    @Override
+    public Citation getAuthority() {
+        return authority;
+    }
+
+    /**
+     * Returns all namespaces recognized by this factory. Those namespaces can appear before codes in
+     * calls to {@code createFoo(String)} methods, for example {@code "ESRI"} in {@code "ESRI:102018"}.
+     * Namespaces are case-insensitive.
+     *
+     * @return the namespaces recognized by this factory.
+     */
+    @Override
+    public Set<String> getCodeSpaces() {
+        lock.readLock().lock();
+        try {
+            return JDK9.copyOf(codespaces);
+        } finally {
+            lock.readLock().unlock();
+        }
+    }
+
+    /**
+     * Returns an arbitrary object from a code.
+     *
+     * @param  code  value allocated by authority.
+     * @return the object for the given code.
+     * @throws NoSuchAuthorityCodeException if the specified {@code code} was not found.
+     * @throws FactoryException if the object creation failed for some other reason.
+     */
+    @Override
+    public IdentifiedObject createObject(final String code) throws FactoryException {
+        /*
+         * Separate the authority from the rest of the code. The CharSequences.skipWhitespaces(…)
+         * methods are robust to negative index and will work even if code.indexOf(…) returned -1.
+         */
+        String codespace = null;
+        String version   = null;
+        String localCode = code;
+        int afterAuthority = code.indexOf(Constants.DEFAULT_SEPARATOR);
+        int end = CharSequences.skipTrailingWhitespaces(code, 0, afterAuthority);
+        int start = CharSequences.skipLeadingWhitespaces(code, 0, end);
+        if (start < end) {
+            codespace = code.substring(start, end);
+            /*
+             * Separate the version from the rest of the code. The version is optional. The code may have no room
+             * for version (e.g. "EPSG:4326"), or specify an empty version (e.g. "EPSG::4326"). If the version is
+             * equals to an empty string, it will be considered as no version.
+             */
+            int afterVersion = code.indexOf(Constants.DEFAULT_SEPARATOR, ++afterAuthority);
+            start = CharSequences.skipLeadingWhitespaces(code, afterAuthority, afterVersion);
+            end = CharSequences.skipTrailingWhitespaces(code, start, afterVersion++);
+            if (start < end) {
+                version = code.substring(start, end);
+            }
+            start = Math.max(afterAuthority, afterVersion);
+            end = code.length();
+            localCode = CharSequences.trimWhitespaces(code, start, end).toString();
+        }
+        /*
+         * At this point we separated codespace, code and version. First, verify that codespace is valid.
+         * Then get CRS definition as an `IdentifiedObject` or an `Element` (the `Disambiguation` case is
+         * resolved as an `IdentifiedObject` or `Element`).
+         */
+        Disambiguation choices = null;
+        Object value = null;
+        lock.readLock().lock();
+        try {
+            boolean valid = (codespace == null || codespace.isEmpty() || codespaces.contains(codespace));
+            if (!valid) {
+                for (final String cs : codespaces) {            // More costly check if no exact match.
+                    valid = cs.equalsIgnoreCase(codespace);
+                    if (valid) break;
+                }
+            }
+            if (valid) {
+                value = definitions.get(localCode);
+                if (value instanceof Disambiguation) {
+                    choices = Disambiguation.find((Disambiguation) value, codespace, version, localCode);
+                    value = (choices != null) ? choices.value : null;
+                }
+            }
+        } finally {
+            lock.readLock().unlock();
+        }
+        if (value == null) {
+            throw new NoSuchAuthorityCodeException(parser.errors().getString(
+                    Errors.Keys.NoSuchValue_1, code), codespace, localCode, code);
+        }
+        /*
+         * At this point we got a value which may be one of the following classes:
+         *
+         *   - `Element`          — if this method is invoked for the first time for the given code.
+         *   - `IdentifiedObject` — if we already built the geodetic object in a previous invocation of this method.
+         *   - `String`           — if a previous invocation for given code failed to build the geodetic object.
+         *                          In this case, the string is the exception message.
+         *
+         * If `Element`, try to replace that value by an `IdentifiedObject` (on success) or `String` (on failure).
+         * Must be done under write lock because `parser` is not thread-safe.
+         */
+        if (value instanceof Element) {
+            lock.writeLock().lock();
+            try {
+                if (choices != null) {
+                    value = choices.value;              // Check again in case value has been computed concurrently.
+                } else {
+                    value = definitions.get(localCode);
+                }
+                if (value instanceof Element) {
+                    ParseException cause = null;
+                    try {
+                        value = parser.parse((Element) value);
+                    } catch (ParseException e) {
+                        cause = e;
+                        value = e.getLocalizedMessage();
+                        if (value == null) {
+                            value = e.getClass().getSimpleName();
+                        }
+                    }
+                    if (choices != null) {
+                        choices.value = value;          // Save result for future uses.
+                    } else {
+                        definitions.put(localCode, value);
+                    }
+                    if (cause != null) {
+                        throw new FactoryException(resources().getString(
+                                Resources.Keys.CanNotInstantiateGeodeticObject_1, code), cause);
+                    }
+                }
+            } finally {
+                lock.writeLock().unlock();
+            }
+        }
+        if (value instanceof IdentifiedObject) {
+            return (IdentifiedObject) value;
+        } else {
+            // Exception message saved in a previous invocation of this method.
+            throw new FactoryException(String.valueOf(value));
+        }
+    }
+
+    /**
+     * Returns the set of authority codes for objects of the given type.
+     * The {@code type} argument specifies the base type of identified objects.
+     *
+     * @param  type  the spatial reference objects type.
+     * @return the set of authority codes for spatial reference objects of the given type.
+     * @throws FactoryException if an error occurred while fetching the codes.
+     */
+    @Override
+    public Set<String> getAuthorityCodes(Class<? extends IdentifiedObject> type) throws FactoryException {
+        final Set<String> codes = new HashSet<>(Containers.hashMapCapacity(definitions.size()));
+        for (final Map.Entry<String,Object> entry : definitions.entrySet()) {
+            final String code  = entry.getKey();
+            final Object value = entry.getValue();
+            if (value instanceof Disambiguation) {
+                Disambiguation.list((Disambiguation) value, code, codes);
+            } else {
+                codes.add(code);
+            }
+        }
+        return codes;
+    }
+}
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTFormat.java b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTFormat.java
index f9ab3e9..6b03397 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTFormat.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/WKTFormat.java
@@ -340,7 +340,7 @@ public class WKTFormat extends CompoundFormat<Object> {
      *
      * @see #errors()
      */
-    private Locale getErrorLocale() {
+    final Locale getErrorLocale() {
         final Locale locale = getLocale(Locale.Category.DISPLAY);
         return (locale != null && locale != Locale.ROOT) ? locale : Locale.getDefault(Locale.Category.DISPLAY);
     }
@@ -757,22 +757,32 @@ public class WKTFormat extends CompoundFormat<Object> {
     public void addFragment(final String name, final String wkt) throws IllegalArgumentException, ParseException {
         ArgumentChecks.ensureNonEmpty("wkt", wkt);
         ArgumentChecks.ensureNonEmpty("name", name);
-        short error = Errors.Keys.NotAUnicodeIdentifier_1;
-        if (CharSequences.isUnicodeIdentifier(name)) {
-            final ParsePosition pos = new ParsePosition(0);
-            final Element element = parseFragment(wkt, pos);
-            final int index = CharSequences.skipLeadingWhitespaces(wkt, pos.getIndex(), wkt.length());
-            if (index < wkt.length()) {
-                throw new UnparsableObjectException(getErrorLocale(), Errors.Keys.UnexpectedCharactersAfter_2,
-                        new Object[] {name + " = " + element.keyword + "[…]", CharSequences.token(wkt, index)}, index);
-            }
-            // `fragments` map has been created by `parser(true)`.
-            if (fragments.putIfAbsent(name, element) == null) {
-                return;
-            }
-            error = Errors.Keys.ElementAlreadyPresent_1;
+        if (!CharSequences.isUnicodeIdentifier(name)) {
+            throw new IllegalArgumentException(errors().getString(Errors.Keys.NotAUnicodeIdentifier_1, name));
+        }
+        final ParsePosition pos = new ParsePosition(0);
+        final Element element = textToTree(wkt, pos);
+        final int length = wkt.length();
+        final int index = CharSequences.skipLeadingWhitespaces(wkt, pos.getIndex(), length);
+        if (index < length) {
+            throw new UnparsableObjectException(getErrorLocale(), Errors.Keys.UnexpectedCharactersAfter_2,
+                    new Object[] {name + " = " + element.keyword + "[…]", CharSequences.token(wkt, index)}, index);
+        }
+        addFragment(name, element);
+    }
+
+    /**
+     * Adds a fragment of Well Know Text (WKT).
+     * Caller must have verified that {@code name} is a valid Unicode identifier.
+     *
+     * @param  name     the Unicode identifier to assign to the WKT fragment.
+     * @param  element  root of the WKT fragment to add.
+     * @throws IllegalArgumentException if a fragment is already associated to the given name.
+     */
+    final void addFragment(final String name, final Element element) {
+        if (fragments(true).putIfAbsent(name, element) != null) {
+            throw new IllegalArgumentException(errors().getString(Errors.Keys.ElementAlreadyPresent_1, name));
         }
-        throw new IllegalArgumentException(errors().getString(error, name));
     }
 
     /**
@@ -782,11 +792,19 @@ public class WKTFormat extends CompoundFormat<Object> {
      * @param  pos  index of the first character to parse (on input) or after last parsed character (on output).
      * @return root of the tree of elements.
      */
-    final Element parseFragment(final String wkt, final ParsePosition pos) throws ParseException {
+    final Element textToTree(final String wkt, final ParsePosition pos) throws ParseException {
         if (sharedValues == null) {
             sharedValues = new HashMap<>();
         }
-        return new Element(parser(true), wkt, pos, sharedValues);
+        return parser(true).textToTree(wkt, pos, sharedValues);
+    }
+
+    /**
+     * Clears warnings and cache of shared values.
+     */
+    final void clear() {
+        warnings = null;
+        sharedValues = null;
     }
 
     /**
@@ -802,17 +820,36 @@ public class WKTFormat extends CompoundFormat<Object> {
      */
     @Override
     public Object parse(final CharSequence wkt, final ParsePosition pos) throws ParseException {
-        warnings = null;
-        sharedValues = null;
+        clear();
         ArgumentChecks.ensureNonEmpty("wkt", wkt);
         ArgumentChecks.ensureNonNull ("pos", pos);
         final AbstractParser parser = parser(false);
         Object object = null;
         try {
-            return object = parser.parseObject(wkt.toString(), pos);
+            object = parser.parseObject(wkt.toString(), pos);
         } finally {
             warnings = parser.getAndClearWarnings(object);
         }
+        return object;
+    }
+
+    /**
+     * Creates an object from the given tree of WKT elements.
+     *
+     * @param  root  the tree of WKT elements.
+     * @return the parsed object (never {@code null}).
+     * @throws ParseException if an error occurred while parsing the WKT.
+     */
+    final Object parse(final Element root) throws ParseException {
+        clear();
+        final AbstractParser parser = parser(false);
+        Object object = null;
+        try {
+            object = parser.buildFromTree(root);
+        } finally {
+            warnings = parser.getAndClearWarnings(object);
+        }
+        return object;
     }
 
     /**
@@ -822,6 +859,10 @@ public class WKTFormat extends CompoundFormat<Object> {
      */
     private AbstractParser parser(final boolean modifiable) {
         AbstractParser parser = this.parser;
+        /*
+         * `parser` is always null on a fresh clone. However the `fragments`
+         * map may need to be cloned if the caller intents to modify it.
+         */
         if (parser == null || (isCloned & modifiable)) {
             this.parser = parser = new Parser(symbols, fragments(modifiable),
                     (NumberFormat) getFormat(Number.class),
@@ -871,7 +912,7 @@ public class WKTFormat extends CompoundFormat<Object> {
      */
     @Override
     public void format(final Object object, final Appendable toAppendTo) throws IOException {
-        warnings = null;
+        clear();
         ArgumentChecks.ensureNonNull("object",     object);
         ArgumentChecks.ensureNonNull("toAppendTo", toAppendTo);
         /*
@@ -970,7 +1011,7 @@ public class WKTFormat extends CompoundFormat<Object> {
     /**
      * Convenience methods for resources for error message in the locale given by {@link #getLocale()}.
      */
-    private Errors errors() {
+    final Errors errors() {
         return Errors.getResources(getErrorLocale());
     }
 
@@ -983,11 +1024,10 @@ public class WKTFormat extends CompoundFormat<Object> {
     @Override
     public WKTFormat clone() {
         final WKTFormat clone = (WKTFormat) super.clone();
-        clone.sharedValues = null;
+        clone.clear();
         clone.factories    = null;                              // Not thread-safe; clone needs its own.
         clone.formatter    = null;                              // Do not share the formatter.
         clone.parser       = null;
-        clone.warnings     = null;
         clone.isCloned = isCloned = true;
         // Symbols and Colors do not need to be cloned because they are flagged as immutable.
         return clone;
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/doc-files/ESRI.txt b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/doc-files/ESRI.txt
new file mode 100644
index 0000000..c1b56f3
--- /dev/null
+++ b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/doc-files/ESRI.txt
@@ -0,0 +1,69 @@
+#
+# Example file for org.apache.sis.io.wkt.ReferencingFactory.
+# This file provides Coordinate Reference System definitions
+# in Well-Known Text (WKT) format. One or many files can be
+# used for extending the set of CRS with custom definitions.
+#
+# Each Coordinate Reference System (or other geodetic object)
+# is defined by a string in WKT format. The key associated to
+# each object is given by the ID[…] or AUTHORITY[…] element,
+# which is typically the last element of a WKT string and is
+# mandatory for definitions in this file.
+#
+# WKT strings can span many lines. All lines after the first
+# line shall be indented with at least one white space.
+# Non-indented lines start new definitions.
+#
+# All lines starting with the # character are comment lines.
+#
+# ---- Aliases for WKT fragments ---------------------------
+# Files with more than one WKT definition tend to repeat the
+# same WKT fragments many times, e.g. the same BaseGeogCRS[…]
+# element may be repeated in every ProjectedCRS definitions.
+# Redundant fragments can be replaced by aliases for making
+# the file more compact, easier to read, faster to parse and
+# with smaller memory footprint.
+#
+# Each line starting with "SET <identifier>=<WKT>" defines
+# an alias for a fragment of WKT string.  The WKT can span
+# many lines as described above.  Aliases are local to the
+# file where they are defined.  Aliases can be expanded in
+# other WKT strings by "$<identifier>".
+#
+
+#
+# Alias for WGS84 geographic CRS.
+# Can be inserted in projected CRS with "$WGS84".
+#
+SET WGS84 =
+ BaseGeodCRS["GCS_WGS_1984",
+  Datum["D_WGS_1984",
+   Ellipsoid["WGS_1984", 6378137, 298.257223563]],
+  AngleUnit["Degree", 0.0174532925199433]]
+
+#
+# Derived from https://github.com/Esri/projection-engine-db-doc
+# with base CRS replaced by alias and parameter values omitted
+# when they have the default value.
+#
+ProjectedCRS["North_Pole_Stereographic",
+ $WGS84,
+ Conversion["Stereographic North Pole",
+  Method["Polar Stereographic (variant A)"],
+  Parameter["Latitude of natural origin", 90]],
+ CS[Cartesian, 2],
+  Axis["Easting (E)", east],
+  Axis["Northing (N)", north],
+  Unit["metre", 1],
+ Id["ESRI", 102018]]
+
+ProjectedCRS["South_Pole_Stereographic",
+ $WGS84,
+ Conversion["Stereographic South Pole",
+  Method["Polar Stereographic (variant A)"],
+  Parameter["Latitude of natural origin", -90]],
+ CS[Cartesian, 2],
+  Axis["Easting (E)", east],
+  Axis["Northing (N)", north],
+  Unit["metre", 1],
+ Id["ESRI", 102021]]
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/package-info.java b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/package-info.java
index 2f21e4c..169fa18 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/package-info.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/package-info.java
@@ -64,7 +64,7 @@
  * The {@link org.apache.sis.geometry.GeneralEnvelope} and {@link org.apache.sis.geometry.GeneralDirectPosition} classes
  * provide their own, limited, WKT parsing and formatting services for the {@code BOX} and {@code POINT} elements.
  * A description for this WKT format can be found on
- * <a href="https://en.wikipedia.org/wiki/Well-known_text_representation_of_coordinate_reference_systems">Wikipedia</a>.
+ * <a href="https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry">Wikipedia</a>.
  *
  * <h2>Where to find WKT examples</h2>
  * An excellent source of well-formed WKT is the online <cite>EPSG Geodetic Parameter Registry</cite>.
@@ -83,7 +83,7 @@
  * @author  Martin Desruisseaux (IRD, Geomatys)
  * @author  Rémi Eve (IRD)
  * @author  Rueben Schulz (UBC)
- * @version 1.0
+ * @version 1.1
  *
  * @see <a href="http://docs.opengeospatial.org/is/12-063r5/12-063r5.html">WKT 2 specification</a>
  * @see <a href="http://www.geoapi.org/3.0/javadoc/org/opengis/referencing/doc-files/WKT.html">Legacy WKT 1</a>
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/io/wkt/WKTDictionaryTest.java b/core/sis-referencing/src/test/java/org/apache/sis/io/wkt/WKTDictionaryTest.java
new file mode 100644
index 0000000..1e32651
--- /dev/null
+++ b/core/sis-referencing/src/test/java/org/apache/sis/io/wkt/WKTDictionaryTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.sis.io.wkt;
+
+import java.util.Arrays;
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.IOException;
+import org.opengis.util.FactoryException;
+import org.opengis.referencing.crs.SingleCRS;
+import org.opengis.referencing.crs.ProjectedCRS;
+import org.opengis.referencing.crs.GeographicCRS;
+import org.opengis.referencing.cs.AxisDirection;
+import org.apache.sis.metadata.iso.citation.Citations;
+import org.apache.sis.test.DependsOn;
+import org.apache.sis.test.TestCase;
+import org.junit.Test;
+
+import static org.apache.sis.test.Assert.*;
+
+
+/**
+ * Tests {@link WKTDictionary}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+@DependsOn(WKTFormatTest.class)
+public final strictfp class WKTDictionaryTest extends TestCase {
+    /**
+     * Tests {@link WKTDictionary#load(BufferedReader)}.
+     *
+     * @throws IOException if an error occurred while reading the test file.
+     * @throws FactoryException if an error occurred while parsing a WKT.
+     */
+    @Test
+    public void testLoad() throws IOException, FactoryException {
+        final WKTDictionary factory = new WKTDictionary(null);
+        try (BufferedReader source = new BufferedReader(new InputStreamReader(
+                WKTFormatTest.class.getResourceAsStream("ExtraCRS.txt"), "UTF-8")))
+        {
+            factory.load(source);
+        }
+        /*
+         * ESRI code space should be fist because it is the most frequently used
+         * in the test file. The authority should be "ESRI" for the same reason.
+         * Codes can be in any order.
+         */
+        assertArrayEquals("getCodeSpaces()", new String[] {"ESRI", "MyCodeSpace"}, factory.getCodeSpaces().toArray());
+        assertSame("getAuthority()", Citations.ESRI, factory.getAuthority());
+        assertSetEquals(Arrays.asList("102018", "ESRI::102021", "MyCodeSpace::102021", "MyCodeSpace:v2:102021"),
+                factory.getAuthorityCodes(SingleCRS.class));
+        /*
+         * Tests CRS creation.
+         */
+        verifyCRS(factory.createProjectedCRS(     "102018"), "North_Pole_Stereographic", +90);
+        verifyCRS(factory.createProjectedCRS("ESRI:102021"), "South_Pole_Stereographic", -90);
+        verifyCRS(factory.createGeographicCRS("MyCodeSpace::102021"), "Anguilla 1957");
+        verifyCRS(factory.createGeographicCRS("MyCodeSpace:v2:102021"), "Anguilla 1957 (bis)");
+    }
+
+    /**
+     * Verifies a projected CRS.
+     *
+     * @param crs   the CRS to verify.
+     * @param name  expected CRS name.
+     * @param φ0    expected latitude of origin.
+     */
+    private static void verifyCRS(final ProjectedCRS crs, final String name, final double φ0) {
+        assertEquals("name", name, crs.getName().getCode());
+        assertAxisDirectionsEqual(name, crs.getCoordinateSystem(),
+                                  AxisDirection.EAST, AxisDirection.NORTH);
+        assertEquals("φ0", φ0, crs.getConversionFromBase().getParameterValues()
+                                  .parameter("Latitude of natural origin").doubleValue(), STRICT);
+    }
+
+    /**
+     * Verifies a geographic CRS.
+     *
+     * @param crs   the CRS to verify.
+     * @param name  expected CRS name.
+     */
+    private static void verifyCRS(final GeographicCRS crs, final String name) {
+        assertEquals("name", name, crs.getName().getCode());
+        assertAxisDirectionsEqual(name, crs.getCoordinateSystem(),
+                                  AxisDirection.NORTH, AxisDirection.EAST);
+    }
+}
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java b/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
index ac64c98..074d59f 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
@@ -222,6 +222,7 @@ import org.junit.BeforeClass;
     org.apache.sis.io.wkt.GeodeticObjectParserTest.class,
     org.apache.sis.io.wkt.WKTFormatTest.class,
     org.apache.sis.io.wkt.WKTParserTest.class,
+    org.apache.sis.io.wkt.WKTDictionaryTest.class,
     org.apache.sis.io.wkt.ComparisonWithEPSG.class,
 
     // Geodetic object creations from authority codes.
diff --git a/core/sis-referencing/src/test/resources/org/apache/sis/io/wkt/ExtraCRS.txt b/core/sis-referencing/src/test/resources/org/apache/sis/io/wkt/ExtraCRS.txt
new file mode 100644
index 0000000..06e7eb9
--- /dev/null
+++ b/core/sis-referencing/src/test/resources/org/apache/sis/io/wkt/ExtraCRS.txt
@@ -0,0 +1,65 @@
+#
+# Test file for org.apache.sis.io.wkt.ReferencingFactory.
+# See "doc-files/ESRI.txt" for file syntax.
+#
+
+#
+# Alias for WGS84 geographic CRS.
+#
+SET DEGREE = Unit["Degree", 0.0174532925199433]
+SET WGS84 =
+ BaseGeodCRS["GCS_WGS_1984",
+  Datum["D_WGS_1984",
+   Ellipsoid["WGS_1984", 6378137, 298.257223563]],
+  $DEGREE]
+
+
+#
+# Derived from https://github.com/Esri/projection-engine-db-doc
+# with base CRS replaced by alias and parameter values omitted
+# when they have the default value.
+#
+ProjectedCRS["North_Pole_Stereographic",
+ $WGS84,
+ Conversion["Stereographic North Pole",
+  Method["Polar Stereographic (variant A)"],
+  Parameter["Latitude of natural origin", 90]],
+ CS[Cartesian, 2],
+  Axis["Easting (E)", east],
+  Axis["Northing (N)", north],
+  Unit["metre", 1],
+ Id["ESRI", 102018]]
+
+ProjectedCRS["South_Pole_Stereographic",
+ $WGS84,
+ Conversion["Stereographic South Pole",
+  Method["Polar Stereographic (variant A)"],
+  Parameter["Latitude of natural origin", -90]],
+ CS[Cartesian, 2],
+  Axis["Easting (E)", east],
+  Axis["Northing (N)", north],
+  Unit["metre", 1],
+ Id["ESRI", 102021]]
+
+#
+# A dummy CRS using the same code than ESRI::102021 but
+# different code space and versions. Used for testing
+# resolution of code collisions.
+#
+GeodCRS["Anguilla 1957",
+  Datum["Anguilla 1957",
+   Ellipsoid["Clarke 1880", 6378249.145, 293.465]],
+ CS[ellipsoidal, 2],
+  Axis["Latitude", north],
+  Axis["Longitude", east],
+  $DEGREE,
+ Id["MyCodeSpace", 102021]]
+
+GeodCRS["Anguilla 1957 (bis)",
+  Datum["Anguilla 1957",
+   Ellipsoid["Clarke 1880", 6378249.145, 293.465]],
+ CS[ellipsoidal, 2],
+  Axis["Latitude", north],
+  Axis["Longitude", east],
+  $DEGREE,
+ Id["MyCodeSpace", 102021, "v2"]]
diff --git a/core/sis-utility/src/main/java/org/apache/sis/internal/jdk9/JDK9.java b/core/sis-utility/src/main/java/org/apache/sis/internal/jdk9/JDK9.java
index 054f4f0..fa9c45a 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/internal/jdk9/JDK9.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/internal/jdk9/JDK9.java
@@ -27,6 +27,7 @@ import java.util.Arrays;
 import java.util.Set;
 import java.util.List;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.HashMap;
 import java.util.LinkedHashSet;
 import java.util.Map;
@@ -100,7 +101,18 @@ public final class JDK9 {
     }
 
     /**
-     * Placeholder for {@code Map.of(...)} (actually a JDK10 method).
+     * Placeholder for {@code Set.copyOf(...)} (actually a JDK10 method).
+     */
+    public static <V> Set<V> copyOf(final Set<V> set) {
+        switch (set.size()) {
+            case 0:  return Collections.emptySet();
+            case 1:  return Collections.singleton(set.iterator().next());
+            default: return new HashSet<>(set);
+        }
+    }
+
+    /**
+     * Placeholder for {@code Map.copyOf(...)} (actually a JDK10 method).
      */
     public static <K,V> Map<K,V> copyOf(final Map<K,V> map) {
         return map.size() < 2 ? CollectionsExt.compact(map) : new HashMap<>(map);
diff --git a/core/sis-utility/src/main/java/org/apache/sis/internal/util/Strings.java b/core/sis-utility/src/main/java/org/apache/sis/internal/util/Strings.java
index 2acad54..94d8c5d 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/internal/util/Strings.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/internal/util/Strings.java
@@ -54,6 +54,31 @@ public final class Strings extends Static {
     }
 
     /**
+     * Returns whether the given strings are equal, ignoring case.
+     * This method accepts null arguments.
+     *
+     * @param  a  first string.
+     * @param  b  another string to be compared with {@code a}.
+     * @return whether the given strings are equal, ignoring case.
+     *
+     * @see java.util.Objects#equals(Object, Object)
+     * @see String#equalsIgnoreCase(String)
+     */
+    public static boolean equalsIgnoreCase(final String a, final String b) {
+        return (a == b) || (a != null && a.equalsIgnoreCase(b));
+    }
+
+    /**
+     * Returns the given text is non-null, or the empty string otherwise.
+     *
+     * @param  text  text or null.
+     * @return given text or empty string (never null).
+     */
+    public static String orEmpty(final String text) {
+        return (text != null) ? text : "";
+    }
+
+    /**
      * Trims the leading and trailing spaces of the given string.
      * If the string is null, empty or contains only spaces, then this method returns {@code null}.
      *