sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] 02/02: In Metadata, java.nio.charset.Charset should be associated to java.util.Locale. This issue is described at https://github.com/opengeospatial/geoapi/issues/28 and is required for https://issues.apache.org/jira/browse/SIS-402 resolution. This work required improvement in many SIS internal classes (ModifiableMetadata, PropertyAccessor, Merger, TreeFormat, PT_Locale, etc…) for allowing the use of java.util.Map in addition of java.util.Collection in metadata properties.
Date Sat, 04 May 2019 23:33:30 GMT
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 f15686fe5e57c5c3f5be60d39772fa5886fc1c1b
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Sun May 5 01:26:36 2019 +0200

    In Metadata, java.nio.charset.Charset should be associated to java.util.Locale.
    This issue is described at https://github.com/opengeospatial/geoapi/issues/28 and is required for https://issues.apache.org/jira/browse/SIS-402 resolution.
    This work required improvement in many SIS internal classes (ModifiableMetadata, PropertyAccessor, Merger, TreeFormat, PT_Locale, etc…) for allowing the use of java.util.Map in addition of java.util.Collection in metadata properties.
---
 .../sis/internal/jaxb/lan/LocaleAdapter.java       |  62 ++++
 .../sis/internal/jaxb/lan/LocaleAndCharset.java    | 400 +++++++++++++++++++++
 .../apache/sis/internal/jaxb/lan/OtherLocales.java | 150 ++++++++
 .../apache/sis/internal/jaxb/lan/PT_Locale.java    | 216 ++++++++---
 .../internal/metadata/LegacyPropertyAdapter.java   |  24 +-
 .../org/apache/sis/internal/metadata/Merger.java   |  83 ++++-
 .../sis/internal/metadata/MetadataUtilities.java   |  41 +++
 .../apache/sis/internal/metadata/OtherLocales.java | 189 ----------
 .../apache/sis/internal/simple/SimpleMetadata.java |  20 +-
 .../apache/sis/metadata/ModifiableMetadata.java    | 170 +++++++--
 .../org/apache/sis/metadata/PropertyAccessor.java  |  48 ++-
 .../java/org/apache/sis/metadata/SpecialCases.java |  30 +-
 .../java/org/apache/sis/metadata/TreeNode.java     |  61 +++-
 .../org/apache/sis/metadata/TreeNodeChildren.java  |  51 +--
 .../apache/sis/metadata/iso/DefaultMetadata.java   | 221 +++++++-----
 .../DefaultFeatureCatalogueDescription.java        |  76 ++--
 .../sis/metadata/iso/content/package-info.java     |   4 +-
 .../identification/DefaultDataIdentification.java  | 114 +++---
 .../metadata/iso/identification/package-info.java  |   6 +-
 .../org/apache/sis/metadata/iso/package-info.java  |   6 +-
 .../sis/internal/jaxb/lan/LanguageCodeTest.java    |   6 +-
 .../sis/internal/jaxb/lan/OtherLocalesTest.java    | 112 ++++++
 .../sis/internal/jaxb/lan/PT_LocaleTest.java       |  12 +-
 .../apache/sis/internal/metadata/MergerTest.java   |  13 +-
 .../internal/metadata/MetadataUtilitiesTest.java   |  32 +-
 .../sis/internal/metadata/OtherLocalesTest.java    | 129 -------
 .../apache/sis/metadata/PropertyAccessorTest.java  |   9 +-
 .../sis/metadata/PropertyConsistencyCheck.java     |  17 +-
 .../sis/metadata/iso/CustomMetadataTest.java       |   1 +
 .../sis/metadata/iso/DefaultMetadataTest.java      |  10 +-
 .../DefaultDataIdentificationTest.java             |  40 +--
 .../org/apache/sis/test/mock/MetadataMock.java     |  15 +-
 .../apache/sis/test/suite/MetadataTestSuite.java   |   2 +-
 .../sis/test/xml/AnnotationConsistencyCheck.java   |  17 +-
 .../apache/sis/test/integration/MetadataTest.java  |  11 +-
 .../apache/sis/internal/util/CollectionsExt.java   |  21 ++
 .../sis/internal/util/TreeFormatCustomization.java |   8 +
 .../sis/util/collection/TreeTableFormat.java       |  32 +-
 .../earthobservation/LandsatReaderTest.java        |   2 +-
 .../apache/sis/internal/storage/xml/StoreTest.java |   6 +-
 40 files changed, 1757 insertions(+), 710 deletions(-)

diff --git a/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/LocaleAdapter.java b/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/LocaleAdapter.java
index 30ef6e1..651f797 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/LocaleAdapter.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/LocaleAdapter.java
@@ -75,4 +75,66 @@ public final class LocaleAdapter extends XmlAdapter<LanguageCode, Locale> {
     public LanguageCode marshal(final Locale value) {
         return LanguageCode.create(Context.current(), value);
     }
+
+
+
+
+    /**
+     * JAXB adapter for XML {@code <PT_Locale>} elements mapped to {@link Locale}.
+     * This adapter formats the locale like below:
+     *
+     * {@preformat xml
+     *   <gmd:locale>
+     *     <gmd:PT_Locale>
+     *       <gmd:language>
+     *         <gmd:LanguageCode codeList="(snip)#LanguageCode" codeListValue="jpn">Japanese</gmd:LanguageCode>
+     *       </gmd:language>
+     *     </gmd:PT_Locale>
+     *   </gmd:locale>
+     * }
+     *
+     * This adapter is used for legacy locales in {@code gmd} namespace.
+     * For locales in the newer {@code lan} namespace, see {@link PT_Locale}.
+     *
+     * @author  Martin Desruisseaux (Geomatys)
+     * @version 1.0
+     * @since   1.0
+     * @module
+     */
+    public static final class Wrapped extends XmlAdapter<PT_Locale, Locale> {
+        /**
+         * Empty constructor for JAXB.
+         */
+        private Wrapped() {
+        }
+
+        /**
+         * Substitutes the locale by the wrapper to be marshalled into an XML file
+         * or stream. JAXB calls automatically this method at marshalling time.
+         *
+         * @param  value  the locale value.
+         * @return the wrapper for the locale value.
+         */
+        @Override
+        public PT_Locale marshal(final Locale value) {
+            if (value == null) {
+                return null;
+            }
+            PT_Locale p = new PT_Locale(value);
+            p.setCharacterSet(null);                // For forcing creating of child element.
+            return p;
+        }
+
+        /**
+         * Substitutes the wrapped value read from a XML stream by the object which will
+         * contains the value. JAXB calls automatically this method at unmarshalling time.
+         *
+         * @param  value  the wrapper for this metadata value.
+         * @return a locale which represents the metadata value.
+         */
+        @Override
+        public Locale unmarshal(final PT_Locale value) {
+            return (value != null) ? value.getLocale() : null;
+        }
+    }
 }
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/LocaleAndCharset.java b/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/LocaleAndCharset.java
new file mode 100644
index 0000000..c99a62e
--- /dev/null
+++ b/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/LocaleAndCharset.java
@@ -0,0 +1,400 @@
+/*
+ * 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.internal.jaxb.lan;
+
+import java.util.Map;
+import java.util.LinkedHashMap;
+import java.util.AbstractSet;
+import java.util.AbstractList;
+import java.util.AbstractCollection;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Locale;
+import java.nio.charset.Charset;
+import java.util.Iterator;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.collection.TableColumn;
+import org.apache.sis.util.collection.TreeTable.Node;
+import org.apache.sis.internal.util.CollectionsExt;
+
+
+/**
+ * Utility methods for handling {@code Map<Locale,Charset>} as separated collections.
+ * Locale and character set were separated properties in legacy ISO 19115:2003 but become combined
+ * under a single {@link PT_Locale} entity in ISO 19115:2014. This change is not really convenient
+ * in Java since the standard {@link Locale} and {@link Charset} objects are separated entities.
+ * This class provides two services for managing that:
+ *
+ * <ul>
+ *   <li>Static methods, used mostly for JAXB marshalling and unmarshalling.</li>
+ *   <li>Implementation of {@link Node} for viewing a {@code Map.Entry<Locale,Charset>} as a
+ *       {@link Locale} node with a {@link Charset} child. This is used for providing textual
+ *       representation of metadata.</li>
+ * </ul>
+ *
+ * Example:
+ *
+ * {@preformat text
+ *     Identification info
+ *      ├─Abstract………………………………………………………………………………… Some data.
+ *      ├─Locale (1 of 2)……………………………………………………………… en_US
+ *      │   └─Character set………………………………………………………… US-ASCII
+ *      └─Locale (2 of 2)……………………………………………………………… fr
+ *          └─Character set………………………………………………………… ISO-8859-1
+ * }
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+public final class LocaleAndCharset implements Node {
+    /**
+     * The node containing a {@code Map.Entry<Locale,Charset>} value.
+     * This is the node to replace by this {@link LocaleAndCharset} view.
+     */
+    private final Node node;
+
+    /**
+     * Creates a new node for the given entry. The user object associated to
+     * the given node must be an instance of {@code Map.Entry<Locale,Charset>}.
+     *
+     * @param  node  the node to wrap.
+     */
+    public LocaleAndCharset(final Node node) {
+        this.node = node;
+    }
+
+    /**
+     * Delegates to wrapped node since this {@link LocaleAndCharset} is a substitute for that node.
+     */
+    @Override
+    public Node getParent() {
+        return node.getParent();
+    }
+
+    /**
+     * Considers this node as non-editable since it represents the key in a map, and keys can not be modified
+     * through the {@link Map.Entry} interface. However {@link Child} will be editable for the value column.
+     */
+    @Override
+    public boolean isEditable(final TableColumn<?> column) {
+        return false;
+    }
+
+    /**
+     * Returns {@code false} since this node can have a children, which is the {@link Child}.
+     */
+    @Override
+    public boolean isLeaf() {
+        return false;
+    }
+
+    /**
+     * Returns the key or the value of the given {@link Map.Entry}. If the given object is not a map entry
+     * or is null, then it is returned as-is. This later case should never happen (the object shall always be
+     * a non-null map entry), but we nevertheless check for making the code more robust to ill-formed metadata.
+     * We apply this tolerance because this method is used (indirectly) for {@code toString()} implementations,
+     * and failure in those methods make debugging more difficult (string representations are often requested
+     * when the developer knows that there is a problem to investigate).
+     *
+     * @param  object  the map entry for which to get the key or the value.
+     * @param  key     {@code true} for fetching the key, or {@code false} for fetching the value.
+     * @return the requested key or value, or the given object itself if it is not a map entry.
+     */
+    private static Object keyOrValue(Object object, final boolean key) {
+        if (object instanceof Map.Entry<?,?>) {
+            final Map.Entry<?,?> entry = (Map.Entry<?,?>) object;
+            object = key ? entry.getKey() : entry.getValue();
+        }
+        return object;
+    }
+
+    /**
+     * Returns the user object associated to this node. For this node, that object is the key (a {@link Locale}) of the
+     * map entry. For the {@link Child}, the user object will be the value (a {@link Charset}) of the same map entry.
+     */
+    @Override
+    public Object getUserObject() {
+        return keyOrValue(node.getUserObject(), true);
+    }
+
+    /**
+     * Returns the value associated to the given column of this node. This method delegates to the wrapped node,
+     * then extract the key component of the map entry if the requested column is the value.
+     */
+    @Override
+    public <V> V getValue(final TableColumn<V> column) {
+        return separateValue(column, true);
+    }
+
+    /**
+     * Implementation of {@link #getValue(TableColumn)} also used by the {@link Child}.
+     */
+    private <V> V separateValue(final TableColumn<V> column, final boolean key) {
+        V value = node.getValue(column);
+        if (TableColumn.VALUE.equals(column)) {
+            value = column.getElementType().cast(keyOrValue(value, key));
+        }
+        return value;
+    }
+
+    /**
+     * Always throws an exception since we can not edit the key of a map entry. Attempts to edit other columns
+     * than the value column will also cause an exception to be thrown, but the error message provided by the
+     * wrapped node is more detailed.
+     */
+    @Override
+    public <V> void setValue(final TableColumn<V> column, final V value) {
+        if (TableColumn.VALUE.equals(column)) {
+            throw new UnsupportedOperationException();
+        } else {
+            node.setValue(column, value);
+        }
+    }
+
+    /**
+     * Returns the list of children, which is implemented by this class itself.
+     * The children are {@link Charset} values associated to the {@link Locale}.
+     * The list contains O or 1 element.
+     */
+    @Override
+    public Collection<Node> getChildren() {
+        return new AbstractList<Node>() {
+            /** Returns the number {@link Charset} associated to the {@link Locale}, which is 0 or 1. */
+            @Override public int size() {
+                return keyOrValue(node.getUserObject(), false) != null ? 1 : 0;
+            }
+
+            /** Returns a child node wrapping the {@link Charset} ad the given index. */
+            @Override public Node get(final int index) {
+                ArgumentChecks.ensureValidIndex(1, index);
+                return new Child();
+            }
+        };
+    }
+
+    /**
+     * Creates a new child only if none exists.
+     */
+    @Override
+    public Node newChild() {
+        if (keyOrValue(node.getUserObject(), false) != null) {
+            throw new UnsupportedOperationException();
+        }
+        return new Child();
+    }
+
+    /**
+     * The only child of the node containing a {@link Locale} value. This child contains the associated {@link Charset} value.
+     * That value is replaceable if the wrapped node is editable, which depends on whether the metadata instance is modifiable
+     * or not.
+     */
+    private final class Child implements Node {
+        @Override public Node             getParent()                  {return LocaleAndCharset.this;}
+        @Override public Object           getUserObject()              {return keyOrValue(node.getUserObject(), false);}
+        @Override public boolean          isEditable(TableColumn<?> c) {return node.isEditable(c);}
+        @Override public boolean          isLeaf()                     {return true;}
+        @Override public Collection<Node> getChildren()                {return Collections.emptySet();}
+        @Override public Node             newChild()                   {throw new UnsupportedOperationException();}
+
+        /** Returns the value at the given column, with hard-coded names. */
+        @Override public <V> V getValue(final TableColumn<V> column) {
+            final String value;
+            if (TableColumn.IDENTIFIER.equals(column)) {
+                value = "characterSet";
+            } else if (TableColumn.NAME.equals(column)) {
+                value = "Character set";
+            } else {
+                return separateValue(column, false);
+            }
+            return column.getElementType().cast(value);
+        }
+
+        /** Sets the value in the map entry key wrapped by this node. */
+        @Override public <V> void setValue(final TableColumn<V> column, V value) {
+            if (TableColumn.VALUE.equals(column)) {
+                /*
+                 * No @SuppressWarning("unchecked") because following is a real hole.
+                 * We rely on Entry.setValue(Object) implementation to perform checks
+                 * (this is the case with SIS implementation backed by PropertyAccessor).
+                 */
+                ((Map.Entry<?,V>) node.getUserObject()).setValue(value);
+            } else {
+                node.setValue(column, value);
+            }
+        }
+
+        /** Returns a hash code value for this node. */
+        @Override public int hashCode() {
+            return ~getParent().hashCode();
+        }
+
+        /** Tests this node with the given object for equality. */
+        @Override public boolean equals(final Object other) {
+            return (other instanceof Child) && getParent().equals(((Child) other).getParent());
+        }
+    }
+
+    /**
+     * Returns a hash code value for this node.
+     */
+    @Override
+    public int hashCode() {
+        return node.hashCode() ^ 37;
+    }
+
+    /**
+     * Tests this node with the given object for equality.
+     * Two {@link LocaleAndCharset} instances are considered equal if they wrap the same node.
+     */
+    @Override
+    public boolean equals(final Object other) {
+        return (other instanceof LocaleAndCharset) && node.equals(((LocaleAndCharset) other).node);
+    }
+
+    /**
+     * Returns the language(s) used within the resource. The returned collection supports the {@code add(Locale)} method
+     * in order to enable XML unmarshalling of legacy ISO 19157 metadata documents. That hack is not needed for newer XML
+     * documents (ISO 19115-3:2016).
+     *
+     * @param  locales  the map of locales and character sets, or {@code null}.
+     * @return language(s) used within the resource, or {@code null}.
+     */
+    public static Collection<Locale> getLanguages(final Map<Locale,Charset> locales) {
+        if (locales == null) {
+            return null;
+        }
+        return new AbstractSet<Locale>() {
+            @Override public int size() {
+                return locales.size();
+            }
+            @Override public boolean contains(Object o) {
+                return locales.containsKey(o);
+            }
+            @Override public Iterator<Locale> iterator() {
+                return locales.keySet().iterator();
+            }
+            @Override public boolean add(final Locale locale) {
+                // We need containsKey(…) check because value may be null: Map.putIfAbsent(…) is not sufficient.
+                if (locale == null || locales.containsKey(locale)) {
+                    return false;
+                }
+                final Charset encoding = locales.remove(null);
+                locales.put(locale, encoding);
+                return true;
+            }
+        };
+    }
+
+    /**
+     * Returns the character coding standard used for the resource. The returned collection supports {@code add(Charset)}
+     * method in order to enable XML unmarshalling of legacy ISO 19157 metadata documents. That hack is not be needed for
+     * newer (ISO 19115-3:2016) XML documents.
+     *
+     * @param  locales  the map of locales and character sets, or {@code null}.
+     * @return character coding standard(s) used, or {@code null}.
+     */
+    public static Collection<Charset> getCharacterSets(final Map<Locale,Charset> locales) {
+        if (locales == null) {
+            return null;
+        }
+        return new AbstractCollection<Charset>() {
+            @Override public int size() {
+                return locales.size();
+            }
+            @Override public boolean contains(Object o) {
+                return locales.containsValue(o);
+            }
+            @Override public Iterator<Charset> iterator() {
+                return locales.values().iterator();
+            }
+            @Override public boolean add(final Charset encoding) {
+                if (encoding == null) {
+                    return false;
+                }
+                for (final Map.Entry<Locale,Charset> entry : locales.entrySet()) {
+                    if (entry.getValue() == null) {
+                        entry.setValue(encoding);
+                        return true;
+                    }
+                }
+                return locales.putIfAbsent(null, encoding) != encoding;
+            }
+        };
+    }
+
+    /**
+     * Sets the language(s) used within the resource.
+     * This method preserves the character sets if possible.
+     *
+     * @param  locales    the map of locales and character sets, or {@code null}.
+     * @param  newValues  the new languages.
+     * @return the given map, or a new map if necessary and the given map was null.
+     */
+    @SuppressWarnings("null")
+    public static Map<Locale,Charset> setLanguages(Map<Locale,Charset> locales, final Collection<? extends Locale> newValues) {
+        final Charset encoding = (locales != null) ? CollectionsExt.first(locales.values()) : null;
+        if (newValues == null || newValues.isEmpty()) {
+            if (locales != null) {
+                locales.clear();
+            }
+        } else {
+            if (locales == null) {
+                locales = new LinkedHashMap<>();
+            }
+            locales.keySet().retainAll(newValues);
+            for (final Locale locale : newValues) {
+                locales.putIfAbsent(locale, null);
+            }
+        }
+        /*
+         * If an encoding was defined before invocation of this method and is not associated to any
+         * locale specified in `newValues`, preserve that encoding in an entry with null locale.
+         */
+        if (encoding != null && !locales.values().contains(encoding)) {     // `locales` is non-null if `encoding` is non-null.
+            locales.put(null, encoding);
+        }
+        return locales;
+    }
+
+    /**
+     * Sets the character coding standard(s) used within the resource. Current implementation takes
+     * only the first {@link Charset} and set the encoding of all locales to that character set.
+     * This is suboptimal, but this approach is used only for implementation of deprecated methods.
+     *
+     * @param  locales    the map of locales and character sets, or {@code null}.
+     * @param  newValues  the new character coding standard(s).
+     * @return the given map, or a new map if necessary and the given map was null.
+     */
+    public static Map<Locale,Charset> setCharacterSets(Map<Locale,Charset> locales, final Collection<? extends Charset> newValues) {
+        final Charset encoding = CollectionsExt.first(newValues);
+        if (locales != null || encoding != null) {
+            if (locales == null) {
+                locales = new LinkedHashMap<>();
+            }
+            if (locales.isEmpty()) {
+                locales.put(null, encoding);
+            } else {
+                for (final Map.Entry<Locale,Charset> entry : locales.entrySet()) {
+                    entry.setValue(encoding);
+                }
+            }
+        }
+        return locales;
+    }
+}
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/OtherLocales.java b/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/OtherLocales.java
new file mode 100644
index 0000000..1624b2e
--- /dev/null
+++ b/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/OtherLocales.java
@@ -0,0 +1,150 @@
+/*
+ * 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.internal.jaxb.lan;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.List;
+import java.util.Collection;
+import java.util.AbstractSet;
+import java.util.LinkedHashMap;
+import java.util.Iterator;
+import java.util.Locale;
+import java.nio.charset.Charset;
+
+
+/**
+ * Helper methods for handling the ISO 19115 {@code defaultLocale} and {@code otherLocale} legacy properties.
+ * The ISO standard defines them as two separated properties while GeoAPI handles them in a single collection
+ * for integration with JDK standard API like {@link java.util.Locale#lookup(List, Collection)}.
+ *
+ * <p>The first element of the {@code languages} collection is taken as the {@code defaultLocale}, and all
+ * remaining ones are taken as {@code otherLocale} elements. Instances of this {@code OtherLocales} class
+ * are for those remaining elements and are created by the {@link #filter(Map)} method.</p>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.0
+ * @since   0.5
+ * @module
+ */
+public final class OtherLocales extends AbstractSet<PT_Locale> {
+    /**
+     * The default locale followed by all other locales.
+     */
+    private final Set<PT_Locale> locales;
+
+    /**
+     * Private constructor for {@link #filter(Map)} only.
+     */
+    private OtherLocales(final Set<PT_Locale> locales) {
+        this.locales = locales;
+    }
+
+    /**
+     * Returns a collection for all elements except the first one from the given collection.
+     *
+     * <p><b>Null values and XML marshalling:</b>
+     * The {@code locales} argument may be {@code null} at XML marshalling time. In such case, this method returns
+     * {@code null} instead than an empty set in order to instruct JAXB to not marshal the {@code otherLocale} element
+     * (an empty set would cause JAXB to marshal an empty element). Since the {@code locales} argument given to this
+     * method should never be null except at XML marshalling time, this rule should not be a violation of public API.</p>
+     *
+     * @param  locales  the collection containing the default locale followed by the other ones, or {@code null}.
+     * @return a collection containing all {@code languages} elements except the first one, or {@code null}.
+     */
+    public static Set<PT_Locale> filter(final Map<Locale,Charset> locales) {
+        final Set<PT_Locale> s = PT_Locale.wrap(locales);
+        return (s != null) ? new OtherLocales(s) : null;
+    }
+
+    /**
+     * Returns the number of elements in this collection.
+     *
+     * @return number of other locales.
+     */
+    @Override
+    public int size() {
+        int size = locales.size();
+        if (size > 0) size--;
+        return size;
+    }
+
+    /**
+     * Returns an iterator over all elements in this collection except the first one.
+     *
+     * @return iterator over all other locales.
+     */
+    @Override
+    public Iterator<PT_Locale> iterator() {
+        final Iterator<PT_Locale> it = locales.iterator();
+        if (it.hasNext()) it.next();                            // Skip the first element.
+        return it;
+    }
+
+    /**
+     * Adds a new element to the collection of "other locales". If we had no "default locale" prior this method call,
+     * then this method will choose one before to add the given locale. This is needed since the other locales begin
+     * only after the first element, so a first element needs to exist.
+     *
+     * <p>The above rule could be a risk of confusion for the users, since it could cause the apparition of a default
+     * locale which has never been specified. However this risk exists only when invoking the deprecated methods, or
+     * when unmarshalling a XML document having a {@code otherLocale} property without {@code defaultLocale} property,
+     * which is probably invalid.</p>
+     *
+     * @param  locale  the element to add.
+     * @return {@code true} if the "other locales" collection has been modified as a result of this method call.
+     */
+    @Override
+    public boolean add(final PT_Locale locale) {
+        if (locales.isEmpty()) {
+            Locale defaultLocale = Locale.getDefault();
+            if (defaultLocale.equals(locale.getLocale())) {
+                defaultLocale = Locale.ROOT;
+            }
+            locales.add(new PT_Locale(defaultLocale));
+        }
+        return locales.add(locale);
+    }
+
+    /**
+     * Returns a map containing the given {@code PT_Locale} followed by other locales in the given {@code addTo} map.
+     *
+     * @param  addTo     the map where to set the first locale, or {@code null}.
+     * @param  newValue  the value to add in the map, or {@code null}.
+     * @return a map containing this locale followed by other locales in the given map.
+     */
+    public static Map<Locale,Charset> setFirst(Map<Locale,Charset> addTo, final PT_Locale newValue) {
+        if (newValue != null) {
+            Object[] keys   = null;
+            Object[] values = null;
+            if (addTo == null) {
+                addTo = new LinkedHashMap<>();
+            } else if (!addTo.isEmpty()) {
+                keys   = addTo.keySet().toArray();
+                values = addTo.values().toArray();
+                addTo.clear();
+            }
+            newValue.addInto(addTo);
+            if (keys != null) {
+                for (int i=1; i<keys.length; i++) {                         // Skip first element.
+                    addTo.put((Locale) keys[i], (Charset) values[i]);
+                }
+            }
+        }
+        return addTo;
+    }
+}
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/PT_Locale.java b/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/PT_Locale.java
index 233ed92..d714ff6 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/PT_Locale.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/internal/jaxb/lan/PT_Locale.java
@@ -16,32 +16,35 @@
  */
 package org.apache.sis.internal.jaxb.lan;
 
+import java.util.Set;
+import java.util.Map;
+import java.util.AbstractSet;
+import java.util.Iterator;
 import java.util.Locale;
 import java.nio.charset.Charset;
 import javax.xml.bind.Marshaller;
 import javax.xml.bind.PropertyException;
 import javax.xml.bind.annotation.XmlType;
 import javax.xml.bind.annotation.XmlElement;
-import javax.xml.bind.annotation.adapters.XmlAdapter;
 import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
 import org.apache.sis.internal.jaxb.code.MD_CharacterSetCode;
 import org.apache.sis.internal.xml.LegacyNamespaces;
 import org.apache.sis.internal.jaxb.Context;
+import org.apache.sis.internal.util.CollectionsExt;
 
 
 /**
- * JAXB adapter for {@link Locale}
- * in order to wrap the value in an XML element as specified by ISO 19115-3 standard.
+ * A {@link Locale} associated to {@link Charset}.
+ * This class wraps the value in an XML element as specified by ISO 19115-3 standard.
  * See package documentation for more information about the handling of {@code CodeList} in ISO 19115-3.
- *
- * <p>This adapter formats the locale like below:</p>
+ * This wrapper formats the locale like below:
  *
  * {@preformat xml
  *   <lan:locale>
  *     <lan:PT_Locale id="locale-eng">
- *       <lan:languageCode>
+ *       <lan:language>
  *         <lan:LanguageCode codeList="./resources/Codelists.xml#LanguageCode" codeListValue="eng">eng</lan:LanguageCode>
- *       </lan:languageCode>
+ *       </lan:language>
  *       <lan:country>
  *         <lan:Country codeList="./resources/Codelists.xml#Country" codeListValue="GB">GB</lan:Country>
  *       </lan:country>
@@ -66,7 +69,15 @@ import org.apache.sis.internal.jaxb.Context;
  * @since 0.3
  * @module
  */
-public final class PT_Locale extends XmlAdapter<PT_Locale, Locale> {
+public final class PT_Locale {
+    /**
+     * The wrapped locale, for information purpose. This object is not marshalled directly.
+     * Instead it will be decomposed in language and country components in {@link Wrapper}.
+     *
+     * @see #getLocale()
+     */
+    private Locale locale;
+
     /**
      * The attributes wrapped in a {@code "PT_Locale"} element.
      */
@@ -77,13 +88,16 @@ public final class PT_Locale extends XmlAdapter<PT_Locale, Locale> {
      * Wraps the {@code "locale"} attributes in a {@code "PT_Locale"} element.
      */
     @XmlType(name = "PT_Locale_Type", propOrder = {
-        "languageCode", "language", "country", "characterEncoding"
+        "languageCode",         // Legacy ISO 19115:2003
+        "language",             // New in ISO 19115:2014
+        "country",
+        "characterEncoding"
     })
     private static final class Wrapper {
         /**
          * The language code, or {@code null} if none.
          */
-        LanguageCode languageCode;
+        LanguageCode language;
 
         /**
          * The country code, or {@code null} if none.
@@ -92,7 +106,8 @@ public final class PT_Locale extends XmlAdapter<PT_Locale, Locale> {
         Country country;
 
         /**
-         * The character encoding. The specification said:
+         * The character encoding. If {@code null}, then this property will be set to the encoding of XML file.
+         * The specification said:
          *
          * <blockquote>Indeed, an XML file can only support data expressed in a single character set, which is generally
          * declared in the XML file header. Having all the localized strings stored in a single XML file would limit the
@@ -123,13 +138,16 @@ public final class PT_Locale extends XmlAdapter<PT_Locale, Locale> {
 
         /**
          * Creates a new wrapper for the given locale.
+         *
+         * @param  locale    the locale to marshal, or {@code null}.
+         * @param  encoding  the character set, or {@code null} for defaulting to the encoding of XML document.
          */
-        Wrapper(final Locale locale) {
+        Wrapper(final Locale locale, final Charset encoding) {
             final Context context = Context.current();
-            isLegacyMetadata = Context.isFlagSet(context, Context.LEGACY_METADATA);
-            languageCode     = LanguageCode.create(context, locale);
-            country          = Country     .create(context, locale);
-            // The characterEncoding field will be initialized at marshalling time (see method below).
+            isLegacyMetadata  = Context.isFlagSet(context, Context.LEGACY_METADATA);
+            language          = LanguageCode.create(context, locale);
+            country           = Country     .create(context, locale);
+            characterEncoding = encoding;
         }
 
         /**
@@ -137,7 +155,7 @@ public final class PT_Locale extends XmlAdapter<PT_Locale, Locale> {
          */
         @XmlElement(name = "languageCode", namespace = LegacyNamespaces.GMD)
         private LanguageCode getLanguageCode() {
-            return isLegacyMetadata ? languageCode : null;
+            return isLegacyMetadata ? language : null;
         }
 
         /**
@@ -145,45 +163,50 @@ public final class PT_Locale extends XmlAdapter<PT_Locale, Locale> {
          */
         @SuppressWarnings("unused")
         private void setLanguageCode(LanguageCode newValue) {
-            languageCode = newValue;
+            language = newValue;
         }
 
         /**
-         * Gets the language code for this PT_Locale. Used in ISO 19115-3.
+         * Gets the language code for this PT_Locale. Used in ISO 19115:2014 model.
          */
         @XmlElement(name = "language", required = true)
         private LanguageCode getLanguage() {
-            return isLegacyMetadata ? null : languageCode;
+            return isLegacyMetadata ? null : language;
         }
 
         /**
-         * Sets the language code for this PT_Locale. Used in ISO 19115:2003 model.
+         * Sets the language code for this PT_Locale. Used in ISO 19115:2014 model.
          */
         @SuppressWarnings("unused")
         private void setLanguage(LanguageCode newValue) {
-            languageCode = newValue;
+            language = newValue;
         }
 
         /**
          * Invoked by JAXB {@link javax.xml.bind.Marshaller} before this object is marshalled to XML.
-         * This method sets the {@link #characterEncoding} to the XML encoding.
+         * If the {@link #characterEncoding} is not set, then this method set a default value.
+         * That default is the encoding of the XML document being written.
          *
-         * <div class="note"><b>Note:</b> This is totally redundant with the encoding declared in the XML header.
-         * Unfortunately, the {@code <lan:characterEncoding>} element is mandatory according OGC/ISO schemas.</div>
+         * <div class="note"><b>Note:</b> This is redundant with the encoding declared in the XML header.
+         * But the {@code <lan:characterEncoding>} element is mandatory according OGC/ISO schemas.</div>
          */
         public void beforeMarshal(final Marshaller marshaller) {
-            final String encoding;
-            try {
-                encoding = (String) marshaller.getProperty(Marshaller.JAXB_ENCODING);
-            } catch (PropertyException | ClassCastException e) {
-                // Should never happen. But if it happen anyway, just let the
-                // characterEncoding unitialized: it will not be marshalled.
-                Context.warningOccured(Context.current(), PT_Locale.class, "beforeMarshal", e, true);
-                return;
-            }
-            if (encoding != null) {
-                final Context context = Context.current();
-                characterEncoding = Context.converter(context).toCharset(context, encoding);
+            if (characterEncoding == null) {
+                final String encoding;
+                try {
+                    encoding = (String) marshaller.getProperty(Marshaller.JAXB_ENCODING);
+                } catch (PropertyException | ClassCastException e) {
+                    /*
+                     * Should never happen. But if it happen anyway, just let the
+                     * characterEncoding unitialized: it will not be marshalled.
+                     */
+                    Context.warningOccured(Context.current(), PT_Locale.class, "beforeMarshal", e, true);
+                    return;
+                }
+                if (encoding != null) {
+                    final Context context = Context.current();
+                    characterEncoding = Context.converter(context).toCharset(context, encoding);
+                }
             }
         }
     }
@@ -191,43 +214,120 @@ public final class PT_Locale extends XmlAdapter<PT_Locale, Locale> {
     /**
      * Empty constructor for JAXB only.
      */
-    public PT_Locale() {
+    private PT_Locale() {
     }
 
     /**
      * Creates a new wrapper for the given locale.
+     *
+     * @param  locale  the language and country components of {@code PT_Locale}.
+     */
+    public PT_Locale(final Locale locale) {
+        this.locale = locale;
+    }
+
+    /**
+     * Creates a new wrapper for the given locale and character set.
+     *
+     * @param  entry  the locale to marshal together with its charset.
      */
-    private PT_Locale(final Locale locale) {
-        element = new Wrapper(locale);
+    private PT_Locale(final Map.Entry<Locale,Charset> entry) {
+        locale  = entry.getKey();
+        setCharacterSet(entry.getValue());
     }
 
     /**
-     * Substitutes the locale by the wrapper to be marshalled into an XML file
-     * or stream. JAXB calls automatically this method at marshalling time.
+     * Sets the character set to the given value.
+     */
+    final void setCharacterSet(final Charset encoding) {
+        element = new Wrapper(locale, encoding);
+    }
+
+    /**
+     * Returns the Java locale wrapped by this {@link PT_Locale} instance.
+     * This method returns a cached instance if possible.
      *
-     * @param  value  the locale value.
-     * @return the wrapper for the locale value.
+     * @return the wrapped locale, or {@code null} if none.
      */
-    @Override
-    public PT_Locale marshal(final Locale value) {
-        return (value != null) ? new PT_Locale(value) : null;
+    public Locale getLocale() {
+        if (locale == null && element != null) {
+            locale = Country.getLocale(Context.current(), element.language, element.country, PT_Locale.class);
+        }
+        return locale;
     }
 
     /**
-     * Substitutes the wrapped value read from a XML stream by the object which will
-     * contains the value. JAXB calls automatically this method at unmarshalling time.
+     * Infers a locale and character set from this wrapper and adds them as an entry in the given map.
      *
-     * @param  value  the wrapper for this metadata value.
-     * @return a locale which represents the metadata value.
+     * @param  addTo  the map where to add an entry for the locale and character set.
+     * @return whether the given map has been modified.
      */
-    @Override
-    public Locale unmarshal(final PT_Locale value) {
-        if (value != null) {
-            final Wrapper element = value.element;
-            if (element != null) {
-                return Country.getLocale(Context.current(), element.languageCode, element.country, PT_Locale.class);
-            }
+    final boolean addInto(final Map<Locale,Charset> addTo) {
+        final Locale locale = getLocale();
+        final Charset encoding = (element != null) ? element.characterEncoding : null;
+        if (locale != null || encoding != null) {
+            // We need a special check if (encoding == null) since put(…) != encoding will not work in that case.
+            final boolean wasAbsent = (encoding == null) && !addTo.containsKey(locale);
+            return (addTo.put(locale, encoding) != encoding) | wasAbsent;
+        }
+        return false;
+    }
+
+    /**
+     * Returns the first element of the given map, or {@code null} if none.
+     *
+     * @param  locales  the locales and character sets, or {@code null}.
+     * @return the first element of the given map, or {@code null}.
+     */
+    public static PT_Locale first(final Map<Locale,Charset> locales) {
+        if (locales != null) {
+            final Map.Entry<Locale,Charset> first = CollectionsExt.first(locales.entrySet());
+            if (first != null) return new PT_Locale(first);
         }
         return null;
     }
+
+    /**
+     * Wraps all elements of the given map in a sequence of {@link PT_Locale}.
+     *
+     * @param  locales  the locales and character sets, or {@code null}.
+     * @return the all elements of the given map, or {@code null} if the given map is null or empty.
+     */
+    public static Set<PT_Locale> wrap(final Map<Locale,Charset> locales) {
+        return (locales != null && !locales.isEmpty()) ? new Sequence(locales) : null;
+    }
+
+    /**
+     * A set of {@link PT_Locale} instances backed by a {@code Map<Locale,Charset>}.
+     * This is used at marshalling and unmarshalling time only.
+     */
+    private static final class Sequence extends AbstractSet<PT_Locale> {
+        /** The languages and character sets. */
+        final Map<Locale,Charset> locales;
+
+        /** Creates a new set backed by the given map. */
+        Sequence(final Map<Locale,Charset> locales) {
+            this.locales = locales;
+        }
+
+        /** Returns the number of elements in this set. */
+        @Override public int size() {
+            return locales.size();
+        }
+
+        /** Add the given {@code PT_Locale} in the backing map. */
+        @Override public boolean add(final PT_Locale value) {
+            return (value != null) && value.addInto(locales);
+        }
+
+        /** Returns an iterator over the entries in this set. */
+        @Override public Iterator<PT_Locale> iterator() {
+            final Iterator<Map.Entry<Locale,Charset>> it = locales.entrySet().iterator();
+            return new Iterator<PT_Locale>() {
+                @Override public boolean   hasNext() {return it.hasNext();}
+                @Override public PT_Locale next()    {return new PT_Locale(it.next());}
+                @Override public void      remove()  {it.remove();}
+            };
+        }
+    }
 }
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/LegacyPropertyAdapter.java b/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/LegacyPropertyAdapter.java
index 74d1ad2..a07d302 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/LegacyPropertyAdapter.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/LegacyPropertyAdapter.java
@@ -138,7 +138,7 @@ public abstract class LegacyPropertyAdapter<L,N> extends AbstractCollection<L> {
 
     /**
      * Returns the singleton value of the given collection, or {@code null} if the given collection is null or empty.
-     * If the given collection contains more than one element, then a warning is emitted.
+     * If the given collection contains more than one non-null and distinct element, then a warning is emitted.
      *
      * @param  <L>           the kind of legacy values to be returned.
      * @param  values        the collection from which to get the value.
@@ -153,18 +153,24 @@ public abstract class LegacyPropertyAdapter<L,N> extends AbstractCollection<L> {
     {
         if (values != null) {
             final Iterator<? extends L> it = values.iterator();
-            if (it.hasNext()) {
+            while (it.hasNext()) {
                 final L value = it.next();
-                if (it.hasNext()) {
-                    if (caller != null) {
-                        if (caller.warningOccurred) {
-                            return value; // Skip the warning.
+                if (value != null) {
+                    while (it.hasNext()) {
+                        final L next = it.next();
+                        if (next != null && !value.equals(next)) {
+                            if (caller != null) {
+                                if (caller.warningOccurred) {
+                                    return value;                       // Skip the warning.
+                                }
+                                caller.warningOccurred = true;
+                            }
+                            warnIgnoredExtraneous(valueClass, callerClass, callerMethod);
+                            break;
                         }
-                        caller.warningOccurred = true;
                     }
-                    warnIgnoredExtraneous(valueClass, callerClass, callerMethod);
+                    return value;
                 }
-                return value;
             }
         }
         return null;
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/Merger.java b/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/Merger.java
index f8bf6ec..ff4a710 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/Merger.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/Merger.java
@@ -22,6 +22,7 @@ import java.util.Collection;
 import java.util.LinkedList;
 import java.util.IdentityHashMap;
 import java.util.Locale;
+import java.util.function.BiFunction;
 import org.apache.sis.metadata.MetadataStandard;
 import org.apache.sis.metadata.AbstractMetadata;
 import org.apache.sis.metadata.InvalidMetadataException;
@@ -62,7 +63,7 @@ import org.apache.sis.util.Classes;
  * @author  Johann Sorel (Geomatys)
  * @author  Benjamin Garcia (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
+ * @version 1.0
  * @since   0.8
  * @module
  */
@@ -219,7 +220,8 @@ public class Merger {
 distribute:                 while (it.hasNext()) {
                                 final Object value = it.next();
                                 switch (resolve(value, (ModifiableMetadata) element)) {
-                                //  case SEPARATE: do nothing.
+                                    default: throw new UnsupportedOperationException();
+                                    case SEPARATE: break;               // do nothing.
                                     case MERGE: {
                                         /*
                                          * If enabled, copy(…, true) call verified that the merge can be done, including
@@ -257,12 +259,14 @@ distribute:                 while (it.hasNext()) {
                         throw new InvalidMetadataException(errors().getString(
                                 Errors.Keys.UnsupportedImplementation_1, Classes.getShortClassName(targetList)));
                     }
+                } else if (targetValue instanceof Map<?,?>) {
+                    success = new ForMap<>(target, propertyName, (Map<?,?>) sourceValue, (Map<?,?>) targetValue).run(dryRun);
                 } else {
                     success = targetValue.equals(sourceValue);
                     if (!success) {
                         if (dryRun) break;
                         merge(target, propertyName, sourceValue, targetValue);
-                        success = true;  // If no exception has been thrown by 'merged', assume the conflict solved.
+                        success = true;         // If no exception has been thrown by 'merged', assume the conflict solved.
                     }
                 }
             }
@@ -276,6 +280,79 @@ distribute:                 while (it.hasNext()) {
     }
 
     /**
+     * Helper class for merging the content of two maps where values may be other metadata objects.
+     */
+    private final class ForMap<V> implements BiFunction<V,V,V> {
+        /** Used only in case of non-merged values. */ private final ModifiableMetadata parent;
+        /** Used only in case of non-merged values. */ private final String property;
+        /** The map to copy. Will not be modified.  */ private final Map<?,?> source;
+        /** Where to write copied or merged values. */ private final Map<?,?> target;
+
+        /** Creates a new merger for maps. */
+        ForMap(final ModifiableMetadata parent, final String property,
+                final Map<?,?> source, final Map<?,?> target)
+        {
+            this.parent   = parent;
+            this.property = property;
+            this.source   = source;
+            this.target   = target;
+        }
+
+        /**
+         * Executes the merge process between the maps specified at construction time.
+         *
+         * @param  dryRun  {@code true} for verifying if there is a merge conflict
+         *                 instead that performing the actual merge operation.
+         */
+        final boolean run(final boolean dryRun) {
+            for (final Map.Entry<?,?> pe : source.entrySet()) {
+                final Object newValue = pe.getValue();
+                if (dryRun) {
+                    final Object oldValue;
+                    if (newValue != null && (oldValue = target.get(pe.getKey())) != null) {
+                        if (newValue instanceof ModifiableMetadata && copy(oldValue, (ModifiableMetadata) newValue, true)) {
+                            continue;
+                        }
+                        return false;               // Copying maps would overwrite at least one value.
+                    }
+                } else {
+                    /*
+                     * No @SuppressWarnings("unchecked") because this is really unchecked. However since the two maps
+                     * have been fetched by calls to the same getter method on two org.apache.sis.metadata.iso objects,
+                     * the types should.
+                     */
+                    ((Map) target).merge(pe.getKey(), newValue, this);
+                }
+            }
+            return true;
+        }
+
+        /**
+         * Invoked when an entry is about to be written in the target map, but a value already exists for that entry.
+         *
+         * @param  oldValue  the metadata value that already exists.
+         * @param  newValue  the metadata value to copy in the target.
+         * @return the value to copy in the target (merged) map.
+         */
+        @Override
+        public V apply(final V oldValue, final V newValue) {
+            if (newValue instanceof ModifiableMetadata) {
+                switch (resolve(oldValue, (ModifiableMetadata) newValue)) {
+                    default: throw new UnsupportedOperationException();
+                    case IGNORE: break;
+                    case MERGE: {
+                        if (!copy(oldValue, (ModifiableMetadata) newValue, false)) {
+                            merge(parent, property, oldValue, newValue);
+                        }
+                        break;
+                    }
+                }
+            }
+            return (newValue != null) ? newValue : oldValue;
+        }
+    }
+
+    /**
      * The action to perform when a <var>source</var> metadata element is about to be written in an existing
      * <var>target</var> element. Many metadata elements defined by ISO 19115 allows multi-occurrence, i.e.
      * are stored in {@link Collection}. When a value <var>A</var> is about to be added in an existing collection
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/MetadataUtilities.java b/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/MetadataUtilities.java
index 36f6bef..e8a219d 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/MetadataUtilities.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/MetadataUtilities.java
@@ -17,7 +17,10 @@
 package org.apache.sis.internal.metadata;
 
 import java.util.Date;
+import java.util.List;
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Iterator;
 import org.apache.sis.xml.NilReason;
 import org.apache.sis.xml.IdentifierSpace;
 import org.apache.sis.xml.IdentifiedObject;
@@ -198,6 +201,44 @@ public final class MetadataUtilities extends Static {
     }
 
     /**
+     * Sets the first element in the given collection to the given value.
+     * Special cases:
+     *
+     * <ul>
+     *   <li>If the given collection is null, a new collection will be returned.</li>
+     *   <li>If the given new value  is null, then the first element in the collection is removed.</li>
+     *   <li>Otherwise if the given collection is empty, the given value will be added to it.</li>
+     * </ul>
+     *
+     * @param  <T>       the type of elements in the collection.
+     * @param  values    the collection where to add the new value, or {@code null}.
+     * @param  newValue  the new value to set, or {@code null} for instead removing the first element.
+     * @return the collection (may or may not be the given {@code values} collection).
+     *
+     * @see org.apache.sis.internal.util.CollectionsExt#first(Iterable)
+     */
+    public static <T> Collection<T> setFirst(Collection<T> values, final T newValue) {
+        if (values == null) {
+            return LegacyPropertyAdapter.asCollection(newValue);
+        }
+        if (newValue == null) {
+            final Iterator<T> it = values.iterator();
+            if (it.hasNext()) {
+                it.next();
+                it.remove();
+            }
+        } else if (values.isEmpty()) {
+            values.add(newValue);
+        } else {
+            if (!(values instanceof List<?>)) {
+                values = new ArrayList<>(values);
+            }
+            ((List<T>) values).set(0, newValue);
+        }
+        return values;
+    }
+
+    /**
      * Returns the {@code gco:id} or {@code gml:id} value to use for the given object.
      * The returned identifier will be unique in the current XML document.
      *
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/OtherLocales.java b/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/OtherLocales.java
deleted file mode 100644
index 621208b..0000000
--- a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/OtherLocales.java
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- * 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.internal.metadata;
-
-import java.util.Locale;
-import java.util.Iterator;
-import java.util.List;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.AbstractCollection;
-import org.apache.sis.util.ArgumentChecks;
-import org.apache.sis.util.collection.Containers;
-
-
-/**
- * Helper methods for handling the ISO 19115 {@code defaultLocale} and {@code otherLocale} legacy properties.
- * The ISO standard defines them as two separated properties while GeoAPI handles them in a single collection
- * for integration with JDK standard API like {@link Locale#lookup(List, Collection)}.
- *
- * <p>The first element of the {@code languages} collection is taken as the {@code defaultLocale}, and all
- * remaining ones are taken as {@code otherLocale} elements. Instances of this {@code OtherLocales} class
- * are for those remaining elements and are created by the {@link #filter(Collection)} method.</p>
- *
- * @author  Martin Desruisseaux (Geomatys)
- * @version 0.5
- * @since   0.5
- * @module
- */
-public final class OtherLocales extends AbstractCollection<Locale> {
-    /**
-     * The default locale followed by all other locales.
-     */
-    private final Collection<Locale> languages;
-
-    /**
-     * Private constructor for {@link #filter(Collection)} only.
-     */
-    private OtherLocales(final Collection<Locale> languages) {
-        this.languages = languages;
-    }
-
-    /**
-     * Returns a collection for all elements except the first one from the given collection.
-     *
-     * <p><b>Null values and XML marshalling:</b>
-     * The {@code languages} argument may be {@code null} at XML marshalling time. In such case, this method returns
-     * {@code null} instead than an empty set in order to instruct JAXB to not marshal the {@code otherLocale} element
-     * (an empty set would cause JAXB to marshal an empty element). Since the {@code languages} argument given to this
-     * method should never be null except at XML marshalling time, this rule should not be a violation of public API.</p>
-     *
-     * <p>The converse of this {@code filter} method is {@link #merge(Locale, Collection)}.</p>
-     *
-     * @param  languages  the collection containing the default locale followed by the other ones.
-     * @return a collection containing all {@code languages} elements except the first one.
-     */
-    public static Collection<Locale> filter(final Collection<Locale> languages) {
-        return (languages != null) ? new OtherLocales(languages) : null;
-    }
-
-    /**
-     * Returns the number of elements in this collection.
-     *
-     * @return number of other locales.
-     */
-    @Override
-    public int size() {
-        int size = languages.size();
-        if (size != 0) size--;
-        return size;
-    }
-
-    /**
-     * Returns an iterator over all elements in this collection except the first one.
-     *
-     * @return iterator over all other locales.
-     */
-    @Override
-    public Iterator<Locale> iterator() {
-        final Iterator<Locale> it = languages.iterator();
-        if (it.hasNext()) it.next();                            // Skip the first element.
-        return it;
-    }
-
-    /**
-     * Adds a new element to the collection of "other locales". If we had no "default locale" prior this method call,
-     * then this method will choose one before to add the given locale. This is needed since the other locales begin
-     * only after the first element, so a first element needs to exist.
-     *
-     * <p>The above rule could be a risk of confusion for the users, since it could cause the apparition of a default
-     * locale which has never been specified. However this risk exists only when invoking the deprecated methods, or
-     * when unmarshalling a XML document having a {@code otherLocale} property without {@code defaultLocale} property,
-     * which is probably invalid.</p>
-     *
-     * @param  locale  the element to add.
-     * @return {@code true} if the "other locales" collection has been modified as a result of this method call.
-     */
-    @Override
-    public boolean add(final Locale locale) {
-        ArgumentChecks.ensureNonNull("locale", locale);
-        if (languages.isEmpty()) {
-            Locale defaultLocale = Locale.getDefault();
-            if (defaultLocale.equals(locale)) {
-                defaultLocale = Locale.ROOT;                    // Same default than merge(Locale, Collection).
-            }
-            languages.add(defaultLocale);
-        }
-        return languages.add(locale);
-    }
-
-    /**
-     * Returns a collection containing the given {@code defaultLocale} followed by the {@code otherLocales}.
-     *
-     * @param  defaultLocale  the first element in the collection to be returned, or {@code null} if unspecified.
-     * @param  otherLocales   all remaining elements in the collection to be returned, or {@code null} if none.
-     * @return a collection containing the default locale followed by all other ones.
-     */
-    public static Collection<Locale> merge(Locale defaultLocale, final Collection<? extends Locale> otherLocales) {
-        final Collection<Locale> merged;
-        if (Containers.isNullOrEmpty(otherLocales)) {
-            merged = LegacyPropertyAdapter.asCollection(defaultLocale);
-        } else {
-            merged = new ArrayList<>(otherLocales.size() + 1);
-            if (defaultLocale == null) {
-                defaultLocale = Locale.getDefault();
-                if (otherLocales.contains(defaultLocale)) {
-                    defaultLocale = Locale.ROOT;                            // Same default than add(Locale).
-                }
-            }
-            merged.add(defaultLocale);
-            merged.addAll(otherLocales);
-        }
-        return merged;
-    }
-
-    /**
-     * Sets the first element in the given collection to the given value.
-     * Special cases:
-     *
-     * <ul>
-     *   <li>If the given collection is null, a new collection will be returned.</li>
-     *   <li>If the given new value  is null, then the first element in the collection is removed.</li>
-     *   <li>Otherwise if the given collection is empty, the given value will be added to it.</li>
-     * </ul>
-     *
-     * <p><b>Note:</b> while defined in {@code OtherLocales} because the primary use for this method is to
-     * get the default locale, this method is also opportunistically used for other legacy properties.</p>
-     *
-     * @param  <T>       the type of elements in the collection.
-     * @param  values    the collection where to add the new value, or {@code null}.
-     * @param  newValue  the new value to set, or {@code null} for instead removing the first element.
-     * @return the collection (may or may not be the given {@code values} collection).
-     *
-     * @see org.apache.sis.internal.util.CollectionsExt#first(Iterable)
-     */
-    public static <T> Collection<T> setFirst(Collection<T> values, final T newValue) {
-        if (values == null) {
-            return LegacyPropertyAdapter.asCollection(newValue);
-        }
-        if (newValue == null) {
-            final Iterator<T> it = values.iterator();
-            if (it.hasNext()) {
-                it.next();
-                it.remove();
-            }
-        } else if (values.isEmpty()) {
-            values.add(newValue);
-        } else {
-            if (!(values instanceof List<?>)) {
-                values = new ArrayList<>(values);
-            }
-            ((List<T>) values).set(0, newValue);
-        }
-        return values;
-    }
-}
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/internal/simple/SimpleMetadata.java b/core/sis-metadata/src/main/java/org/apache/sis/internal/simple/SimpleMetadata.java
index 99f2e33..5454a81 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/internal/simple/SimpleMetadata.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/internal/simple/SimpleMetadata.java
@@ -21,6 +21,7 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.Date;
 import java.util.Locale;
+import java.util.Map;
 import org.opengis.metadata.ApplicationSchemaInformation;
 import org.opengis.metadata.Identifier;
 import org.opengis.metadata.Metadata;
@@ -79,7 +80,7 @@ import org.opengis.util.InternationalString;
  * </ul>
  *
  * @author  Johann Sorel (Geomatys)
- * @version 0.8
+ * @version 1.0
  * @since   0.8
  * @module
  */
@@ -113,12 +114,21 @@ public class SimpleMetadata implements Metadata, MetadataScope, DataIdentificati
      * Also the language(s) used within the data.
      */
     @Override
+    public Map<Locale,Charset> getLocalesAndCharsets() {
+        return Collections.emptyMap();
+    }
+
+    /**
+     * @deprecated As of SIS 1.0, replaced by {@link #getLocalesAndCharsets()}.
+     */
+    @Override
+    @Deprecated
     public Collection<Locale> getLanguages() {
         return Collections.emptySet();                  // We use 'Set' because we handle 'Locale' like a CodeList.
     }
 
     /**
-     * @deprecated As of SIS 0.5, replaced by {@link #getLanguages()}.
+     * @deprecated As of SIS 1.0, replaced by {@link #getLocalesAndCharsets()}.
      */
     @Override
     @Deprecated
@@ -127,7 +137,7 @@ public class SimpleMetadata implements Metadata, MetadataScope, DataIdentificati
     }
 
     /**
-     * @deprecated As of SIS 0.5, replaced by {@link #getLanguages()}.
+     * @deprecated As of SIS 1.0, replaced by {@link #getLocalesAndCharsets()}.
      */
     @Override
     @Deprecated
@@ -136,10 +146,10 @@ public class SimpleMetadata implements Metadata, MetadataScope, DataIdentificati
     }
 
     /**
-     * The character coding standard used for the metadata set.
-     * Also the character coding standard(s) used for the dataset.
+     * @deprecated As of SIS 1.0, replaced by {@link #getLocalesAndCharsets()}.
      */
     @Override
+    @Deprecated
     public Collection<Charset> getCharacterSets() {
         return Collections.emptySet();                  // We use 'Set' because we handle 'Charset' like a CodeList.
     }
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/metadata/ModifiableMetadata.java b/core/sis-metadata/src/main/java/org/apache/sis/metadata/ModifiableMetadata.java
index bc7c901..56b5b24 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/metadata/ModifiableMetadata.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/metadata/ModifiableMetadata.java
@@ -16,11 +16,14 @@
  */
 package org.apache.sis.metadata;
 
+import java.util.Map;
 import java.util.Set;
 import java.util.List;
+import java.util.EnumMap;
 import java.util.EnumSet;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.Locale;
 import java.util.Currency;
 import java.util.NoSuchElementException;
@@ -539,7 +542,7 @@ public abstract class ModifiableMetadata extends AbstractMetadata {
             if (state == FREEZING) {
                 /*
                  * transition(State.FINAL) is under progress. The source collection is already
-                 * an unmodifiable instance created by StageChanger.
+                 * an unmodifiable instance created by StateChanger.
                  */
                 assert (useSet != null) || collectionType(elementType).isInstance(source) : elementType;
                 return (Collection<E>) source;
@@ -578,6 +581,61 @@ public abstract class ModifiableMetadata extends AbstractMetadata {
     }
 
     /**
+     * Writes the content of the {@code source} map into the {@code target} map,
+     * creating it if needed. This method performs the following steps:
+     *
+     * <ul>
+     *   <li>Invokes {@link #checkWritePermission(Object)} in order to ensure that this metadata is modifiable.</li>
+     *   <li>If {@code source} is null or empty, returns {@code null}
+     *       (meaning that the metadata property is not provided).</li>
+     *   <li>If {@code target} is null, creates a new {@link Map}.</li>
+     *   <li>Copies the content of the given {@code source} into the target.</li>
+     * </ul>
+     *
+     * @param  <K>      the type of keys represented by the {@code Class} argument.
+     * @param  <V>      the type of values in the map.
+     * @param  source   the source map, or {@code null}.
+     * @param  target   the target map, or {@code null} if not yet created.
+     * @param  keyType  the base type of keys to put in the map.
+     * @return a map (possibly the {@code target} instance) containing the {@code source} entries,
+     *         or {@code null} if the source was null.
+     * @throws UnmodifiableMetadataException if this metadata is unmodifiable.
+     *
+     * @see #nonNullMap(Map, Class)
+     *
+     * @since 1.0
+     */
+    @SuppressWarnings("unchecked")
+    protected final <K,V> Map<K,V> writeMap(final Map<? extends K, ? extends V> source, Map<K,V> target,
+            Class<K> keyType) throws UnmodifiableMetadataException
+    {
+        /*
+         * Code in this method is a copy of write(Collection, Collection, Class) with some calls inlined.
+         * See the comments inside that write(…) method body for more information on the logic.
+         */
+        if (source != target) {
+            if (state == FREEZING) {
+                return (Map<K,V>) source;
+            }
+            checkWritePermission((target == null) || target.isEmpty() ? null : target);
+            if (isNullOrEmpty(source)) {
+                target = null;
+            } else {
+                if (target != null && state != COMPLETABLE) {
+                    target.clear();
+                } else {
+                    target = createMap(keyType, source);
+                }
+                target.putAll(source);
+                if (state == COMPLETABLE) {
+                    target = CollectionsExt.unmodifiableOrCopy(target);
+                }
+            }
+        }
+        return target;
+    }
+
+    /**
      * Creates a list with the content of the {@code source} collection,
      * or returns {@code null} if the source is {@code null} or empty.
      * This is a convenience method for copying fields in subclass copy constructors.
@@ -646,6 +704,29 @@ public abstract class ModifiableMetadata extends AbstractMetadata {
     }
 
     /**
+     * Creates a map with the content of the {@code source} map,
+     * or returns {@code null} if the source is {@code null} or empty.
+     * This is a convenience method for copying fields in subclass copy constructors.
+     *
+     * @param  <K>      the type of keys represented by the {@code Class} argument.
+     * @param  <V>      the type of values in the map.
+     * @param  source   the source map, or {@code null}.
+     * @param  keyType  the base type of keys to put in the map.
+     * @return a map containing the {@code source} entries,
+     *         or {@code null} if the source was null or empty.
+     *
+     * @since 1.0
+     */
+    protected final <K,V> Map<K,V> copyMap(final Map<? extends K, ? extends V> source, final Class<K> keyType) {
+        if (isNullOrEmpty(source)) {
+            return null;
+        }
+        final Map<K,V> target = createMap(keyType, source);
+        target.putAll(source);
+        return target;
+    }
+
+    /**
      * Creates a singleton list or set containing only the given value, if non-null.
      * This is a convenience method for initializing fields in subclass constructors.
      *
@@ -684,13 +765,13 @@ public abstract class ModifiableMetadata extends AbstractMetadata {
     }
 
     /**
-     * Returns the specified list, or a new one if {@code c} is null.
+     * Returns the specified list, or a new one if {@code current} is null.
      * This is a convenience method for implementation of {@code getFoo()} methods.
      *
      * @param  <E>          the type represented by the {@code Class} argument.
      * @param  current      the existing list, or {@code null} if the list has not yet been created.
-     * @param  elementType  the element type (used only if {@code c} is null).
-     * @return {@code c}, or a new list if {@code c} is null.
+     * @param  elementType  the element type (used only if {@code current} is null).
+     * @return {@code current}, or a new list if {@code current} is null.
      */
     protected final <E> List<E> nonNullList(final List<E> current, final Class<E> elementType) {
         if (current != null) {
@@ -700,26 +781,19 @@ public abstract class ModifiableMetadata extends AbstractMetadata {
             return null;
         }
         if (state < FREEZING) {
-            /*
-             * Do not specify an initial capacity, because the list will stay empty in a majority of cases
-             * (i.e. the users will want to iterate over the list elements more often than they will want
-             * to add elements). JDK implementation of ArrayList has a lazy instantiation mechanism for
-             * initially empty lists, but as of JDK8 this lazy instantiation works only for list having
-             * the default capacity.
-             */
-            return new CheckedArrayList<>(elementType);
+            return createList(elementType, current);        // `current` given as a matter of principle even if null.
         }
         return Collections.emptyList();
     }
 
     /**
-     * Returns the specified set, or a new one if {@code c} is null.
+     * Returns the specified set, or a new one if {@code current} is null.
      * This is a convenience method for implementation of {@code getFoo()} methods.
      *
      * @param  <E>          the type represented by the {@code Class} argument.
      * @param  current      the existing set, or {@code null} if the set has not yet been created.
-     * @param  elementType  the element type (used only if {@code c} is null).
-     * @return {@code c}, or a new set if {@code c} is null.
+     * @param  elementType  the element type (used only if {@code current} is null).
+     * @return {@code current}, or a new set if {@code current} is null.
      */
     protected final <E> Set<E> nonNullSet(final Set<E> current, final Class<E> elementType) {
         if (current != null) {
@@ -729,13 +803,13 @@ public abstract class ModifiableMetadata extends AbstractMetadata {
             return null;
         }
         if (state < FREEZING) {
-            return createSet(elementType, null);
+            return createSet(elementType, current);     // `current` given as a matter of principle even if null.
         }
         return Collections.emptySet();
     }
 
     /**
-     * Returns the specified collection, or a new one if {@code c} is null.
+     * Returns the specified collection, or a new one if {@code current} is null.
      * This is a convenience method for implementation of {@code getFoo()} methods.
      *
      * <div class="section">Choosing a collection type</div>
@@ -747,8 +821,8 @@ public abstract class ModifiableMetadata extends AbstractMetadata {
      *
      * @param  <E>          the type represented by the {@code Class} argument.
      * @param  current      the existing collection, or {@code null} if the collection has not yet been created.
-     * @param  elementType  the element type (used only if {@code c} is null).
-     * @return {@code c}, or a new collection if {@code c} is null.
+     * @param  elementType  the element type (used only if {@code current} is null).
+     * @return {@code current}, or a new collection if {@code current} is null.
      */
     protected final <E> Collection<E> nonNullCollection(final Collection<E> current, final Class<E> elementType) {
         if (current != null) {
@@ -761,14 +835,13 @@ public abstract class ModifiableMetadata extends AbstractMetadata {
         final boolean isModifiable = (state < FREEZING);
         if (useSet(elementType)) {
             if (isModifiable) {
-                return createSet(elementType, null);
+                return createSet(elementType, current);         // `current` given as a matter of principle even if null.
             } else {
                 return Collections.emptySet();
             }
         } else {
             if (isModifiable) {
-                // Do not specify an initial capacity for the reason explained in nonNullList(…).
-                return new CheckedArrayList<>(elementType);
+                return createList(elementType, current);        // `current` given as a matter of principle even if null.
             } else {
                 return Collections.emptyList();
             }
@@ -776,6 +849,31 @@ public abstract class ModifiableMetadata extends AbstractMetadata {
     }
 
     /**
+     * Returns the specified map, or a new one if {@code current} is null.
+     * This is a convenience method for implementation of {@code getFoo()} methods.
+     *
+     * @param  <K>      the type of keys represented by the {@code Class} argument.
+     * @param  <V>      the type of values in the map.
+     * @param  current  the existing map, or {@code null} if the map has not yet been created.
+     * @param  keyType  the key type (used only if {@code current} is null).
+     * @return {@code current}, or a new map if {@code current} is null.
+     *
+     * @since 1.0
+     */
+    protected final <K,V> Map<K,V> nonNullMap(final Map<K,V> current, final Class<K> keyType) {
+        if (current != null) {
+            return current.isEmpty() && emptyCollectionAsNull() ? null : current;
+        }
+        if (emptyCollectionAsNull()) {
+            return null;
+        }
+        if (state < FREEZING) {
+            return createMap(keyType, current);         // `current` given as a matter of principle even if null.
+        }
+        return Collections.emptyMap();
+    }
+
+    /**
      * Creates a modifiable list for elements of the given type. This method is defined mostly
      * for consistency with {@link #createSet(Class, Collection)}.
      *
@@ -783,12 +881,23 @@ public abstract class ModifiableMetadata extends AbstractMetadata {
      *                 only for computing initial capacity; it does not perform the actual copy.
      */
     private static <E> List<E> createList(final Class<E> elementType, final Collection<?> source) {
+        if (source == null) {
+            /*
+             * Do not specify an initial capacity, because the list will stay empty in a majority of cases
+             * (i.e. the users will want to iterate over the list elements more often than they will want
+             * to add elements). JDK implementation of ArrayList has a lazy instantiation mechanism for
+             * initially empty lists, but as of JDK 10 this lazy instantiation works only for list having
+             * the default capacity.
+             */
+            return new CheckedArrayList<>(elementType);
+        }
         return new CheckedArrayList<>(elementType, source.size());
     }
 
     /**
      * Creates a modifiable set for elements of the given type. This method will create an {@link EnumSet},
      * {@link CodeListSet} or {@link java.util.LinkedHashSet} depending on the {@code elementType} argument.
+     * The set must have a stable iteration order (this is needed by {@link TreeTableView}).
      *
      * @param  source  the collection to be copied in the new set, or {@code null} if unknown.
      *                 This method uses this information only for computing initial capacity;
@@ -811,6 +920,23 @@ public abstract class ModifiableMetadata extends AbstractMetadata {
     }
 
     /**
+     * Creates a modifiable map for elements of the given type.
+     * The map must have a stable iteration order (this is needed by {@link TreeTableView}).
+     *
+     * @param  source  the map to be copied in the new map. This method uses this information
+     *                 only for computing initial capacity; it does not perform the actual copy.
+     */
+    @SuppressWarnings({"unchecked","rawtypes"})
+    private static <K,V> Map<K,V> createMap(final Class<K> keyType, final Map<?,?> source) {
+        if (Enum.class.isAssignableFrom(keyType)) {
+            return new EnumMap(keyType);
+        } else {
+            // Must be LinkedHashMap, not HashMap, because TreeTableView needs stable iteration order.
+            return new LinkedHashMap<>((source != null) ? Containers.hashMapCapacity(source.size()) : 4);
+        }
+    }
+
+    /**
      * Returns {@code true} if we should use a {@link Set} instead than a {@link List}
      * for elements of the given type.
      */
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/metadata/PropertyAccessor.java b/core/sis-metadata/src/main/java/org/apache/sis/metadata/PropertyAccessor.java
index 8314aef..5d2e52f 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/metadata/PropertyAccessor.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/metadata/PropertyAccessor.java
@@ -28,6 +28,7 @@ import java.lang.reflect.InvocationTargetException;
 import org.opengis.annotation.UML;
 import org.opengis.metadata.citation.Citation;
 import org.opengis.metadata.ExtendedElementInformation;
+import org.apache.sis.internal.util.CollectionsExt;
 import org.apache.sis.internal.util.Citations;
 import org.apache.sis.internal.util.Numerics;
 import org.apache.sis.measure.ValueRange;
@@ -109,7 +110,7 @@ class PropertyAccessor {
         try {
             EXTRA_GETTER = IdentifiedObject.class.getMethod("getIdentifiers", (Class<?>[]) null);
         } catch (NoSuchMethodException e) {
-            throw new AssertionError(e); // Should never happen.
+            throw new AssertionError(e);                                // Should never happen.
         }
     }
 
@@ -192,6 +193,7 @@ class PropertyAccessor {
      *
      * <p>Notes:</p>
      * <ul>
+     *   <li>Element type of {@link Map} collection is {@link Map.Entry}.</li>
      *   <li>Primitive types like {@code double} or {@code int} are converted to their wrapper types.</li>
      *   <li>This array may contain null values if the type of elements in a collection is unknown
      *       (i.e. the collection is not parameterized).</li>
@@ -250,11 +252,11 @@ class PropertyAccessor {
         int standardCount = allCount;
         if (allCount != 0 && getters[allCount-1] == EXTRA_GETTER) {
             if (!EXTRA_GETTER.getDeclaringClass().isAssignableFrom(implementation)) {
-                allCount--; // The extra getter method does not exist.
+                allCount--;                                 // The extra getter method does not exist.
             }
             standardCount--;
         }
-        while (standardCount != 0) { // Skip deprecated methods.
+        while (standardCount != 0) {                        // Skip deprecated methods.
             if (!isDeprecated(standardCount - 1)) {
                 break;
             }
@@ -325,8 +327,10 @@ class PropertyAccessor {
                 try {
                     getter = implementation.getMethod(getter.getName(), (Class<?>[]) null);
                 } catch (NoSuchMethodException error) {
-                    // Should never happen, since the implementation class
-                    // implements the interface where the getter come from.
+                    /*
+                     * Should never happen, since the implementation class
+                     * implements the interface where the getter come from.
+                     */
                     throw new AssertionError(error);
                 }
                 if (returnType != (returnType = getter.getReturnType())) {
@@ -334,8 +338,10 @@ class PropertyAccessor {
                     try {
                         setter = implementation.getMethod(name, arguments);
                     } catch (NoSuchMethodException ignore) {
-                        // There is no setter, which may be normal. At this stage
-                        // the 'setter' variable should still have the null value.
+                        /*
+                         * There is no setter, which may be normal. At this stage
+                         * the 'setter' variable should still have the null value.
+                         */
                     }
                 }
             }
@@ -358,6 +364,8 @@ class PropertyAccessor {
                     // Subclass has erased parameterized type. Use method declared in the interface.
                     elementType = Classes.boundOfParameterizedProperty(getters[i]);
                 }
+            } else if (Map.class.isAssignableFrom(elementType)) {
+                elementType = Map.Entry.class;
             }
             elementTypes[i] = Numbers.primitiveToWrapper(elementType);
         }
@@ -530,7 +538,7 @@ class PropertyAccessor {
      * @return the name of the given kind at the given index, or {@code null} if the index is out of bounds.
      */
     @SuppressWarnings("fallthrough")
-    @Workaround(library="JDK", version="1.7") // Actually apply to String.intern() below.
+    @Workaround(library="JDK", version="10")                        // Actually apply to String.intern() below.
     final String name(final int index, final KeyNamePolicy keyPolicy) {
         if (index >= 0 && index < names.length) {
             switch (keyPolicy) {
@@ -538,10 +546,10 @@ class PropertyAccessor {
                     final UML uml = getters[index].getAnnotation(UML.class);
                     if (uml != null) {
                         /*
-                         * Workaround here: I though that annotation strings were interned like any other
-                         * constants, but it doesn't seem to be the case as of JDK7. To check if a future
-                         * JDK release still needs this explicit call to String.intern(), try to remove
-                         * the ".intern()" part and run the NameMapTest.testStringIntern() method.
+                         * Workaround here: I though that annotation strings were interned like any other constants,
+                         * but it does not seem to be the case as of JDK 10. To check if a future JDK release still
+                         * needs this explicit call to String.intern(), try to remove the ".intern()" part and run
+                         * the NameMapTest.testStringIntern() method.
                          */
                         return uml.identifier().intern();
                     }
@@ -606,16 +614,24 @@ class PropertyAccessor {
     }
 
     /**
-     * Returns {@code true} if the type at the given index is {@link Collection}.
+     * Returns {@code true} if the type at the given index is {@link Collection} or {@link Map}.
      */
-    final boolean isCollection(final int index) {
+    final boolean isCollectionOrMap(final int index) {
         if (index >= 0 && index < allCount) {
-            return Collection.class.isAssignableFrom(getters[index].getReturnType());
+            final Class<?> type = getters[index].getReturnType();
+            return Collection.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type);
         }
         return false;
     }
 
     /**
+     * Returns {@code true} if the type at the given index is {@link Map}.
+     */
+    final boolean isMap(final int index) {
+        return (index >= 0 && index < allCount) &&  elementTypes[index] == Map.Entry.class;
+    }
+
+    /**
      * Returns {@code true} if the property at the given index is deprecated, either in the interface that
      * declare the method or in the implementation class. A method may be deprecated in the implementation
      * but not in the interface when the implementation has been updated for a new standard
@@ -1132,7 +1148,7 @@ class PropertyAccessor {
                          * Count always at least one element because if the user wanted to skip null or empty
                          * collections, then 'valuePolicy.isSkipped(value)' above would have returned 'true'.
                          */
-                        count += (value != null && isCollection(i)) ? Math.max(((Collection<?>) value).size(), 1) : 1;
+                        count += isCollectionOrMap(i) ? Math.max(CollectionsExt.size(value), 1) : 1;
                         break;
                     }
                     default: throw new AssertionError(mode);
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/metadata/SpecialCases.java b/core/sis-metadata/src/main/java/org/apache/sis/metadata/SpecialCases.java
index edcd984..6186158 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/metadata/SpecialCases.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/metadata/SpecialCases.java
@@ -25,10 +25,9 @@ import org.apache.sis.util.collection.BackingStoreException;
 
 
 /**
- * Substitute on-the-fly the values of some properties handled in a special way.
- * The current implementation handles only the longitude and latitude bounds of
- * {@link GeographicBoundingBox}, which are returned as {@link Longitude} or
- * {@link Latitude} instances instead of {@link Double}.
+ * Substitute on-the-fly the values of some ISO 19115 properties handled in a special way.
+ * Current implementation handles the longitude and latitude bounds of {@link GeographicBoundingBox},
+ * which are returned as {@link Longitude} or {@link Latitude} instances instead of {@link Double}.
  *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 1.0
@@ -156,4 +155,27 @@ final class SpecialCases extends PropertyAccessor {
             return super.set(index, metadata, value, mode);
         }
     }
+
+    /**
+     * Returns {@code true} if the property at the given index is a {@code Map<Locale,Charset>}.
+     */
+    static boolean isLocaleAndCharset(final PropertyAccessor accessor, final int indexInData) {
+        return accessor.isMap(indexInData) && accessor.type.getName().startsWith("org.opengis.metadata.")
+                 && "localesAndCharsets".equals(accessor.name(indexInData, KeyNamePolicy.JAVABEANS_PROPERTY));
+    }
+
+    /**
+     * Returns the identifier to use in replacement of the identifier given in {@link org.opengis.annotation.UML} annotations.
+     * We usually want to use those identifiers as-is because they were specified by ISO standards, but we may an exception if
+     * the identifier is actually a construction of two or more identifiers like {@code "defaultLocale+otherLocale"}.
+     *
+     * @param  name  the UML identifier(s) from ISO specification.
+     * @return the potentially simplified identifier to use for displaying purpose.
+     */
+    static String rename(final String name) {
+        if ("defaultLocale+otherLocale".equals(name)) {
+            return "locale";
+        }
+        return name;
+    }
 }
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/metadata/TreeNode.java b/core/sis-metadata/src/main/java/org/apache/sis/metadata/TreeNode.java
index 1cfa971..cdf45e7 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/metadata/TreeNode.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/metadata/TreeNode.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.metadata;
 
+import java.util.Map;
 import java.util.List;
 import java.util.Iterator;
 import java.util.Collection;
@@ -23,6 +24,9 @@ import java.util.Collections;
 import java.util.Objects;
 import java.util.NoSuchElementException;
 import java.util.ConcurrentModificationException;
+import java.util.function.Function;
+import org.apache.sis.internal.jaxb.lan.LocaleAndCharset;
+import org.apache.sis.internal.util.CollectionsExt;
 import org.apache.sis.util.Classes;
 import org.apache.sis.util.iso.Types;
 import org.apache.sis.util.CharSequences;
@@ -172,7 +176,7 @@ class TreeNode implements Node {
      * @param  metadata  the metadata object for which this node will be a value.
      * @param  baseType  the return type of the getter method that provides the value encapsulated by this node.
      */
-    TreeNode(final TreeNode parent, final Object metadata, final Class<?> baseType) {
+    private TreeNode(final TreeNode parent, final Object metadata, final Class<?> baseType) {
         this.table    = parent.table;
         this.parent   = parent;
         this.metadata = metadata;
@@ -321,6 +325,19 @@ class TreeNode implements Node {
         private final int indexInData;
 
         /**
+         * If tree node should be wrapped in another object before to be returned, the function performing that wrapping.
+         * This is used if we want to render a metadata property in a different way than the way implied by JavaBeans.
+         * The wrapping operation should be cheap because it will be applied every time the user request the node.
+         *
+         * <div class="note"><b>Example:</b>
+         * the {@code "defaultLocale+otherLocale"} property is represented by {@code Map.Entry<Locale,Charset>} values.
+         * The nodes created by this class contain those {@code Map.Entry} values, but we want to show them to users as
+         * as a {@link java.util.Locale} node with a {@link java.nio.charset.Charset} child. This separation is done by
+         * {@link LocaleAndCharset}.</div>
+         */
+        final Function<TreeNode,Node> decorator;
+
+        /**
          * Creates a new child for a property of the given metadata at the given index.
          *
          * @param  parent       the parent of this node.
@@ -334,6 +351,11 @@ class TreeNode implements Node {
             super(parent, metadata, accessor.type(indexInData, TypeValuePolicy.ELEMENT_TYPE));
             this.accessor = accessor;
             this.indexInData = indexInData;
+            if (SpecialCases.isLocaleAndCharset(accessor, indexInData)) {
+                decorator = LocaleAndCharset::new;
+            } else {
+                decorator = null;
+            }
         }
 
         /**
@@ -360,7 +382,8 @@ class TreeNode implements Node {
          * node for each element in a collection.
          *
          * <p>If the property name is equals, ignoring case, to the simple type name, then this method
-         * returns the subtype name. For example instead of:</p>
+         * returns the subtype name (<a href="https://issues.apache.org/jira/browse/SIS-298">SIS-298</a>).
+         * For example instead of:</p>
          *
          * {@preformat text
          *   Citation
@@ -377,8 +400,6 @@ class TreeNode implements Node {
          *       └─Individual
          *          └─Name ……………………………… Jon Smith
          * }
-         *
-         * @see <a href="https://issues.apache.org/jira/browse/SIS-298">SIS-298</a>
          */
         @Override
         CharSequence getName() {
@@ -392,6 +413,7 @@ class TreeNode implements Node {
                     }
                 }
             }
+            identifier = SpecialCases.rename(identifier);                       // Hard-coded special case.
             return CharSequences.camelCaseToSentence(identifier).toString();
         }
 
@@ -421,7 +443,7 @@ class TreeNode implements Node {
          * Gets remarks about the value in this node, or {@code null} if none.
          */
         @Override
-        CharSequence getRemarks() {
+        final CharSequence getRemarks() {
             return accessor.remarks(indexInData, metadata);
         }
 
@@ -519,12 +541,9 @@ class TreeNode implements Node {
         @Override
         CharSequence getName() {
             CharSequence name = super.getName();
-            final Collection<?> values = (Collection<?>) super.getUserObject();
-            if (values != null) {
-                final int size = values.size();
-                if (size >= 2) {
-                    name = Vocabulary.formatInternational(Vocabulary.Keys.Of_3, name, indexInList+1, size);
-                }
+            final int size = CollectionsExt.size(super.getUserObject());
+            if (size >= 2) {
+                name = Vocabulary.formatInternational(Vocabulary.Keys.Of_3, name, indexInList+1, size);
             }
             return name;
         }
@@ -535,7 +554,17 @@ class TreeNode implements Node {
          */
         @Override
         public Object getUserObject() {
-            final Collection<?> values = (Collection<?>) super.getUserObject();
+            final Object collection = super.getUserObject();
+            final Collection<?> values;
+            if (collection instanceof Collection<?>) {
+                values = (Collection<?>) collection;
+            } else {
+                /*
+                 * ClassCastException should never happen here unless PropertyAccessor.isCollectionOrMap(…) has
+                 * been modified, in which case there is probably many code to update (not only this method).
+                 */
+                values = ((Map<?,?>) collection).entrySet();
+            }
             /*
              * If the collection is null or empty but the value existence policy tells
              * us that such elements shall be shown, behave as if the collection was a
@@ -672,8 +701,8 @@ class TreeNode implements Node {
              * exists otherwise the call to 'isLeaf()' above would have returned 'true'.
              */
             if (children == null || ((TreeNodeChildren) children).metadata != value) {
-                children = new TreeNodeChildren(this, value,
-                        table.standard.getAccessor(new CacheKey(value.getClass(), baseType), true));
+                PropertyAccessor accessor = table.standard.getAccessor(new CacheKey(value.getClass(), baseType), true);
+                children = new TreeNodeChildren(this, value, accessor);
             }
         }
         return children;
@@ -773,8 +802,8 @@ class TreeNode implements Node {
                     }
                     final TreeNodeChildren siblings = getSiblings();
                     final int indexInList;
-                    if (siblings.isCollection(indexInData)) {
-                        indexInList = ((Collection<?>) siblings.valueAt(indexInData)).size();
+                    if (siblings.isCollectionOrMap(indexInData)) {
+                        indexInList = CollectionsExt.size(siblings.valueAt(indexInData));
                     } else {
                         indexInList = -1;
                     }
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/metadata/TreeNodeChildren.java b/core/sis-metadata/src/main/java/org/apache/sis/metadata/TreeNodeChildren.java
index 7e82ef2..aade06c 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/metadata/TreeNodeChildren.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/metadata/TreeNodeChildren.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.metadata;
 
+import java.util.Map;
 import java.util.Iterator;
 import java.util.Collections;
 import java.util.AbstractCollection;
@@ -94,7 +95,7 @@ final class TreeNodeChildren extends AbstractCollection<TreeTable.Node> {
      * <p>Not all elements in this array will be returned by the iterator.
      * The value needs to be verified for the {@link ValueExistencePolicy}.</p>
      */
-    private final TreeNode[] children;
+    private final TreeNode.Element[] children;
 
     /**
      * Index of the property to write in the parent node instead than as a child.
@@ -138,7 +139,7 @@ final class TreeNodeChildren extends AbstractCollection<TreeTable.Node> {
         this.parent   = parent;
         this.metadata = metadata;
         this.accessor = accessor;
-        this.children = new TreeNode[accessor.count()];
+        this.children = new TreeNode.Element[accessor.count()];
         /*
          * Search for something that looks like the main property, to be associated with the parent node
          * instead than provided as a child. The intent is to have more compact and easy to read trees.
@@ -225,9 +226,8 @@ final class TreeNodeChildren extends AbstractCollection<TreeTable.Node> {
     }
 
     /**
-     * Returns {@code true} if the type at the given index is a collection. The given
-     * {@code index} is relative to the {@link #accessor} indexing, <strong>not</strong>
-     * to this collection.
+     * Returns {@code true} if the type at the given index is a collection or a map.
+     * The given {@code index} is relative to the {@link #accessor} indexing, <strong>not</strong> to this collection.
      *
      * <div class="note"><b>Implementation note:</b>
      * We do not test {@code (value instanceof Collection)} because the value could be any user's implementation.
@@ -236,8 +236,8 @@ final class TreeNodeChildren extends AbstractCollection<TreeTable.Node> {
      * @param  index  the index in the accessor (<em>not</em> the index in this collection).
      * @return {@code true} if the value at the given index is a collection.
      */
-    final boolean isCollection(final int index) {
-        return accessor.isCollection(index);
+    final boolean isCollectionOrMap(final int index) {
+        return accessor.isCollectionOrMap(index);
     }
 
     /**
@@ -263,13 +263,16 @@ final class TreeNodeChildren extends AbstractCollection<TreeTable.Node> {
      *         collection (<em>not</em> the index in <em>this</em> collection). Otherwise -1.
      * @return the node to be returned by public API.
      */
-    final TreeNode childAt(final int index, final int subIndex) {
-        TreeNode node = children[index];
+    final TreeNode.Element childAt(final int index, final int subIndex) {
+        TreeNode.Element node = children[index];
         if (subIndex >= 0) {
             /*
              * If the value is an element of a collection, we will cache only the last used value.
              * We don't cache all elements in order to avoid yet more complex code, and this cover
              * the majority of cases where the collection has only one element anyway.
+             *
+             * Note: subIndex is ≧ 0 only if node is an instance of CollectionElement.
+             * A ClassCastException below would be a logical error in this class.
              */
             if (node == null || ((TreeNode.CollectionElement) node).indexInList != subIndex) {
                 node = new TreeNode.CollectionElement(parent, metadata, accessor, index, subIndex);
@@ -462,23 +465,25 @@ final class TreeNodeChildren extends AbstractCollection<TreeTable.Node> {
                 if (nextInAccessor != titleProperty) {
                     nextValue = valueAt(nextInAccessor);
                     if (!isSkipped(nextValue)) {
-                        if (isCollection(nextInAccessor)) {
+                        if (isCollectionOrMap(nextInAccessor)) {
+                            /*
+                             * Null collections are illegal (it shall be empty collections instead),
+                             * but we try to keep the iterator robust to ill-formed metadata because
+                             * we want AbstractMetadata.toString() to work so we can spot problems.
+                             */
+                            if (nextValue == null) {
+                                subIterator = Collections.emptyIterator();
+                            } else if (nextValue instanceof Iterable<?>) {
+                                subIterator = ((Iterable<?>) nextValue).iterator();
+                            } else {
+                                subIterator = ((Map<?,?>) nextValue).entrySet().iterator();
+                            }
                             /*
                              * If the property is a collection, unconditionally get the first element
                              * even if absent (null) in order to comply with the ValueExistencePolicy.
                              * if we were expected to ignore empty collections, 'isSkipped(nextValue)'
                              * would have returned 'true'.
                              */
-                            if (nextValue != null) {
-                                subIterator = ((Iterable<?>) nextValue).iterator();
-                            } else {
-                                subIterator = Collections.emptyIterator();
-                                /*
-                                 * Null collections are illegal (it shall be empty collections instead),
-                                 * but we try to keep the iterator robut to ill-formed metadata, because
-                                 * we want AbstractMetadata.toString() to work so we can spot problems.
-                                 */
-                            }
                             subIndex = 0;
                             if (subIterator.hasNext()) {
                                 nextValue = subIterator.next();
@@ -502,12 +507,12 @@ final class TreeNodeChildren extends AbstractCollection<TreeTable.Node> {
         /**
          * Returns the node for the metadata property at the current {@link #nextInAccessor}.
          * The value of this property is initially {@link #nextValue}, but this may change at
-         * any time if the user modify the underlying metadata object.
+         * any time if the user modifies the underlying metadata object.
          */
         @Override
         public TreeTable.Node next() {
             if (hasNext()) {
-                final TreeNode node = childAt(nextInAccessor, subIndex);
+                final TreeNode.Element node = childAt(nextInAccessor, subIndex);
                 node.cachedValue = nextValue;
                 previousInAccessor = nextInAccessor;
                 if (subIterator == null) {
@@ -521,7 +526,7 @@ final class TreeNodeChildren extends AbstractCollection<TreeTable.Node> {
                     nextInAccessor++;
                 }
                 isNextVerified = false;
-                return node;
+                return (node.decorator == null) ? node : node.decorator.apply(node);
             }
             throw new NoSuchElementException();
         }
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/DefaultMetadata.java b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/DefaultMetadata.java
index 4390d69..4347249 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/DefaultMetadata.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/DefaultMetadata.java
@@ -18,6 +18,9 @@ package org.apache.sis.metadata.iso;
 
 import java.util.Date;
 import java.util.Locale;
+import java.util.Set;
+import java.util.EnumSet;
+import java.util.Map;
 import java.util.List;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -63,17 +66,22 @@ import org.apache.sis.metadata.iso.citation.DefaultOnlineResource;
 import org.apache.sis.metadata.iso.identification.AbstractIdentification;
 import org.apache.sis.metadata.iso.identification.DefaultDataIdentification;
 import org.apache.sis.internal.metadata.LegacyPropertyAdapter;
-import org.apache.sis.internal.metadata.OtherLocales;
+import org.apache.sis.internal.metadata.MetadataUtilities;
 import org.apache.sis.internal.metadata.Dependencies;
 import org.apache.sis.internal.util.CollectionsExt;
+import org.apache.sis.internal.jaxb.lan.LocaleAndCharset;
 import org.apache.sis.internal.jaxb.lan.LocaleAdapter;
-import org.apache.sis.internal.xml.LegacyNamespaces;
+import org.apache.sis.internal.jaxb.lan.OtherLocales;
+import org.apache.sis.internal.jaxb.lan.PT_Locale;
 import org.apache.sis.internal.jaxb.FilterByVersion;
 import org.apache.sis.internal.jaxb.Context;
 import org.apache.sis.internal.jaxb.metadata.CI_Citation;
 import org.apache.sis.internal.jaxb.metadata.MD_Identifier;
-
-import static org.apache.sis.internal.metadata.MetadataUtilities.valueIfDefined;
+import org.apache.sis.internal.xml.LegacyNamespaces;
+import org.apache.sis.util.collection.Containers;
+import org.apache.sis.util.ObjectConverter;
+import org.apache.sis.internal.converter.SurjectiveConverter;
+import org.apache.sis.math.FunctionProperty;
 
 
 /**
@@ -189,17 +197,12 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
     /**
      * Serial number for inter-operability with different versions.
      */
-    private static final long serialVersionUID = -4935599812744534502L;
-
-    /**
-     * Language(s) used for documenting metadata.
-     */
-    private Collection<Locale> languages;
+    private static final long serialVersionUID = -1128741312274891545L;
 
     /**
-     * Full name of the character coding standard used for the metadata set.
+     * Language(s) and character set(s) used within the dataset.
      */
-    private Collection<Charset> characterSets;
+    private Map<Locale,Charset> locales;
 
     /**
      * Identification of the parent metadata record.
@@ -345,8 +348,7 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
         if (object != null) {
             identifiers                   = singleton(object.getMetadataIdentifier(), Identifier.class);
             parentMetadata                = object.getParentMetadata();
-            languages                     = copyCollection(object.getLanguages(),                     Locale.class);
-            characterSets                 = copyCollection(object.getCharacterSets(),                 Charset.class);
+            locales                       = copyMap       (object.getLocalesAndCharsets(),            Locale.class);
             metadataScopes                = copyCollection(object.getMetadataScopes(),                MetadataScope.class);
             contacts                      = copyCollection(object.getContacts(),                      Responsibility.class);
             dateInfo                      = copyCollection(object.getDateInfo(),                      CitationDate.class);
@@ -476,23 +478,77 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
     }
 
     /**
-     * Returns the language(s) used for documenting metadata.
+     * Returns the language(s) and character set(s) used for documenting metadata.
      * The first element in iteration order is the default language.
      * All other elements, if any, are alternate language(s) used within the resource.
      *
-     * <p>Unless an other locale has been specified with the {@link org.apache.sis.xml.XML#LOCALE} property,
+     * <p>Unless another locale has been specified with the {@link org.apache.sis.xml.XML#LOCALE} property,
      * this {@code DefaultMetadata} instance and its children will use the first locale returned by this method
      * for marshalling {@link org.opengis.util.InternationalString} and {@link org.opengis.util.CodeList} instances
-     * in ISO 19115-2 compliant XML documents.
+     * in ISO 19115-2 compliant XML documents.</p>
      *
-     * @return language(s) used for documenting metadata.
+     * <div class="section">Relationship with ISO 19115</div>
+     * Each ({@link Locale}, {@link Charset}) entry is equivalent to an instance of ISO {@code PT_Locale} class.
+     * ISO 19115-1:2014 represents character sets by references to the
+     * <a href="http://www.iana.org/assignments/character-sets">IANA Character Set register</a>,
+     * which is represented in Java by {@link java.nio.charset.Charset}.
+     * Instances can be obtained by a call to {@link Charset#forName(String)}.
      *
-     * @since 0.5
+     * <div class="note"><b>Examples:</b>
+     * {@code UCS-2}, {@code UCS-4}, {@code UTF-7}, {@code UTF-8}, {@code UTF-16},
+     * {@code ISO-8859-1} (a.k.a. {@code ISO-LATIN-1}), {@code ISO-8859-2}, {@code ISO-8859-3}, {@code ISO-8859-4},
+     * {@code ISO-8859-5}, {@code ISO-8859-6}, {@code ISO-8859-7}, {@code ISO-8859-8}, {@code ISO-8859-9},
+     * {@code ISO-8859-10}, {@code ISO-8859-11}, {@code ISO-8859-12}, {@code ISO-8859-13}, {@code ISO-8859-14},
+     * {@code ISO-8859-15}, {@code ISO-8859-16},
+     * {@code JIS_X0201}, {@code Shift_JIS}, {@code EUC-JP}, {@code US-ASCII}, {@code EBCDIC}, {@code EUC-KR},
+     * {@code Big5}, {@code GB2312}.
+     * </div>
+     *
+     * @return language(s) and character set(s) used for documenting metadata.
+     *
+     * @since 1.0
      */
     @Override
     // @XmlElement at the end of this class.
+    public Map<Locale,Charset> getLocalesAndCharsets() {
+        return locales = nonNullMap(locales, Locale.class);
+    }
+
+    /**
+     * Sets the language(s) and character set(s) used within the dataset.
+     * The first element in iteration order should be the default language.
+     * All other elements, if any, are alternate language(s) used within the resource.
+     *
+     * @param  newValues  the new language(s) and character set(s) used for documenting metadata.
+     *
+     * @see org.apache.sis.xml.XML#LOCALE
+     *
+     * @since 1.0
+     */
+    public void setLocalesAndCharsets(final Map<? extends Locale, ? extends Charset> newValues) {
+        locales = writeMap(newValues, locales, Locale.class);
+        /*
+         * The "magic" applying this language to every children
+         * is performed by the 'beforeMarshal(Marshaller)' method.
+         */
+    }
+
+    /**
+     * Returns the language(s) used for documenting metadata.
+     * The first element in iteration order is the default language.
+     * All other elements, if any, are alternate language(s) used within the resource.
+     *
+     * @return language(s) used for documenting metadata.
+     *
+     * @since 0.5
+     *
+     * @deprecated Replaced by {@code getLocalesAndCharsets().keySet()}.
+     */
+    @Deprecated
+    @Dependencies("getLocalesAndCharsets")
     public Collection<Locale> getLanguages() {
-        return languages = nonNullCollection(languages, Locale.class);
+        // TODO: delete after SIS 1.0 release (method not needed by JAXB).
+        return FilterByVersion.LEGACY_METADATA.accept() ? LocaleAndCharset.getLanguages(getLocalesAndCharsets()) : null;
     }
 
     /**
@@ -502,14 +558,14 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
      *
      * @param  newValues  the new languages.
      *
-     * @see org.apache.sis.xml.XML#LOCALE
-     *
      * @since 0.5
+     *
+     * @deprecated Replaced by putting keys in {@link #getLocalesAndCharsets()} map.
      */
+    @Deprecated
     public void setLanguages(final Collection<Locale> newValues) {
-        languages = writeCollection(newValues, languages, Locale.class);
-        // The "magic" applying this language to every children
-        // is performed by the 'beforeMarshal(Marshaller)' method.
+        // TODO: delete after SIS 1.0 release (method not needed by JAXB).
+        setLocalesAndCharsets(LocaleAndCharset.setLanguages(getLocalesAndCharsets(), newValues));
     }
 
     /**
@@ -521,9 +577,8 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
      */
     @Override
     @Deprecated
-    @Dependencies("getLanguages")
+    @Dependencies("getLocalesAndCharsets")
     @XmlElement(name = "language", namespace = LegacyNamespaces.GMD)
-    @XmlJavaTypeAdapter(LocaleAdapter.class)
     public Locale getLanguage() {
         return FilterByVersion.LEGACY_METADATA.accept() ? CollectionsExt.first(getLanguages()) : null;
         /*
@@ -547,8 +602,7 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
      */
     @Deprecated
     public void setLanguage(final Locale newValue) {
-        checkWritePermission(valueIfDefined(languages));
-        setDefaultLocale(newValue);
+        setLocalesAndCharsets(OtherLocales.setFirst(locales, new PT_Locale(newValue)));
     }
 
     /**
@@ -560,54 +614,56 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
      */
     @Override
     @Deprecated
-    @Dependencies("getLanguages")
+    @Dependencies("getLocalesAndCharsets")
     @XmlElement(name = "locale", namespace = LegacyNamespaces.GMD)
+    @XmlJavaTypeAdapter(LocaleAdapter.Wrapped.class)
     public Collection<Locale> getLocales() {
-        return FilterByVersion.LEGACY_METADATA.accept() ? OtherLocales.filter(getLanguages()) : null;
+        if (FilterByVersion.LEGACY_METADATA.accept()) {
+            final Set<PT_Locale> locales = OtherLocales.filter(getLocalesAndCharsets());
+            return Containers.derivedSet(locales, ToLocale.INSTANCE);
+        }
+        return null;
     }
 
     /**
-     * Sets information about an alternatively used localized character string for a linguistic extension.
-     *
-     * @param  newValues  the new locales.
-     *
-     * @deprecated As of SIS 0.5, replaced by {@link #setLanguages(Collection)}.
+     * Converter from {@link PT_Locale} and {@link Locale}.
      */
-    @Deprecated
-    public void setLocales(final Collection<? extends Locale> newValues) {
-        checkWritePermission(valueIfDefined(languages));
-        setOtherLocales(newValues);
+    private static final class ToLocale extends SurjectiveConverter<PT_Locale,Locale> {
+        static final ToLocale INSTANCE = new ToLocale();
+        private ToLocale() {}
+        @Override public Class<PT_Locale> getSourceClass()   {return PT_Locale.class;}
+        @Override public Class<Locale>    getTargetClass()   {return    Locale.class;}
+        @Override public Locale           apply(PT_Locale p) {return p.getLocale();}
+        @Override public ObjectConverter<Locale, PT_Locale> inverse() {return FromLocale.INSTANCE;}
+    }
+
+    /**
+     * Converter from {@link Locale} and {@link PT_Locale}.
+     */
+    private static final class FromLocale implements ObjectConverter<Locale,PT_Locale> {
+        static final FromLocale INSTANCE = new FromLocale();
+        private FromLocale() {}
+        @Override public Set<FunctionProperty> properties()     {return EnumSet.of(FunctionProperty.INJECTIVE);}
+        @Override public Class<Locale>         getSourceClass() {return Locale.class;}
+        @Override public Class<PT_Locale>      getTargetClass() {return PT_Locale.class;}
+        @Override public PT_Locale             apply(Locale o)  {return (o != null) ? new PT_Locale(o) : null;}
+        @Override public ObjectConverter<PT_Locale, Locale> inverse() {return ToLocale.INSTANCE;}
     }
 
     /**
      * Returns the character coding standard used for the metadata set.
-     * ISO 19115:2014 represents character sets by references to the
-     * <a href="http://www.iana.org/assignments/character-sets">IANA Character Set register</a>,
-     * which is represented in Java by {@link java.nio.charset.Charset}.
-     * Instances can be obtained by a call to {@link Charset#forName(String)}.
-     *
-     * <div class="note"><b>Examples:</b>
-     * {@code UCS-2}, {@code UCS-4}, {@code UTF-7}, {@code UTF-8}, {@code UTF-16},
-     * {@code ISO-8859-1} (a.k.a. {@code ISO-LATIN-1}), {@code ISO-8859-2}, {@code ISO-8859-3}, {@code ISO-8859-4},
-     * {@code ISO-8859-5}, {@code ISO-8859-6}, {@code ISO-8859-7}, {@code ISO-8859-8}, {@code ISO-8859-9},
-     * {@code ISO-8859-10}, {@code ISO-8859-11}, {@code ISO-8859-12}, {@code ISO-8859-13}, {@code ISO-8859-14},
-     * {@code ISO-8859-15}, {@code ISO-8859-16},
-     * {@code JIS_X0201}, {@code Shift_JIS}, {@code EUC-JP}, {@code US-ASCII}, {@code EBCDIC}, {@code EUC-KR},
-     * {@code Big5}, {@code GB2312}.
-     * </div>
      *
      * @return character coding standards used for the metadata.
      *
-     * @see #getLanguages()
-     * @see org.opengis.metadata.identification.DataIdentification#getCharacterSets()
-     * @see Charset#forName(String)
-     * @see <a href="https://issues.apache.org/jira/browse/SIS-402">SIS-402</a>
-     *
      * @since 0.5
+     *
+     * @deprecated Replaced by {@code getLocalesAndCharsets().values()}.
      */
-    @Override
+    @Deprecated
+    @Dependencies("getLocalesAndCharsets")
     public Collection<Charset> getCharacterSets() {
-        return characterSets = nonNullCollection(characterSets, Charset.class);
+        // TODO: delete after SIS 1.0 release (method not needed by JAXB).
+        return FilterByVersion.LEGACY_METADATA.accept() ? LocaleAndCharset.getCharacterSets(getLocalesAndCharsets()) : null;
     }
 
     /**
@@ -616,9 +672,13 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
      * @param  newValues  the new character coding standards.
      *
      * @since 0.5
+     *
+     * @deprecated Replaced by putting values in {@link #getLocalesAndCharsets()} map.
      */
+    @Deprecated
     public void setCharacterSets(final Collection<? extends Charset> newValues) {
-        characterSets = writeCollection(newValues, characterSets, Charset.class);
+        // TODO: delete after SIS 1.0 release (method not needed by JAXB).
+        setLocalesAndCharsets(LocaleAndCharset.setCharacterSets(getLocalesAndCharsets(), newValues));
     }
 
     /**
@@ -630,7 +690,7 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
      */
     @Override
     @Deprecated
-    @Dependencies("getCharacterSets")
+    @Dependencies("getLocalesAndCharsets")
     @XmlElement(name = "characterSet", namespace = LegacyNamespaces.GMD)
     public CharacterSet getCharacterSet() {
         if (FilterByVersion.LEGACY_METADATA.accept()) {
@@ -803,7 +863,7 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
      */
     @Deprecated
     public void setHierarchyLevels(final Collection<? extends ScopeCode> newValues) {
-        checkWritePermission(valueIfDefined(metadataScopes));
+        checkWritePermission(MetadataUtilities.valueIfDefined(metadataScopes));
         ((LegacyPropertyAdapter<ScopeCode,?>) getHierarchyLevels()).setValues(newValues);
     }
 
@@ -854,7 +914,7 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
      */
     @Deprecated
     public void setHierarchyLevelNames(final Collection<? extends String> newValues) {
-        checkWritePermission(valueIfDefined(metadataScopes));
+        checkWritePermission(MetadataUtilities.valueIfDefined(metadataScopes));
         ((LegacyPropertyAdapter<String,?>) getHierarchyLevelNames()).setValues(newValues);
     }
 
@@ -939,7 +999,7 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
      */
     @Deprecated
     public void setDateStamp(final Date newValue) {
-        checkWritePermission(valueIfDefined(dateInfo));
+        checkWritePermission(MetadataUtilities.valueIfDefined(dateInfo));
         Collection<CitationDate> newValues = dateInfo;      // See "Note about deprecated methods implementation"
         if (newValues == null) {
             if (newValue == null) {
@@ -1072,7 +1132,7 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
      * {@link #setMetadataStandardVersion(String)} methods.
      */
     private void setMetadataStandard(final boolean version, final String newValue) {
-        checkWritePermission(valueIfDefined(metadataStandards));
+        checkWritePermission(MetadataUtilities.valueIfDefined(metadataStandards));
         final InternationalString i18n = (newValue != null) ? new SimpleInternationalString(newValue) : null;
         final List<Citation> newValues = (metadataStandards != null)
                 ? new ArrayList<>(metadataStandards)
@@ -1231,7 +1291,7 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
     public void setDataSetUri(final String newValue) throws URISyntaxException {
         final URI uri = new URI(newValue);
         Collection<Identification> info = identificationInfo;   // See "Note about deprecated methods implementation"
-        checkWritePermission(valueIfDefined(info));
+        checkWritePermission(MetadataUtilities.valueIfDefined(info));
         AbstractIdentification firstId = AbstractIdentification.castOrCopy(CollectionsExt.first(info));
         if (firstId == null) {
             firstId = new DefaultDataIdentification();
@@ -1246,10 +1306,10 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
             firstOnline = new DefaultOnlineResource();
         }
         firstOnline.setLinkage(uri);
-        onlineResources = OtherLocales.setFirst(onlineResources, firstOnline);
+        onlineResources = MetadataUtilities.setFirst(onlineResources, firstOnline);
         citation.setOnlineResources(onlineResources);
         firstId.setCitation(citation);
-        info = OtherLocales.setFirst(info, firstId);
+        info = MetadataUtilities.setFirst(info, firstId);
         setIdentificationInfo(info);
     }
 
@@ -1548,7 +1608,7 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
      */
     @SuppressWarnings("unused")
     private void beforeMarshal(final Marshaller marshaller) {
-        Context.push(CollectionsExt.first(languages));
+        Context.push(CollectionsExt.first(LocaleAndCharset.getLanguages(getLocalesAndCharsets())));
     }
 
     /**
@@ -1564,30 +1624,23 @@ public class DefaultMetadata extends ISOMetadata implements Metadata {
      * Gets the default locale for this record (used in ISO 19115-3 format).
      */
     @XmlElement(name = "defaultLocale")
-    private Locale getDefaultLocale() {
-        return FilterByVersion.CURRENT_METADATA.accept() ? CollectionsExt.first(getLanguages()) : null;
+    private PT_Locale getDefaultLocale() {
+        return FilterByVersion.CURRENT_METADATA.accept() ? PT_Locale.first(getLocalesAndCharsets()) : null;
     }
 
     /**
      * Sets the default locale for this record (used in ISO 19115-3 format).
      */
-    private void setDefaultLocale(final Locale newValue) {
-        setLanguages(OtherLocales.setFirst(languages, newValue)); // See "Note about deprecated methods implementation"
+    private void setDefaultLocale(final PT_Locale newValue) {
+        setLocalesAndCharsets(OtherLocales.setFirst(locales, newValue));
     }
 
     /**
      * Gets the other locales for this record (used in ISO 19115-3 format).
      */
     @XmlElement(name = "otherLocale")
-    private Collection<Locale> getOtherLocales() {
-        return FilterByVersion.CURRENT_METADATA.accept() ? OtherLocales.filter(getLanguages()) : null;
-    }
-
-    /**
-     * Sets the other locales for this record (used in ISO 19115-3 format).
-     */
-    private void setOtherLocales(final Collection<? extends Locale> newValues) {
-        setLanguages(OtherLocales.merge(CollectionsExt.first(languages), newValues));
+    private Collection<PT_Locale> getOtherLocales() {
+        return FilterByVersion.CURRENT_METADATA.accept() ? OtherLocales.filter(getLocalesAndCharsets()) : null;
     }
 
     /**
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/content/DefaultFeatureCatalogueDescription.java b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/content/DefaultFeatureCatalogueDescription.java
index ea109f9..1f17382 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/content/DefaultFeatureCatalogueDescription.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/content/DefaultFeatureCatalogueDescription.java
@@ -16,21 +16,23 @@
  */
 package org.apache.sis.metadata.iso.content;
 
-import java.util.Locale;
+import java.util.Map;
 import java.util.Collection;
+import java.util.Locale;
+import java.nio.charset.Charset;
 import javax.xml.bind.annotation.XmlType;
 import javax.xml.bind.annotation.XmlElement;
 import javax.xml.bind.annotation.XmlRootElement;
-import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
 import org.opengis.util.GenericName;
 import org.opengis.metadata.citation.Citation;
 import org.opengis.metadata.content.FeatureCatalogueDescription;
 import org.opengis.metadata.content.FeatureTypeInfo;
 import org.apache.sis.internal.jaxb.FilterByVersion;
 import org.apache.sis.internal.xml.LegacyNamespaces;
-import org.apache.sis.internal.jaxb.lan.LocaleAdapter;
+import org.apache.sis.internal.jaxb.lan.PT_Locale;
 import org.apache.sis.internal.metadata.Dependencies;
 import org.apache.sis.internal.metadata.LegacyPropertyAdapter;
+import org.apache.sis.internal.jaxb.lan.LocaleAndCharset;
 
 import static org.apache.sis.internal.metadata.MetadataUtilities.valueIfDefined;
 
@@ -65,7 +67,7 @@ import static org.apache.sis.internal.metadata.MetadataUtilities.valueIfDefined;
 @XmlType(name = "MD_FeatureCatalogueDescription_Type", propOrder = {
     "compliant",
     "locale",                       // New in ISO 19115:2014
-    "language",                     // Legacy ISO 19115:2003
+    "languages",                    // Legacy ISO 19115:2003
     "includedWithDataset",
     "featureTypesInfo",             // New in ISO 19115:2014. Actual name is "featureTypeInfo"
     "featureTypes",                 // Legacy ISO 19115:2003
@@ -78,7 +80,7 @@ public class DefaultFeatureCatalogueDescription extends AbstractContentInformati
     /**
      * Serial number for inter-operability with different versions.
      */
-    private static final long serialVersionUID = 5731044701122380718L;
+    private static final long serialVersionUID = 4637544662644655274L;
 
     /**
      * Whether or not the cited feature catalogue complies with ISO 19110.
@@ -90,9 +92,9 @@ public class DefaultFeatureCatalogueDescription extends AbstractContentInformati
     private Boolean compliant;
 
     /**
-     * Language(s) used within the catalogue
+     * Language(s) and character set(s) used within the catalogue.
      */
-    private Collection<Locale> languages;
+    private Map<Locale,Charset> locales;
 
     /**
      * Whether or not the feature catalogue is included with the resource.
@@ -129,7 +131,7 @@ public class DefaultFeatureCatalogueDescription extends AbstractContentInformati
         if (object != null) {
             compliant                 = object.isCompliant();
             includedWithDataset       = object.isIncludedWithDataset();
-            languages                 = copyCollection(object.getLanguages(), Locale.class);
+            locales                   = copyMap(object.getLocalesAndCharsets(), Locale.class);
             featureTypes              = copyCollection(object.getFeatureTypeInfo(), FeatureTypeInfo.class);
             featureCatalogueCitations = copyCollection(object.getFeatureCatalogueCitations(), Citation.class);
         }
@@ -182,23 +184,55 @@ public class DefaultFeatureCatalogueDescription extends AbstractContentInformati
     }
 
     /**
-     * Returns the language(s) used within the catalogue
+     * Returns the language(s) and character set(s) used within the catalogue.
      *
-     * @return language(s) used within the catalogue.
+     * @return language(s) and character set(s) used within the catalogue.
+     *
+     * @since 1.0
      */
     @Override
     // @XmlElement at the end of this class.
+    public Map<Locale,Charset> getLocalesAndCharsets() {
+        return locales = nonNullMap(locales, Locale.class);
+    }
+
+    /**
+     * Sets the language(s) and character set(s) used within the catalogue.
+     *
+     * @param  newValues  the new language(s) and character set(s) used within the catalogue.
+     *
+     * @since 1.0
+     */
+    public void setLocalesAndCharsets(final Map<? extends Locale, ? extends Charset> newValues) {
+        locales = writeMap(newValues, locales, Locale.class);
+    }
+
+    /**
+     * Returns the language(s) used within the catalogue.
+     *
+     * @return language(s) used within the catalogue.
+     *
+     * @deprecated Replaced by {@code getLocalesAndCharsets().keySet()}.
+     */
+    @Override
+    @Deprecated
+    @Dependencies("getLocalesAndCharsets")
+    @XmlElement(name = "language", namespace = LegacyNamespaces.GMD)
     public Collection<Locale> getLanguages() {
-        return languages = nonNullCollection(languages, Locale.class);
+        return FilterByVersion.LEGACY_METADATA.accept() ? LocaleAndCharset.getLanguages(getLocalesAndCharsets()) : null;
     }
 
     /**
-     * Sets the language(s) used within the catalogue
+     * Sets the language(s) used within the catalogue.
      *
      * @param  newValues  the new languages.
+     *
+     * @deprecated Replaced by putting keys in {@link #getLocalesAndCharsets()} map.
      */
+    @Deprecated
     public void setLanguages(final Collection<? extends Locale> newValues) {
-        languages = writeCollection(newValues, languages, Locale.class);
+        // TODO: delete after SIS 1.0 release (method not needed by JAXB).
+        setLocalesAndCharsets(LocaleAndCharset.setLanguages(getLocalesAndCharsets(), newValues));
     }
 
     /**
@@ -339,21 +373,11 @@ public class DefaultFeatureCatalogueDescription extends AbstractContentInformati
     }
 
     /**
-     * Returns the locale to marshal if the XML document is to be written
+     * Returns the locales and character sets to marshal if the XML document is to be written
      * according the new ISO 19115:2014 model.
      */
     @XmlElement(name = "locale")
-    private Collection<Locale> getLocale() {
-        return FilterByVersion.CURRENT_METADATA.accept() ? getLanguages() : null;
-    }
-
-    /**
-     * Returns the locale to marshal if the XML document is to be written
-     * according the legacy ISO 19115:2003 model.
-     */
-    @XmlElement(name = "language", namespace = LegacyNamespaces.GMD)
-    @XmlJavaTypeAdapter(LocaleAdapter.class)
-    private Collection<Locale> getLanguage() {
-        return FilterByVersion.LEGACY_METADATA.accept() ? getLanguages() : null;
+    private Collection<PT_Locale> getLocale() {
+        return FilterByVersion.CURRENT_METADATA.accept() ? PT_Locale.wrap(locales) : null;
     }
 }
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/content/package-info.java b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/content/package-info.java
index c3976f7..8292c8f 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/content/package-info.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/content/package-info.java
@@ -112,10 +112,10 @@
     @XmlJavaTypeAdapter(MI_PolarisationOrientationCode.class),
     @XmlJavaTypeAdapter(MI_RangeElementDescription.class),
     @XmlJavaTypeAdapter(MI_TransferFunctionTypeCode.class),
-    @XmlJavaTypeAdapter(PT_Locale.class),
 
     // Java types, primitive types and basic OGC types handling
     @XmlJavaTypeAdapter(UnitAdapter.class),
+    @XmlJavaTypeAdapter(LocaleAdapter.class),
     @XmlJavaTypeAdapter(InternationalStringAdapter.class),
     @XmlJavaTypeAdapter(value=GO_Boolean.class, type=boolean.class)
 })
@@ -130,7 +130,7 @@ import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
 import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapters;
 import org.apache.sis.xml.Namespaces;
 import org.apache.sis.internal.xml.LegacyNamespaces;
-import org.apache.sis.internal.jaxb.lan.PT_Locale;
+import org.apache.sis.internal.jaxb.lan.LocaleAdapter;
 import org.apache.sis.internal.jaxb.gco.*;
 import org.apache.sis.internal.jaxb.code.*;
 import org.apache.sis.internal.jaxb.metadata.*;
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/identification/DefaultDataIdentification.java b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/identification/DefaultDataIdentification.java
index 2e6edb2..f7bb052 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/identification/DefaultDataIdentification.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/identification/DefaultDataIdentification.java
@@ -16,22 +16,24 @@
  */
 package org.apache.sis.metadata.iso.identification;
 
+import java.util.Map;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Locale;
 import java.nio.charset.Charset;
 import javax.xml.bind.annotation.XmlType;
 import javax.xml.bind.annotation.XmlElement;
 import javax.xml.bind.annotation.XmlRootElement;
-import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
 import org.opengis.util.InternationalString;
 import org.opengis.metadata.citation.Citation;
 import org.opengis.metadata.identification.TopicCategory;
 import org.opengis.metadata.identification.DataIdentification;
-import org.apache.sis.internal.metadata.OtherLocales;
-import org.apache.sis.internal.jaxb.lan.LocaleAdapter;
+import org.apache.sis.internal.jaxb.lan.LocaleAndCharset;
+import org.apache.sis.internal.jaxb.lan.OtherLocales;
+import org.apache.sis.internal.jaxb.lan.PT_Locale;
 import org.apache.sis.internal.xml.LegacyNamespaces;
 import org.apache.sis.internal.jaxb.FilterByVersion;
-import org.apache.sis.internal.util.CollectionsExt;
+import org.apache.sis.internal.metadata.Dependencies;
 
 
 /**
@@ -71,7 +73,7 @@ import org.apache.sis.internal.util.CollectionsExt;
  * @module
  */
 @XmlType(name = "MD_DataIdentification_Type", propOrder = {
-    "language",                 // Legacy ISO 19115:2003
+    "languages",                // Legacy ISO 19115:2003
     "characterSets",            // Legacy ISO 19115:2003
     "defaultLocale",            // New in ISO 19115:2014
     "otherLocales",             // New in ISO 19115:2014
@@ -92,17 +94,12 @@ public class DefaultDataIdentification extends AbstractIdentification implements
     /**
      * Serial number for compatibility with different versions.
      */
-    private static final long serialVersionUID = 6104637930243499851L;
+    private static final long serialVersionUID = 7302901752833238436L;
 
     /**
-     * Language(s) used within the dataset.
+     * Language(s) and character set(s) used within the dataset.
      */
-    private Collection<Locale> languages;
-
-    /**
-     * Full name of the character coding standard used for the dataset.
-     */
-    private Collection<Charset> characterSets;
+    private Map<Locale,Charset> locales;
 
     /**
      * Description of the dataset in the producer’s processing environment, including items
@@ -135,7 +132,9 @@ public class DefaultDataIdentification extends AbstractIdentification implements
                                      final TopicCategory topicCategory)
     {
         super(citation, abstracts);
-        languages = singleton(language, Locale.class);
+        if (language != null) {
+            locales = writeMap(Collections.singletonMap(language, null), null, Locale.class);
+        }
         super.setTopicCategories(singleton(topicCategory, TopicCategory.class));
     }
 
@@ -151,10 +150,9 @@ public class DefaultDataIdentification extends AbstractIdentification implements
     public DefaultDataIdentification(final DataIdentification object) {
         super(object);
         if (object != null) {
-            languages                  = copyCollection(object.getLanguages(), Locale.class);
-            characterSets              = copyCollection(object.getCharacterSets(), Charset.class);
-            environmentDescription     = object.getEnvironmentDescription();
-            supplementalInformation    = object.getSupplementalInformation();
+            locales                 = copyMap(object.getLocalesAndCharsets(), Locale.class);
+            environmentDescription  = object.getEnvironmentDescription();
+            supplementalInformation = object.getSupplementalInformation();
         }
     }
 
@@ -184,6 +182,34 @@ public class DefaultDataIdentification extends AbstractIdentification implements
     }
 
     /**
+     * Returns the language(s) and character set(s) used within the dataset.
+     * The first element in iteration order is the default language.
+     * All other elements, if any, are alternate language(s) used within the resource.
+     *
+     * @return language(s) and character set(s) used within the dataset.
+     *
+     * @since 1.0
+     */
+    @Override
+    // @XmlElement at the end of this class.
+    public Map<Locale,Charset> getLocalesAndCharsets() {
+        return locales = nonNullMap(locales, Locale.class);
+    }
+
+    /**
+     * Sets the language(s) and character set(s) used within the dataset.
+     * The first element in iteration order should be the default language.
+     * All other elements, if any, are alternate language(s) used within the resource.
+     *
+     * @param  newValues  the new language(s) and character set(s) used within the dataset.
+     *
+     * @since 1.0
+     */
+    public void setLocalesAndCharsets(final Map<? extends Locale, ? extends Charset> newValues) {
+        locales = writeMap(newValues, locales, Locale.class);
+    }
+
+    /**
      * Returns the language(s) used within the resource.
      * The first element in iteration order shall be the default language.
      * All other elements, if any, are alternate language(s) used within the resource.
@@ -193,41 +219,55 @@ public class DefaultDataIdentification extends AbstractIdentification implements
      *
      * @return language(s) used.
      *
-     * @see Locale#getISO3Language()
+     * @deprecated Replaced by {@code getLocalesAndCharsets().keySet()}.
      */
     @Override
-    // @XmlElement at the end of this class.
+    @Deprecated
+    @Dependencies("getLocalesAndCharsets")
+    @XmlElement(name = "language", namespace = LegacyNamespaces.GMD)
     public Collection<Locale> getLanguages() {
-        return languages = nonNullCollection(languages, Locale.class);
+        return FilterByVersion.LEGACY_METADATA.accept() ? LocaleAndCharset.getLanguages(getLocalesAndCharsets()) : null;
     }
 
     /**
-     * Sets the language(s) used within the dataset.
+     * Sets the language(s) used within the resource.
      *
      * @param  newValues  the new languages.
+     *
+     * @deprecated Replaced by putting keys in {@link #getLocalesAndCharsets()} map.
      */
-    public void setLanguages(final Collection<? extends Locale> newValues)  {
-        languages = writeCollection(newValues, languages, Locale.class);
+    @Deprecated
+    public void setLanguages(final Collection<? extends Locale> newValues) {
+        // TODO: delete after SIS 1.0 release (method not needed by JAXB).
+        setLocalesAndCharsets(LocaleAndCharset.setLanguages(getLocalesAndCharsets(), newValues));
     }
 
     /**
      * Returns the character coding standard used for the dataset.
      *
      * @return character coding standard(s) used.
+     *
+     * @deprecated Replaced by {@code getLocalesAndCharsets().values()}.
      */
     @Override
+    @Deprecated
+    @Dependencies("getLocalesAndCharsets")
     @XmlElement(name = "characterSet", namespace = LegacyNamespaces.GMD)
     public Collection<Charset> getCharacterSets() {
-        return characterSets = nonNullCollection(characterSets, Charset.class);
+        return FilterByVersion.LEGACY_METADATA.accept() ? LocaleAndCharset.getCharacterSets(getLocalesAndCharsets()) : null;
     }
 
     /**
      * Sets the character coding standard used for the dataset.
      *
      * @param  newValues  the new character sets.
+     *
+     * @deprecated Replaced by putting values in {@link #getLocalesAndCharsets()} map.
      */
+    @Deprecated
     public void setCharacterSets(final Collection<? extends Charset> newValues) {
-        characterSets = writeCollection(newValues, characterSets, Charset.class);
+        // TODO: delete after SIS 1.0 release (method not needed by JAXB).
+        setLocalesAndCharsets(LocaleAndCharset.setCharacterSets(getLocalesAndCharsets(), newValues));
     }
 
     /**
@@ -291,33 +331,23 @@ public class DefaultDataIdentification extends AbstractIdentification implements
      * Gets the default locale for this record (used in ISO 19115-3 format).
      */
     @XmlElement(name = "defaultLocale")
-    private Locale getDefaultLocale() {
-        return FilterByVersion.CURRENT_METADATA.accept() ? CollectionsExt.first(getLanguages()) : null;
+    private PT_Locale getDefaultLocale() {
+        return FilterByVersion.CURRENT_METADATA.accept() ? PT_Locale.first(getLocalesAndCharsets()) : null;
     }
 
     /**
      * Sets the default locale for this record (used in ISO 19115-3 format).
      */
     @SuppressWarnings("unused")
-    private void setDefaultLocale(final Locale newValue) {
-        setLanguages(OtherLocales.setFirst(languages, newValue));
+    private void setDefaultLocale(final PT_Locale newValue) {
+        setLocalesAndCharsets(OtherLocales.setFirst(locales, newValue));
     }
 
     /**
      * Gets the other locales for this record (used in ISO 19115-3 format).
      */
     @XmlElement(name = "otherLocale")
-    private Collection<Locale> getOtherLocales() {
-        return FilterByVersion.CURRENT_METADATA.accept() ? OtherLocales.filter(getLanguages()) : null;
-    }
-
-    /**
-     * Returns the locale to marshal if the XML document is to be written
-     * according the legacy ISO 19115:2003 model.
-     */
-    @XmlElement(name = "language", namespace = LegacyNamespaces.GMD)
-    @XmlJavaTypeAdapter(LocaleAdapter.class)
-    private Collection<Locale> getLanguage() {
-        return FilterByVersion.LEGACY_METADATA.accept() ? getLanguages() : null;
+    private Collection<PT_Locale> getOtherLocales() {
+        return FilterByVersion.CURRENT_METADATA.accept() ? OtherLocales.filter(getLocalesAndCharsets()) : null;
     }
 }
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/identification/package-info.java b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/identification/package-info.java
index 2497232..dfe8ba0 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/identification/package-info.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/identification/package-info.java
@@ -138,7 +138,6 @@
     @XmlJavaTypeAdapter(MD_StandardOrderProcess.class),
     @XmlJavaTypeAdapter(MD_TopicCategoryCode.class),
     @XmlJavaTypeAdapter(MD_Usage.class),
-    @XmlJavaTypeAdapter(PT_Locale.class),
     @XmlJavaTypeAdapter(SV_CoupledResource.class),
     @XmlJavaTypeAdapter(SV_CouplingType.class),
     @XmlJavaTypeAdapter(SV_OperationMetadata.class),
@@ -149,7 +148,8 @@
     // Java types, primitive types and basic OGC types handling
     @XmlJavaTypeAdapter(URIAdapter.class),
     @XmlJavaTypeAdapter(StringAdapter.class),
-    @XmlJavaTypeAdapter(InternationalStringAdapter.class)
+    @XmlJavaTypeAdapter(InternationalStringAdapter.class),
+    @XmlJavaTypeAdapter(LocaleAdapter.class)
 })
 package org.apache.sis.metadata.iso.identification;
 
@@ -162,7 +162,7 @@ import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
 import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapters;
 import org.apache.sis.xml.Namespaces;
 import org.apache.sis.internal.xml.LegacyNamespaces;
-import org.apache.sis.internal.jaxb.lan.PT_Locale;
+import org.apache.sis.internal.jaxb.lan.LocaleAdapter;
 import org.apache.sis.internal.jaxb.gco.*;
 import org.apache.sis.internal.jaxb.gts.*;
 import org.apache.sis.internal.jaxb.code.*;
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/package-info.java b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/package-info.java
index 016fafb..70c9f1e 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/package-info.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/package-info.java
@@ -123,13 +123,13 @@
     @XmlJavaTypeAdapter(MD_ScopeCode.class),
     @XmlJavaTypeAdapter(MD_SpatialRepresentation.class),
     @XmlJavaTypeAdapter(MI_AcquisitionInformation.class),
-    @XmlJavaTypeAdapter(PT_Locale.class),
     @XmlJavaTypeAdapter(RS_ReferenceSystem.class),
     @XmlJavaTypeAdapter(LegacyCharacterSet.class),
 
     // Java types, primitive types and basic OGC types handling
     @XmlJavaTypeAdapter(StringAdapter.class),
-    @XmlJavaTypeAdapter(InternationalStringAdapter.class)
+    @XmlJavaTypeAdapter(InternationalStringAdapter.class),
+    @XmlJavaTypeAdapter(LocaleAdapter.class)
 })
 package org.apache.sis.metadata.iso;
 
@@ -142,7 +142,7 @@ import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
 import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapters;
 import org.apache.sis.xml.Namespaces;
 import org.apache.sis.internal.xml.LegacyNamespaces;
-import org.apache.sis.internal.jaxb.lan.PT_Locale;
+import org.apache.sis.internal.jaxb.lan.LocaleAdapter;
 import org.apache.sis.internal.jaxb.gco.*;
 import org.apache.sis.internal.jaxb.code.*;
 import org.apache.sis.internal.jaxb.metadata.*;
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/internal/jaxb/lan/LanguageCodeTest.java b/core/sis-metadata/src/test/java/org/apache/sis/internal/jaxb/lan/LanguageCodeTest.java
index fac59d6..d55cd18 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/internal/jaxb/lan/LanguageCodeTest.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/internal/jaxb/lan/LanguageCodeTest.java
@@ -167,7 +167,7 @@ public final strictfp class LanguageCodeTest extends TestCase {
         final Unmarshaller unmarshaller = pool.acquireUnmarshaller();
         final String xml = getMetadataXML(LANGUAGE_CODE);
         final Metadata metadata = (Metadata) unmarshal(unmarshaller, xml);
-        assertEquals(Locale.JAPANESE, getSingleton(metadata.getLanguages()));
+        assertEquals(Locale.JAPANESE, getSingleton(metadata.getLocalesAndCharsets().keySet()));
     }
 
     /**
@@ -190,7 +190,7 @@ public final strictfp class LanguageCodeTest extends TestCase {
         final Unmarshaller unmarshaller = pool.acquireUnmarshaller();
         final String xml = getMetadataXML(LANGUAGE_CODE_WITHOUT_ATTRIBUTE);
         final Metadata metadata = (Metadata) unmarshal(unmarshaller, xml);
-        assertEquals(Locale.JAPANESE, getSingleton(metadata.getLanguages()));
+        assertEquals(Locale.JAPANESE, getSingleton(metadata.getLocalesAndCharsets().keySet()));
         pool.recycle(unmarshaller);
     }
 
@@ -232,7 +232,7 @@ public final strictfp class LanguageCodeTest extends TestCase {
         final Unmarshaller unmarshaller = pool.acquireUnmarshaller();
         final String xml = getMetadataXML(CHARACTER_STRING);
         final Metadata metadata = (Metadata) unmarshal(unmarshaller, xml);
-        assertEquals(Locale.JAPANESE, getSingleton(metadata.getLanguages()));
+        assertEquals(Locale.JAPANESE, getSingleton(metadata.getLocalesAndCharsets().keySet()));
         pool.recycle(unmarshaller);
     }
 }
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/internal/jaxb/lan/OtherLocalesTest.java b/core/sis-metadata/src/test/java/org/apache/sis/internal/jaxb/lan/OtherLocalesTest.java
new file mode 100644
index 0000000..4200b9a
--- /dev/null
+++ b/core/sis-metadata/src/test/java/org/apache/sis/internal/jaxb/lan/OtherLocalesTest.java
@@ -0,0 +1,112 @@
+/*
+ * 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.internal.jaxb.lan;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.nio.charset.Charset;
+import org.junit.Test;
+import org.apache.sis.test.TestCase;
+
+import static java.util.Locale.*;
+import static org.junit.Assert.*;
+
+
+/**
+ * Tests the {@link OtherLocales} class.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.0
+ * @since   0.5
+ * @module
+ */
+public final strictfp class OtherLocalesTest extends TestCase {
+    /**
+     * Returns the locales in an array. Character sets are ignored.
+     */
+    private static Locale[] toArray(final Set<PT_Locale> locales) {
+        final Locale[] languages = new Locale[locales.size()];
+        int i = 0;
+        for (final PT_Locale p : locales) {
+            languages[i++] = p.getLocale();
+        }
+        assertEquals(i, languages.length);
+        return languages;
+    }
+
+    /**
+     * Tests {@link OtherLocales#filter(Map)}.
+     */
+    @Test
+    public void testFilter() {
+        final Map<Locale,Charset> languages = new LinkedHashMap<>();
+        assertNull(OtherLocales.filter(languages));
+        /*
+         * The first locale in the 'languages' list is taken as the default locale.
+         * It shall not appears in the 'other locales' collection.
+         */
+        assertNull(languages.put(ENGLISH, null));
+        final Set<PT_Locale> otherLocales = OtherLocales.filter(languages);
+        assertEquals("size", 0, otherLocales.size());
+        /*
+         * All elements after the first one in the 'language' list are "other locales".
+         */
+        assertNull(languages.put(FRENCH, null));
+        assertEquals("size", 1, otherLocales.size());
+        assertArrayEquals(new Locale[] {FRENCH}, toArray(otherLocales));
+        /*
+         * Adding to the "other locales" collection shall delegate to the 'languages' list.
+         */
+        assertTrue(otherLocales.add(new PT_Locale(JAPANESE)));
+        assertEquals("size", 2, otherLocales.size());
+        assertArrayEquals(new Locale[] {FRENCH, JAPANESE}, toArray(otherLocales));
+        assertArrayEquals(new Locale[] {ENGLISH, FRENCH, JAPANESE}, languages.keySet().toArray());
+        /*
+         * Clearing the "other locales" list shall not remove the default locale.
+         */
+        otherLocales.clear();
+        assertEquals("size", 0, otherLocales.size());
+        assertArrayEquals(new Locale[] {ENGLISH}, languages.keySet().toArray());
+        /*
+         * The first 'add' operation on an empty 'languages' list generates a default locale.
+         * Note that we can not test the first element of 'languages', since it is system-dependent.
+         */
+        languages.clear();
+        assertTrue(otherLocales.add(new PT_Locale(FRENCH)));
+        assertArrayEquals(new Locale[] {FRENCH}, toArray(otherLocales));
+        assertEquals("size", 2, languages.size());
+    }
+
+    /**
+     * Tests {@link OtherLocales#setFirst(Map, PT_Locale)}.
+     */
+    @Test
+    public void testSetFirst() {
+        Map<Locale,Charset> merged = OtherLocales.setFirst(null, null);
+        assertNull(merged);
+
+        merged = OtherLocales.setFirst(null, new PT_Locale(ENGLISH));
+        assertArrayEquals(new Locale[] {ENGLISH}, merged.keySet().toArray());
+
+        merged.put(FRENCH, null);
+        merged.put(JAPANESE, null);
+        merged = OtherLocales.setFirst(merged, new PT_Locale(GERMAN));
+        assertArrayEquals(new Locale[] {GERMAN, FRENCH, JAPANESE}, merged.keySet().toArray());
+    }
+}
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/internal/jaxb/lan/PT_LocaleTest.java b/core/sis-metadata/src/test/java/org/apache/sis/internal/jaxb/lan/PT_LocaleTest.java
index f0e3a16..8b6aa6c 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/internal/jaxb/lan/PT_LocaleTest.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/internal/jaxb/lan/PT_LocaleTest.java
@@ -16,8 +16,9 @@
  */
 package org.apache.sis.internal.jaxb.lan;
 
-import java.util.Arrays;
+import java.util.Map;
 import java.util.Locale;
+import java.nio.charset.Charset;
 import javax.xml.bind.JAXBException;
 import org.apache.sis.util.Version;
 import org.apache.sis.metadata.iso.DefaultMetadata;
@@ -64,7 +65,10 @@ public final strictfp class PT_LocaleTest extends TestUsingFile {
             throws JAXBException
     {
         final DefaultMetadata metadata = new DefaultMetadata();
-        metadata.setLanguages(Arrays.asList(locales));
+        final Map<Locale,Charset> lc = metadata.getLocalesAndCharsets();
+        for (final Locale locale : locales) {
+            lc.put(locale, null);
+        }
         assertMarshalEqualsFile(filename, metadata, version, STRICT, ignoredNodes,
                 new String[] {"xmlns:*", "xsi:*"});
     }
@@ -99,7 +103,7 @@ public final strictfp class PT_LocaleTest extends TestUsingFile {
     @Test
     public void testUnmarshalling() throws JAXBException {
         final DefaultMetadata metadata = unmarshalFile(DefaultMetadata.class, XML2016+FILENAME);
-        assertArrayEquals(locales, metadata.getLanguages().toArray());
+        assertArrayEquals(locales, metadata.getLocalesAndCharsets().keySet().toArray());
     }
 
     /**
@@ -110,6 +114,6 @@ public final strictfp class PT_LocaleTest extends TestUsingFile {
     @Test
     public void testUnmarshallingLegacy() throws JAXBException {
         final DefaultMetadata metadata = unmarshalFile(DefaultMetadata.class, XML2007+FILENAME);
-        assertArrayEquals(locales, metadata.getLanguages().toArray());
+        assertArrayEquals(locales, metadata.getLocalesAndCharsets().keySet().toArray());
     }
 }
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/internal/metadata/MergerTest.java b/core/sis-metadata/src/test/java/org/apache/sis/internal/metadata/MergerTest.java
index 6d38226..e3ef4bd 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/internal/metadata/MergerTest.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/internal/metadata/MergerTest.java
@@ -43,7 +43,7 @@ import static org.apache.sis.test.Assert.*;
  * Tests the {@link Merger} class.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
+ * @version 1.0
  * @since   0.8
  * @module
  */
@@ -67,8 +67,7 @@ public final strictfp class MergerTest extends TestCase {
         image.setCloudCoverPercentage(0.8);
         metadata.getContentInfo().add(image);
 
-        metadata.getLanguages().add(Locale.JAPANESE);
-        metadata.getCharacterSets().add(StandardCharsets.UTF_16);
+        metadata.setLocalesAndCharsets(Collections.singletonMap(Locale.JAPANESE, StandardCharsets.UTF_16));
         return metadata;
     }
 
@@ -87,7 +86,7 @@ public final strictfp class MergerTest extends TestCase {
         features.setIncludedWithDataset(Boolean.TRUE);
         metadata.getContentInfo().add(features);
 
-        metadata.getLanguages().add(Locale.FRENCH);
+        metadata.setLocalesAndCharsets(Collections.singletonMap(Locale.FRENCH, StandardCharsets.UTF_8));
         return metadata;
     }
 
@@ -102,8 +101,10 @@ public final strictfp class MergerTest extends TestCase {
         final Merger merger = new Merger(null);
         merger.copy(source, target);
 
-        assertSetEquals(Arrays.asList(Locale.JAPANESE, Locale.FRENCH),  target.getLanguages());
-        assertSetEquals(Collections.singleton(StandardCharsets.UTF_16), target.getCharacterSets());
+        assertSetEquals(Arrays.asList(Locale.JAPANESE, Locale.FRENCH),
+                        target.getLocalesAndCharsets().keySet());
+        assertSetEquals(Arrays.asList(StandardCharsets.UTF_16, StandardCharsets.UTF_8),
+                        target.getLocalesAndCharsets().values());
 
         final Iterator<ContentInformation> it       = target.getContentInfo().iterator();
         final ImageDescription             image    = (ImageDescription)            it.next();
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/internal/metadata/MetadataUtilitiesTest.java b/core/sis-metadata/src/test/java/org/apache/sis/internal/metadata/MetadataUtilitiesTest.java
index a1271c1..593cf7b 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/internal/metadata/MetadataUtilitiesTest.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/internal/metadata/MetadataUtilitiesTest.java
@@ -17,17 +17,22 @@
 package org.apache.sis.internal.metadata;
 
 import java.util.Date;
-import org.junit.Test;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.Locale;
 import org.apache.sis.test.TestCase;
+import org.junit.Test;
 
 import static org.junit.Assert.*;
+import static java.util.Locale.*;
 
 
 /**
  * Tests the {@link MetadataUtilities} class.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.4
+ * @version 1.0
  * @since   0.3
  * @module
  */
@@ -49,4 +54,27 @@ public final strictfp class MetadataUtilitiesTest extends TestCase {
         assertEquals(new Date(1000), MetadataUtilities.toDate(1000));
         assertNull(MetadataUtilities.toDate(Long.MIN_VALUE));
     }
+
+    /**
+     * Tests the {@link MetadataUtilities#setFirst(Collection, Object)} method.
+     */
+    @Test
+    public void testSetFirst() {
+        Collection<Locale> locales = MetadataUtilities.setFirst(null, null);
+        assertTrue(locales.isEmpty());
+
+        locales = MetadataUtilities.setFirst(null, GERMAN);
+        assertArrayEquals(new Locale[] {GERMAN}, locales.toArray());
+
+        locales = Arrays.asList(ENGLISH, JAPANESE, FRENCH);
+        assertSame("Shall set value in-place.", locales, MetadataUtilities.setFirst(locales, GERMAN));
+        assertArrayEquals(new Locale[] {GERMAN, JAPANESE, FRENCH}, locales.toArray());
+
+        locales = new LinkedHashSet<>(Arrays.asList(ENGLISH, JAPANESE, FRENCH));
+        locales = MetadataUtilities.setFirst(locales, ITALIAN);
+        assertArrayEquals(new Locale[] {ITALIAN, JAPANESE, FRENCH}, locales.toArray());
+
+        locales = MetadataUtilities.setFirst(locales, null);
+        assertArrayEquals(new Locale[] {JAPANESE, FRENCH}, locales.toArray());
+    }
 }
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/internal/metadata/OtherLocalesTest.java b/core/sis-metadata/src/test/java/org/apache/sis/internal/metadata/OtherLocalesTest.java
deleted file mode 100644
index 0921aa8..0000000
--- a/core/sis-metadata/src/test/java/org/apache/sis/internal/metadata/OtherLocalesTest.java
+++ /dev/null
@@ -1,129 +0,0 @@
-/*
- * 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.internal.metadata;
-
-import java.util.Locale;
-import java.util.Arrays;
-import java.util.Iterator;
-import java.util.Collection;
-import java.util.LinkedHashSet;
-import org.junit.Test;
-import org.apache.sis.test.TestCase;
-
-import static java.util.Locale.*;
-import static org.junit.Assert.*;
-
-
-/**
- * Tests the {@link OtherLocales} class.
- *
- * @author  Martin Desruisseaux (Geomatys)
- * @version 0.5
- * @since   0.5
- * @module
- */
-public final strictfp class OtherLocalesTest extends TestCase {
-    /**
-     * Tests {@link OtherLocales#filter(Collection)}.
-     */
-    @Test
-    public void testFilter() {
-        final Collection<Locale> languages = new LinkedHashSet<>();
-        final Collection<Locale> otherLocales = OtherLocales.filter(languages);
-        assertEquals("size", 0, otherLocales.size());
-        /*
-         * The first locale in the 'languages' list is taken as the default locale.
-         * It shall not appears in the 'other locales' collection.
-         */
-        assertTrue(languages.add(ENGLISH));
-        assertEquals("size", 0, otherLocales.size());
-        /*
-         * All elements after the first one in the 'language' list are "other locales".
-         */
-        assertTrue(languages.add(FRENCH));
-        assertEquals("size", 1, otherLocales.size());
-        assertArrayEquals(new Locale[] {FRENCH}, otherLocales.toArray());
-        /*
-         * Adding to the "other locales" collection shall delegate to the 'languages' list.
-         */
-        assertTrue(otherLocales.add(JAPANESE));
-        assertEquals("size", 2, otherLocales.size());
-        assertArrayEquals(new Locale[] {FRENCH, JAPANESE}, otherLocales.toArray());
-        assertArrayEquals(new Locale[] {ENGLISH, FRENCH, JAPANESE}, languages.toArray());
-        /*
-         * Clearing the "other locales" list shall not remove the default locale.
-         */
-        otherLocales.clear();
-        assertEquals("size", 0, otherLocales.size());
-        assertArrayEquals(new Locale[] {ENGLISH}, languages.toArray());
-        /*
-         * The first 'add' operation on an empty 'languages' list generates a default locale.
-         * Note that we can not test the first element of 'languages', since it is system-dependent.
-         */
-        languages.clear();
-        assertTrue(otherLocales.add(FRENCH));
-        assertArrayEquals(new Locale[] {FRENCH}, otherLocales.toArray());
-        assertEquals("size", 2, languages.size());
-    }
-
-    /**
-     * Tests {@link OtherLocales#merge(Locale, Collection)}.
-     */
-    @Test
-    public void testMerge() {
-        Collection<Locale> merged = OtherLocales.merge(null, null);
-        assertTrue(merged.isEmpty());
-
-        merged = OtherLocales.merge(ENGLISH, null);
-        assertArrayEquals(new Locale[] {ENGLISH}, merged.toArray());
-
-        merged = OtherLocales.merge(ENGLISH, Arrays.asList(FRENCH, JAPANESE));
-        assertArrayEquals(new Locale[] {ENGLISH, FRENCH, JAPANESE}, merged.toArray());
-        /*
-         * The tricky case: a default locale will be generated. That locale is system-dependent.
-         */
-        merged = OtherLocales.merge(null, Arrays.asList(FRENCH, JAPANESE));
-        final Iterator<Locale> it = merged.iterator();
-        assertNotNull(it.next()); // System-dependent value.
-        assertEquals(FRENCH,   it.next());
-        assertEquals(JAPANESE, it.next());
-        assertFalse(it.hasNext());
-    }
-
-    /**
-     * Tests the {@link OtherLocales#setFirst(Collection, Object)} method.
-     */
-    @Test
-    public void testSetFirst() {
-        Collection<Locale> locales = OtherLocales.setFirst(null, null);
-        assertTrue(locales.isEmpty());
-
-        locales = OtherLocales.setFirst(null, GERMAN);
-        assertArrayEquals(new Locale[] {GERMAN}, locales.toArray());
-
-        locales = Arrays.asList(ENGLISH, JAPANESE, FRENCH);
-        assertSame("Shall set value in-place.", locales, OtherLocales.setFirst(locales, GERMAN));
-        assertArrayEquals(new Locale[] {GERMAN, JAPANESE, FRENCH}, locales.toArray());
-
-        locales = new LinkedHashSet<>(Arrays.asList(ENGLISH, JAPANESE, FRENCH));
-        locales = OtherLocales.setFirst(locales, ITALIAN);
-        assertArrayEquals(new Locale[] {ITALIAN, JAPANESE, FRENCH}, locales.toArray());
-
-        locales = OtherLocales.setFirst(locales, null);
-        assertArrayEquals(new Locale[] {JAPANESE, FRENCH}, locales.toArray());
-    }
-}
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/metadata/PropertyAccessorTest.java b/core/sis-metadata/src/test/java/org/apache/sis/metadata/PropertyAccessorTest.java
index 140c7e2..ec01d42 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/metadata/PropertyAccessorTest.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/metadata/PropertyAccessorTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.metadata;
 
+import java.util.Map;
 import java.util.Set;
 import java.util.List;
 import java.util.Arrays;
@@ -23,7 +24,6 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Locale;
 import java.util.Date;
-import java.nio.charset.Charset;
 
 import org.opengis.metadata.Identifier;
 import org.opengis.metadata.extent.Extent;
@@ -148,6 +148,8 @@ public final strictfp class PropertyAccessorTest extends TestCase {
                         propertyType = Set.class;
                     }
                 }
+            } else if (propertyType == Map.class) {
+                elementType = Map.Entry.class;
             }
             assertEquals(propertyName,  propertyType, accessor.type(index, TypeValuePolicy.PROPERTY_TYPE));
             assertEquals(umlIdentifier, elementType,  accessor.type(index, TypeValuePolicy.ELEMENT_TYPE));
@@ -221,10 +223,9 @@ public final strictfp class PropertyAccessorTest extends TestCase {
             Identification.class, "getResourceSpecificUsages",     "resourceSpecificUsages",     "resourceSpecificUsage",     "Resource specific usages",     Usage[].class,
             Identification.class, "getResourceConstraints",        "resourceConstraints",        "resourceConstraints",       "Resource constraints",         Constraints[].class,
             Identification.class, "getAssociatedResources",        "associatedResources",        "associatedResource",        "Associated resources",         AssociatedResource[].class,
-        DataIdentification.class, "getLanguages",                  "languages",                  "language",                  "Languages",                    Locale[].class,
-        DataIdentification.class, "getCharacterSets",              "characterSets",              "characterSet",              "Character sets",               Charset[].class,
         DataIdentification.class, "getEnvironmentDescription",     "environmentDescription",     "environmentDescription",    "Environment description",      InternationalString.class,
-        DataIdentification.class, "getSupplementalInformation",    "supplementalInformation",    "supplementalInformation",   "Supplemental information",     InternationalString.class);
+        DataIdentification.class, "getSupplementalInformation",    "supplementalInformation",    "supplementalInformation",   "Supplemental information",     InternationalString.class,
+        DataIdentification.class, "getLocalesAndCharsets",         "localesAndCharsets",         "defaultLocale+otherLocale", "Locales and charsets",         Map.class);
     }
 
     /**
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/metadata/PropertyConsistencyCheck.java b/core/sis-metadata/src/test/java/org/apache/sis/metadata/PropertyConsistencyCheck.java
index 4c3b9d3..4f73282 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/metadata/PropertyConsistencyCheck.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/metadata/PropertyConsistencyCheck.java
@@ -242,25 +242,29 @@ public abstract strictfp class PropertyConsistencyCheck extends AnnotationConsis
             final Class<?>  elementType = Numbers.primitiveToWrapper(accessor.type(i, TypeValuePolicy.ELEMENT_TYPE));
             assertNotNull(testingMethod, propertyType);
             assertNotNull(testingMethod, elementType);
+            final boolean isMap        =        Map.class.isAssignableFrom(propertyType);
             final boolean isCollection = Collection.class.isAssignableFrom(propertyType);
             assertFalse("Element type can not be Collection.", Collection.class.isAssignableFrom(elementType));
             assertEquals("Property and element types shall be the same if and only if not a collection.",
-                         !isCollection, propertyType == elementType);
+                         !(isMap | isCollection), propertyType == elementType);
             /*
              * Try to get a value.
              */
             Object value = accessor.get(i, instance);
             if (value == null) {
-                assertFalse("Null values are not allowed to be collections.", isCollection);
+                assertFalse("Null values are not allowed to be collections.", isMap | isCollection);
             } else {
                 assertTrue("Wrong property type.", propertyType.isInstance(value));
                 if (value instanceof CheckedContainer<?>) {
                     assertTrue("Wrong element type in collection.",
                             elementType.isAssignableFrom(((CheckedContainer<?>) value).getElementType()));
                 }
-                if (isCollection) {
+                if (isMap) {
+                    assertTrue("Collections shall be initially empty.", ((Map<?,?>) value).isEmpty());
+                    value = CollectionsExt.modifiableCopy((Map<?,?>) value);                          // Protect from changes.
+                } else if (isCollection) {
                     assertTrue("Collections shall be initially empty.", ((Collection<?>) value).isEmpty());
-                    value = CollectionsExt.modifiableCopy((Collection<?>) value); // Protect from changes.
+                    value = CollectionsExt.modifiableCopy((Collection<?>) value);                     // Protect from changes.
                 }
             }
             /*
@@ -275,13 +279,16 @@ public abstract strictfp class PropertyConsistencyCheck extends AnnotationConsis
                     // Dates requires sis-temporal module, which is not available for sis-metadata.
                     continue;
                 }
+                if (isMap) {
+                    continue;
+                }
                 final Object newValue = sampleValueFor(property, elementType);
                 final Object oldValue = accessor.set(i, instance, newValue, PropertyAccessor.RETURN_PREVIOUS);
                 assertEquals("PropertyAccessor.set(…) shall return the value previously returned by get(…).", value, oldValue);
                 value = accessor.get(i, instance);
                 if (isCollection) {
                     if (newValue == null) {
-                        assertTrue("We didn't generated a random value for this type, consequently the "
+                        assertTrue("We did not generated a random value for this type, consequently the "
                                 + "collection should still empty.", ((Collection<?>) value).isEmpty());
                         value = null;
                     } else {
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/metadata/iso/CustomMetadataTest.java b/core/sis-metadata/src/test/java/org/apache/sis/metadata/iso/CustomMetadataTest.java
index 688339b..5dc44ac 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/metadata/iso/CustomMetadataTest.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/metadata/iso/CustomMetadataTest.java
@@ -136,6 +136,7 @@ public final strictfp class CustomMetadataTest extends TestCase {
 @Deprecated @Override public Collection<AggregateInformation>      getAggregationInfo()            {return null;}
             @Override public Collection<AssociatedResource>        getAssociatedResources()        {return null;}
             @Override public Collection<Citation>                  getAdditionalDocumentations()   {return null;}
+            @Override public Map<Locale,Charset>                   getLocalesAndCharsets()         {return null;}
         };
         final DefaultMetadata data = new DefaultMetadata();
         data.setIdentificationInfo(singleton(identification));
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/metadata/iso/DefaultMetadataTest.java b/core/sis-metadata/src/test/java/org/apache/sis/metadata/iso/DefaultMetadataTest.java
index ea18a01..8fd3d7f 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/metadata/iso/DefaultMetadataTest.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/metadata/iso/DefaultMetadataTest.java
@@ -53,7 +53,7 @@ import static org.apache.sis.test.TestUtilities.getSingleton;
  * @since   0.3
  * @module
  */
-@DependsOn(org.apache.sis.internal.metadata.OtherLocalesTest.class)
+@DependsOn(org.apache.sis.internal.jaxb.lan.OtherLocalesTest.class)
 public final strictfp class DefaultMetadataTest extends TestCase {
     /**
      * A flag for tracing workarounds for allowing some tests to pass despite regression.
@@ -102,9 +102,9 @@ public final strictfp class DefaultMetadataTest extends TestCase {
     }
 
     /**
-     * Tests {@link DefaultMetadata#getLanguage()}, {@link DefaultMetadata#setLanguage(Locale)},
-     * {@link DefaultMetadata#getLocales()} and {@link DefaultMetadata#setLocales(Collection)}
-     * legacy methods. Those methods should delegate to newer methods.
+     * Tests {@link DefaultMetadata#getLanguage()}, {@link DefaultMetadata#setLanguage(Locale)}
+     * and {@link DefaultMetadata#getLocales()} legacy methods.
+     * Those methods should delegate to newer methods.
      *
      * @since 0.5
      */
@@ -123,7 +123,7 @@ public final strictfp class DefaultMetadataTest extends TestCase {
          * Add other languages. They should appear as additional entries after the first one.
          * The "language" property shall be unmodified by changes in the "other locales" one.
          */
-        metadata.setLocales(Arrays.asList(Locale.FRENCH, Locale.ENGLISH));
+        metadata.getLocales().addAll(Arrays.asList(Locale.FRENCH, Locale.ENGLISH));
         assertLanguagesEquals(metadata, Locale.JAPANESE, Locale.FRENCH, Locale.ENGLISH);
         /*
          * Ensure that the "locales" list is modifiable, since JAXB writes directly in it.
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/metadata/iso/identification/DefaultDataIdentificationTest.java b/core/sis-metadata/src/test/java/org/apache/sis/metadata/iso/identification/DefaultDataIdentificationTest.java
index 2a2d621..ed5ab1f 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/metadata/iso/identification/DefaultDataIdentificationTest.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/metadata/iso/identification/DefaultDataIdentificationTest.java
@@ -34,7 +34,6 @@ import org.apache.sis.test.TestCase;
 import org.apache.sis.test.TestUtilities;
 import org.junit.Test;
 
-import static java.util.Arrays.asList;
 import static java.util.Collections.singleton;
 import static org.apache.sis.test.MetadataAssert.*;
 
@@ -56,11 +55,6 @@ import static org.apache.sis.test.MetadataAssert.*;
 })
 public final strictfp class DefaultDataIdentificationTest extends TestCase {
     /**
-     * The locales used in this test.
-     */
-    private static final Locale[] LOCALES = {Locale.US, Locale.ENGLISH};
-
-    /**
      * Creates the instance to test.
      */
     private static DefaultDataIdentification create() {
@@ -90,7 +84,7 @@ public final strictfp class DefaultDataIdentificationTest extends TestCase {
         /*
          * Identification info
          *  ├─(above objects)
-         *  ├─Abstract………………………………………………………………………………… NCEP SST Global 5.0 x 2.5 degree model data
+         *  ├─Abstract………………………………………………………………………………… Global 5.0 x 2.5 degree model data
          *  ├─Descriptive keywords
          *  │   ├─Keyword………………………………………………………………………… EARTH SCIENCE > Oceans > Ocean Temperature > Sea Surface Temperature
          *  │   ├─Type………………………………………………………………………………… Theme
@@ -99,9 +93,10 @@ public final strictfp class DefaultDataIdentificationTest extends TestCase {
          *  ├─Resource constraints
          *  │   └─Use limitation……………………………………………………… Freely available
          *  ├─Spatial representation type……………………………… Grid
-         *  ├─Language (1 of 2)………………………………………………………… en_US
-         *  ├─Language (2 of 2)………………………………………………………… en
-         *  ├─Character set…………………………………………………………………… US-ASCII
+         *  ├─Locale (1 of 2)……………………………………………………………… en_US
+         *  │   └─Character set………………………………………………………… US-ASCII
+         *  ├─Locale (2 of 2)……………………………………………………………… fr
+         *  │   └─Character set………………………………………………………… ISO-8859-1
          *  └─Extent
          *      └─Geographic element
          *          ├─West bound longitude…………………………… 180°W
@@ -111,13 +106,13 @@ public final strictfp class DefaultDataIdentificationTest extends TestCase {
          *          └─Extent type code……………………………………… true
          */
         final DefaultDataIdentification info = new DefaultDataIdentification(citation,
-                "NCEP SST Global 5.0 x 2.5 degree model data", null, null);
+                "Global 5.0 x 2.5 degree model data", null, null);
         info.setSpatialRepresentationTypes(singleton(SpatialRepresentationType.GRID));
         info.setDescriptiveKeywords(singleton(keywords));
         info.setResourceConstraints(singleton(new DefaultConstraints("Freely available")));
+        info.getLocalesAndCharsets().put(Locale.US,     StandardCharsets.US_ASCII);
+        info.getLocalesAndCharsets().put(Locale.FRENCH, StandardCharsets.ISO_8859_1);
         info.setExtents(singleton(Extents.WORLD));
-        info.setLanguages(asList(LOCALES));
-        info.setCharacterSets(singleton(StandardCharsets.US_ASCII));
         return info;
     }
 
@@ -139,7 +134,7 @@ public final strictfp class DefaultDataIdentificationTest extends TestCase {
                 "  │   ├─Date………………………………………………………… 2005-09-22 00:00:00\n" +
                 "  │   │   └─Date type………………………………… Creation\n" +
                 "  │   └─Identifier………………………………………… SST_Global.nc\n" +
-                "  ├─Abstract………………………………………………………… NCEP SST Global 5.0 x 2.5 degree model data\n" +
+                "  ├─Abstract………………………………………………………… Global 5.0 x 2.5 degree model data\n" +
                 "  ├─Spatial representation type……… Grid\n" +
                 "  ├─Extent……………………………………………………………… World\n" +
                 "  │   └─Geographic element\n" +
@@ -154,9 +149,10 @@ public final strictfp class DefaultDataIdentificationTest extends TestCase {
                 "  │   └─Thesaurus name……………………………… GCMD Science Keywords\n" +
                 "  ├─Resource constraints\n" +
                 "  │   └─Use limitation……………………………… Freely available\n" +
-                "  ├─Language (1 of 2)………………………………… en_US\n" +
-                "  ├─Language (2 of 2)………………………………… en\n" +
-                "  └─Character set…………………………………………… US-ASCII\n",
+                "  ├─Locale (1 of 2)……………………………………… en_US\n" +
+                "  │   └─Character set………………………………… US-ASCII\n" +
+                "  └─Locale (2 of 2)……………………………………… fr\n" +
+                "      └─Character set………………………………… ISO-8859-1\n",
             TestUtilities.formatMetadata(create().asTreeTable()));
     }
 
@@ -168,11 +164,13 @@ public final strictfp class DefaultDataIdentificationTest extends TestCase {
     public void testValueMap() {
         final DefaultDataIdentification info = create();
         final Map<String,Object> map = info.asMap();
-        assertEquals("abstract", "NCEP SST Global 5.0 x 2.5 degree model data", map.get("abstract").toString());
+        assertEquals("abstract", "Global 5.0 x 2.5 degree model data", map.get("abstract").toString());
         assertTitleEquals("title", "Sea Surface Temperature Analysis Model", (Citation) map.get("citation"));
         assertEquals("spatialRepresentationType", singleton(SpatialRepresentationType.GRID), map.get("spatialRepresentationType"));
-        assertArrayEquals("language",     LOCALES, ((Collection<?>) map.get("language")).toArray());
-        assertArrayEquals("languages",    LOCALES, ((Collection<?>) map.get("languages")).toArray());
-        assertArrayEquals("getLanguages", LOCALES, ((Collection<?>) map.get("getLanguages")).toArray());
+
+        final Locale[] locales = {Locale.US, Locale.FRENCH};
+        assertArrayEquals("language",     locales, ((Collection<?>) map.get("language")).toArray());
+        assertArrayEquals("languages",    locales, ((Collection<?>) map.get("languages")).toArray());
+        assertArrayEquals("getLanguages", locales, ((Collection<?>) map.get("getLanguages")).toArray());
     }
 }
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/test/mock/MetadataMock.java b/core/sis-metadata/src/test/java/org/apache/sis/test/mock/MetadataMock.java
index 5479bc2..634f1e2 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/test/mock/MetadataMock.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/test/mock/MetadataMock.java
@@ -16,9 +16,11 @@
  */
 package org.apache.sis.test.mock;
 
-import java.util.Locale;
+import java.util.Map;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Locale;
+import java.nio.charset.Charset;
 import javax.xml.bind.annotation.XmlElement;
 import javax.xml.bind.annotation.XmlRootElement;
 import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
@@ -67,11 +69,22 @@ public final strictfp class MetadataMock extends SimpleMetadata {
     }
 
     /**
+     * Returns {@link #language} in a singleton map or an empty map.
+     *
+     * @return {@link #language}
+     */
+    @Override
+    public Map<Locale,Charset> getLocalesAndCharsets() {
+        return (language != null) ? Collections.singletonMap(language, null) : Collections.emptyMap();
+    }
+
+    /**
      * Returns {@link #language} in a singleton set or an empty set.
      *
      * @return {@link #language}
      */
     @Override
+    @Deprecated
     public Collection<Locale> getLanguages() {
         return (language != null) ? Collections.singleton(language) : Collections.emptySet();
     }
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/test/suite/MetadataTestSuite.java b/core/sis-metadata/src/test/java/org/apache/sis/test/suite/MetadataTestSuite.java
index 90cde37..cc15b61 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/test/suite/MetadataTestSuite.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/test/suite/MetadataTestSuite.java
@@ -35,7 +35,6 @@ import org.junit.BeforeClass;
     org.apache.sis.internal.metadata.NameMeaningTest.class,
     org.apache.sis.internal.metadata.MetadataUtilitiesTest.class,
     org.apache.sis.internal.metadata.VerticalDatumTypesTest.class,
-    org.apache.sis.internal.metadata.OtherLocalesTest.class,
 
     // Classes using Java reflection.
     org.apache.sis.metadata.PropertyInformationTest.class,
@@ -75,6 +74,7 @@ import org.junit.BeforeClass;
     org.apache.sis.internal.jaxb.gco.PropertyTypeTest.class,
     org.apache.sis.internal.jaxb.gco.MultiplicityTest.class,
     org.apache.sis.internal.jaxb.lan.PT_LocaleTest.class,
+    org.apache.sis.internal.jaxb.lan.OtherLocalesTest.class,
     org.apache.sis.internal.jaxb.lan.LanguageCodeTest.class,
     org.apache.sis.internal.jaxb.lan.FreeTextMarshallingTest.class,
     org.apache.sis.internal.jaxb.cat.EnumAdapterTest.class,
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/test/xml/AnnotationConsistencyCheck.java b/core/sis-metadata/src/test/java/org/apache/sis/test/xml/AnnotationConsistencyCheck.java
index 06f4e56..33919ec 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/test/xml/AnnotationConsistencyCheck.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/test/xml/AnnotationConsistencyCheck.java
@@ -209,6 +209,17 @@ public abstract strictfp class AnnotationConsistencyCheck extends TestCase {
     }
 
     /**
+     * Returns the identifier specified by the given UML, taking only the first one if it compound.
+     * For example if the identifier is {@code "defaultLocale+otherLocale"}, then this method returns
+     * only {@code "defaultLocale"}.
+     */
+    private static String firstIdentifier(final UML uml) {
+        String identifier = uml.identifier();
+        final int s = identifier.indexOf('+');
+        return (s >= 0) ? identifier.substring(0, s) : identifier;
+    }
+
+    /**
      * Returns the beginning of expected namespace for an element defined by the given UML.
      * For example the namespace of most types defined by {@link Specification#ISO_19115}
      * starts with is {@code "http://standards.iso.org/iso/19115/-3/"}.
@@ -393,7 +404,7 @@ public abstract strictfp class AnnotationConsistencyCheck extends TestCase {
      * @see #testMethodAnnotations()
      */
     protected String getExpectedXmlElementName(final Class<?> enclosing, final UML uml) {
-        String name = uml.identifier();
+        String name = firstIdentifier(uml);
         switch (name) {
             case "stepDateTime": {
                 if (org.opengis.metadata.lineage.ProcessStep.class.isAssignableFrom(enclosing)) {
@@ -768,7 +779,7 @@ public abstract strictfp class AnnotationConsistencyCheck extends TestCase {
                      * for example verifying whether we are marshalling ISO 19139:2007 or ISO 19115-3:2016.
                      */
                     boolean wasPublic = false;
-                    final String identifier = uml.identifier();
+                    final String identifier = firstIdentifier(uml);
                     for (final Method pm : impl.getDeclaredMethods()) {
                         final XmlElement e = pm.getAnnotation(XmlElement.class);
                         if (e != null && identifier.equals(e.name())) {
@@ -784,7 +795,7 @@ public abstract strictfp class AnnotationConsistencyCheck extends TestCase {
                         }
                     }
                     /*
-                     * If a few case the annotation is not on a getter method, but directly on the field.
+                     * In a few case the annotation is not on a getter method, but directly on the field.
                      * The main case is the "pass" field in DefaultConformanceResult.
                      */
                     if (element == null) try {
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/test/integration/MetadataTest.java b/core/sis-referencing/src/test/java/org/apache/sis/test/integration/MetadataTest.java
index 1384074..0e7c530 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/test/integration/MetadataTest.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/test/integration/MetadataTest.java
@@ -152,8 +152,7 @@ public strictfp class MetadataTest extends TestCase {
     private DefaultMetadata createHardCoded() {
         final DefaultMetadata metadata = new DefaultMetadata();
         metadata.setMetadataIdentifier(new DefaultIdentifier("Apache SIS/Metadata test"));
-        metadata.setLanguages(singleton(Locale.ENGLISH));
-        metadata.setCharacterSets(singleton(StandardCharsets.UTF_8));
+        metadata.setLocalesAndCharsets(singletonMap(Locale.ENGLISH, StandardCharsets.UTF_8));
         metadata.setMetadataScopes(singleton(new DefaultMetadataScope(ScopeCode.DATASET, "Common Data Index record")));
         metadata.setDateInfo(singleton(new DefaultCitationDate(TestUtilities.date("2009-01-01 04:00:00"), DateType.CREATION)));
         /*
@@ -413,7 +412,7 @@ public strictfp class MetadataTest extends TestCase {
         /*
          * The <gmd:EX_TemporalExtent> block can not be marshalled yet, since it requires the sis-temporal module.
          * We need to instruct the XML comparator to ignore this block during the comparison. We also ignore for
-         * now the "gml:id" attribute since SIS generates different values than the ones in oyr test XML file,
+         * now the "gml:id" attribute since SIS generates different values than the ones in our test XML file,
          * and those values may change in future SIS version.
          */
         final DocumentComparator comparator = new DocumentComparator(getResource(), xml.toString());
@@ -472,11 +471,11 @@ public strictfp class MetadataTest extends TestCase {
         final Metadata metadata = unmarshalFile(Metadata.class, VERTICAL_CRS_XML);
         if (REGRESSION) {
             assertTrue("Maybe SIS-402 has been fixed and this anti-regression hack can be removed?",
-                       metadata.getCharacterSets().add(StandardCharsets.UTF_8));
+                       ((DefaultMetadata) metadata).getCharacterSets().add(StandardCharsets.UTF_8));
         }
         assertEquals("fileIdentifier", "20090901",                     metadata.getMetadataIdentifier().getCode());
-        assertEquals("language",       Locale.ENGLISH,                 getSingleton(metadata.getLanguages()));
-        assertEquals("characterSet",   StandardCharsets.UTF_8,         getSingleton(metadata.getCharacterSets()));
+        assertEquals("language",       Locale.ENGLISH,                 getSingleton(metadata.getLocalesAndCharsets().keySet()));
+        assertEquals("characterSet",   StandardCharsets.UTF_8,         getSingleton(metadata.getLocalesAndCharsets().values()));
         assertEquals("dateStamp",      xmlDate("2014-01-04 00:00:00"), getSingleton(metadata.getDateInfo()).getDate());
         /*
          * <gmd:contact>
diff --git a/core/sis-utility/src/main/java/org/apache/sis/internal/util/CollectionsExt.java b/core/sis-utility/src/main/java/org/apache/sis/internal/util/CollectionsExt.java
index b469435..53f2ed3 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/internal/util/CollectionsExt.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/internal/util/CollectionsExt.java
@@ -102,6 +102,27 @@ public final class CollectionsExt extends Static {
     }
 
     /**
+     * Returns the number of elements if the given object is a collection or a map.
+     * Otherwise returns 0 if the given object if null or 1 otherwise.
+     *
+     * @param  c  the collection or map for which to get the size, or {@code null}.
+     * @return the size or pseudo-size of the given object.
+     *
+     * @since 1.0
+     */
+    public static int size(final Object c) {
+        if (c == null) {
+            return 0;
+        } else if (c instanceof Collection<?>) {
+            return ((Collection<?>) c).size();
+        } else if (c instanceof Map<?,?>) {
+            return ((Map<?,?>) c).size();
+        } else {
+            return 1;
+        }
+    }
+
+    /**
      * Returns the first element of the given iterable, or {@code null} if none.
      * This method does not emit warning if more than one element is found.
      * Consequently, this method should be used only when multi-occurrence is not ambiguous.
diff --git a/core/sis-utility/src/main/java/org/apache/sis/internal/util/TreeFormatCustomization.java b/core/sis-utility/src/main/java/org/apache/sis/internal/util/TreeFormatCustomization.java
index 966b998..f270220 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/internal/util/TreeFormatCustomization.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/internal/util/TreeFormatCustomization.java
@@ -26,6 +26,14 @@ import org.apache.sis.util.collection.TreeTableFormat;
  * are invoked by {@link TreeTableFormat#format(TreeTable, Appendable)} before to format the tree.
  * Non-null return values are merged with the {@code TreeTableFormat} configuration.
  *
+ * <div class="note"><b>Design note:</b>
+ * methods in this class are invoked for configuring the formatter before to write the tree.
+ * We do not use this interface as callbacks invoked for individual rows during formatting.
+ * The reason is that functions provided by this interface may need to manage a state
+ * (for example {@linkplain #filter() filtering} may depend on previous rows) but we do not want
+ * to force implementations to store such state in {@code TreeFormatCustomization} instances
+ * since objects implementing this interface may be immutable.</div>
+ *
  * <p>This class is not yet in public API. We are waiting for more experience before to decide if it should be
  * committed API.</p>
  *
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 f730caa..f6fb420 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
@@ -241,8 +241,8 @@ public class TreeTableFormat extends TabularFormat<TreeTable> {
     }
 
     /**
-     * Returns the table columns to parse and format, or {@code null} for the default list of
-     * columns. The default is:
+     * Returns the table columns to parse and format, or {@code null} for the default list of columns.
+     * The default is:
      *
      * <ul>
      *   <li>On parsing, a single column containing the node label as a {@link String}.</li>
@@ -701,10 +701,12 @@ public class TreeTableFormat extends TabularFormat<TreeTable> {
             this.recursivityGuard = recursivityGuard;
             Predicate<TreeTable.Node> filter = nodeFilter;
             if (tree instanceof TreeFormatCustomization) {
-                final Predicate<TreeTable.Node> more = ((TreeFormatCustomization) tree).filter();
+                final TreeFormatCustomization custom = (TreeFormatCustomization) tree;
+                final Predicate<TreeTable.Node> more = custom.filter();
                 if (more != null) {
                     filter = (filter != null) ? more.and(filter) : more;
                 }
+            } else {
             }
             this.filter = filter;
             setTabulationExpanded(true);
@@ -779,6 +781,20 @@ public class TreeTableFormat extends TabularFormat<TreeTable> {
             } else if (value instanceof Object[]) {
                 formatCollection(Arrays.asList((Object[]) value), recursive);
                 return;
+            } else if (value instanceof Map.Entry<?,?>) {
+                final Map.Entry<?,?> entry = (Map.Entry<?,?>) value;
+                final Object k = entry.getKey();
+                final Object v = entry.getValue();
+                if (k == null) {
+                    append(null);
+                } else {
+                    formatValue(k, recursive);
+                }
+                if (v != null) {
+                    append(" → ");
+                    formatValue(v, recursive);
+                }
+                return;
             } else {
                 /*
                  * Check for a value-by-value format only as last resort. If a column-wide format was specified by
@@ -872,14 +888,12 @@ public class TreeTableFormat extends TabularFormat<TreeTable> {
              * may be followed by a non-null value, which is why we need to check all of them before to know how many
              * columns to omit.
              */
-            int n = 0;
             for (int i=0; i<columns.length; i++) {
-                if ((values[i] = node.getValue(columns[i])) != null) {
-                    n = i;
-                }
+                values[i] = node.getValue(columns[i]);
             }
-            if (!omitTrailingNulls) {
-                n = values.length - 1;
+            int n = values.length - 1;
+            if (omitTrailingNulls) {
+                while (n > 0 && values[n] == null) n--;
             }
             /*
              * Format the values that we fetched in above loop.
diff --git a/storage/sis-earth-observation/src/test/java/org/apache/sis/storage/earthobservation/LandsatReaderTest.java b/storage/sis-earth-observation/src/test/java/org/apache/sis/storage/earthobservation/LandsatReaderTest.java
index aa887c0..c1c13f0 100644
--- a/storage/sis-earth-observation/src/test/java/org/apache/sis/storage/earthobservation/LandsatReaderTest.java
+++ b/storage/sis-earth-observation/src/test/java/org/apache/sis/storage/earthobservation/LandsatReaderTest.java
@@ -106,7 +106,7 @@ public class LandsatReaderTest extends TestCase {
         verifier.addPropertyToIgnore(Metadata.class, "referenceSystemInfo");        // Very verbose and depends on EPSG connection.
         verifier.addMetadataToVerify(actual);
         verifier.assertMetadataEquals(
-            "language[0]",                                                                           "en",
+            "defaultLocale+otherLocale[0]",                                                          "en",
             "metadataIdentifier.code",                                                               "LandsatTest",
             "metadataScope[0].resourceScope",                                                        ScopeCode.COVERAGE,
             "dateInfo[0].date",                                                                      date("2016-06-27 16:48:12"),
diff --git a/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/xml/StoreTest.java b/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/xml/StoreTest.java
index c251d74..6155e3b 100644
--- a/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/xml/StoreTest.java
+++ b/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/xml/StoreTest.java
@@ -18,7 +18,6 @@ package org.apache.sis.internal.storage.xml;
 
 import java.util.Locale;
 import java.io.StringReader;
-import java.nio.charset.StandardCharsets;
 import org.opengis.metadata.Metadata;
 import org.opengis.metadata.citation.*;
 import org.apache.sis.xml.Namespaces;
@@ -31,7 +30,6 @@ import org.junit.Test;
 
 import static org.opengis.test.Assert.*;
 import static org.apache.sis.test.TestUtilities.getSingleton;
-import static org.apache.sis.metadata.iso.DefaultMetadataTest.REGRESSION;
 
 
 /**
@@ -103,9 +101,7 @@ public final strictfp class StoreTest extends TestCase {
         final OnlineResource resource = getSingleton(contact.getOnlineResources());
 
         assertInstanceOf("party", Organisation.class, party);
-        assertEquals(Locale.ENGLISH,              getSingleton(metadata.getLanguages()));
-        if (!REGRESSION)
-            assertEquals(StandardCharsets.UTF_8,  getSingleton(metadata.getCharacterSets()));
+        assertEquals(Locale.ENGLISH,              getSingleton(metadata.getLocalesAndCharsets().keySet()));
         assertEquals(Role.PRINCIPAL_INVESTIGATOR, resp.getRole());
         assertEquals("Apache SIS",                String.valueOf(party.getName()));
         assertEquals("http://sis.apache.org",     String.valueOf(resource.getLinkage()));


Mime
View raw message