You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2023/08/15 09:32:37 UTC

[sis] 07/08: Add Gradle build scripts. Port Maven MOJO to Gradle plugin.

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 70ccdd51e5398ed5727795ae03522b7c98a284fe
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sat May 20 19:11:46 2023 +0200

    Add Gradle build scripts.
    Port Maven MOJO to Gradle plugin.
---
 .gitignore                                         |   7 +-
 buildSrc/build.gradle.kts                          |  87 +++++
 .../{internal => buildtools}/book/Assembler.java   |   6 +-
 .../book/BookException.java                        |   4 +-
 .../{internal => buildtools}/book/Characters.java  |   4 +-
 .../book/CodeColorizer.java                        |   4 +-
 .../sis/{internal => buildtools}/book/GEOAPI.lst   |   0
 .../sis/{internal => buildtools}/book/OGC.lst      |   0
 .../sis/{internal => buildtools}/book/SIS.lst      |   0
 .../{internal => buildtools}/book/XML_PREFIX.lst   |   0
 .../book/package-info.java                         |   8 +-
 .../{internal => buildtools}/doclet/Doclet.java    |  72 +---
 .../{internal => buildtools}/doclet/Include.java   |   4 +-
 .../{internal => buildtools}/doclet/Rewriter.java  |  11 +-
 .../{internal => buildtools}/doclet/Taglet.java    |   4 +-
 .../doclet/package-info.java                       |   4 +-
 .../apache/sis/buildtools/gradle/Assembler.java    | 131 +++++++
 .../apache/sis/buildtools/gradle/BuildHelper.java  | 180 ++++++++++
 .../apache/sis/buildtools/gradle/Conventions.java  | 192 ++++++++++
 .../apache/sis/buildtools/gradle/Dependency.java   | 271 ++++++++++++++
 .../apache/sis/buildtools/gradle/JavaMaker.java    | 118 +++++++
 .../sis/buildtools/gradle/ModularCompilation.java  | 283 +++++++++++++++
 .../apache/sis/buildtools/gradle/ModularJAR.java   | 176 +++++++++
 .../sis/buildtools/gradle/ModularJavadoc.java      | 200 +++++++++++
 .../sis/buildtools/gradle/ModularPublishing.java   |  79 +++++
 .../apache/sis/buildtools/gradle/ModularTest.java  | 260 ++++++++++++++
 .../gradle/SISBuildException.java}                 |  27 +-
 .../org/apache/sis/buildtools/gradle/UnoPkg.java   | 215 +++++++++++
 .../apache/sis/buildtools/gradle/ZipWriter.java    | 286 +++++++++++++++
 .../maven => buildtools/gradle}/package-info.java  |  17 +-
 .../org/apache/sis/buildtools/maven/Element.java   |  73 ++++
 .../org/apache/sis/buildtools/maven/Generator.java | 239 +++++++++++++
 .../doclet => buildtools/maven}/package-info.java  |  13 +-
 .../sis/buildtools/maven/workaround/Argument.java  | 100 ++++++
 .../maven/workaround/ProxyGenerator.java           | 279 +++++++++++++++
 .../buildtools/maven/workaround/package-info.java  |  38 ++
 .../resources/IndexedResourceCompiler.java         | 321 ++++++++++++-----
 .../resources}/package-info.java                   |  11 +-
 .../org/apache/sis/internal/maven/Assembler.java   | 270 --------------
 .../org/apache/sis/internal/maven/Filenames.java   | 111 ------
 .../apache/sis/internal/maven/JarCollector.java    | 282 ---------------
 .../org/apache/sis/internal/unopkg/JavaMaker.java  | 148 --------
 .../org/apache/sis/internal/unopkg/UnoPkg.java     | 295 ----------------
 .../apache/sis/internal/unopkg/package-info.java   |  87 -----
 .../sis/util/resources/ResourceCompilerMojo.java   | 283 ---------------
 .../org/apache/sis/util/resources/package-info.txt |   2 -
 .../resources/IndexedResourceCompilerTest.java     |  65 ++++
 .../util/resources/ResourceCompilerMojoTest.java   |  51 ---
 endorsed/build.gradle.kts                          | 392 +++++++++++++++++++++
 .../bundle/build-instruction.html                  |   4 +-
 .../org/apache/sis/openoffice/XReferencing.java    |   2 +-
 .../org/apache/sis/openoffice/package-info.java    |   2 +-
 .../src/org.apache.sis.test/test/module-info.java  |  14 +-
 incubator/build.gradle.kts                         | 144 ++++++++
 .../main/antlr/org/apache/sis/internal/cql/CQL.g4  |   3 +
 .../main/org/apache/sis/internal/cql/.gitignore    |   7 +
 .../src/org.apache.sis.test/test/module-info.java  |  12 +-
 optional/build.gradle.kts                          | 168 +++++++++
 .../src/org.apache.sis.test/test/module-info.java  |  12 +-
 settings.gradle.kts                                |  92 +++++
 60 files changed, 4387 insertions(+), 1783 deletions(-)

diff --git a/.gitignore b/.gitignore
index b233e1efca..6840c52153 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,11 @@
-target/
+# Gradle files and build results
+.gradle/
+build/
 *.tmp
 *.log
+
+# Legacy systems (should not be used anymore)
+target/
 .svn/
 
 # Generated by NetBeans IDE
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
new file mode 100644
index 0000000000..c32e77e311
--- /dev/null
+++ b/buildSrc/build.gradle.kts
@@ -0,0 +1,87 @@
+/*
+ * 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.
+ */
+plugins {
+    `java-gradle-plugin`
+}
+
+gradlePlugin {
+    plugins {
+        create("buildTools") {
+            id = "org.apache.sis.buildtools"
+            implementationClass = "org.apache.sis.buildtools.gradle.BuildHelper"
+        }
+    }
+}
+
+/*
+ * Source files are organized following the same convention than other Apache SIS sub-projects.
+ * However since there is currently only one module in `buildSRC`, we do not need to configure
+ * for Module Source Hierarchy. We let Gradle uses Package Hierarchy (the default Gradle mode)
+ * and simulate the module source hierarchy with `setSrcDirs(…)`.
+ */
+sourceSets {
+    main {
+        java {
+            setSrcDirs(listOf("src/org.apache.sis.buildtools/main"))
+        }
+        resources {
+            setSrcDirs(listOf("src/org.apache.sis.buildtools/main"))
+            include("**/*.lst")
+        }
+    }
+    test {
+        java {
+            setSrcDirs(listOf("src/org.apache.sis.buildtools/test"))
+        }
+    }
+}
+
+/*
+ * All dependencies used by the plugin.
+ */
+repositories {
+    mavenCentral()
+}
+dependencies {
+    implementation (group = "org.apache.commons", name = "commons-compress",        version = "1.23.0")
+    testCompileOnly(group = "junit",              name = "junit",                   version = "4.13.2")
+    testRuntimeOnly(group = "org.junit.vintage",  name = "junit-vintage-engine",    version = "5.9.3")
+    testRuntimeOnly(group = "org.junit.platform", name = "junit-platform-launcher", version = "1.9.3")
+}
+
+/*
+ * Compile main classes from Package Hierarchy.
+ */
+tasks.compileJava {
+    options.encoding = "UTF-8"      // The character encoding to be used when reading source files.
+    options.setDeprecation(true)    // Whether to log details of usage of deprecated members or classes.
+}
+tasks.compileTestJava {
+    options.encoding = "UTF-8"
+    options.setDeprecation(true)
+}
+
+/*
+ * Discover and execute JUnit-based tests.
+ */
+tasks.test {
+    useJUnitPlatform()
+    testLogging {
+        events("PASSED", "SKIPPED", "FAILED", "STANDARD_OUT", "STANDARD_ERROR")
+        setExceptionFormat("FULL")
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/Assembler.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/Assembler.java
similarity index 99%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/Assembler.java
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/Assembler.java
index d1c21313b9..700eb469a9 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/Assembler.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/Assembler.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.sis.internal.book;
+package org.apache.sis.buildtools.book;
 
 import java.util.Arrays;
 import java.util.HashMap;
@@ -39,7 +39,7 @@ import org.w3c.dom.NodeList;
 import org.xml.sax.SAXException;
 
 // Share a convenience method.
-import static org.apache.sis.internal.book.CodeColorizer.toArray;
+import static org.apache.sis.buildtools.book.CodeColorizer.toArray;
 
 
 /**
@@ -60,7 +60,7 @@ import static org.apache.sis.internal.book.CodeColorizer.toArray;
  * See package javadoc for usage example.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
+ * @version 1.4
  * @since   0.7
  */
 public final class Assembler {
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/BookException.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/BookException.java
similarity index 95%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/BookException.java
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/BookException.java
index 7ea2f517c4..71bc5b692c 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/BookException.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/BookException.java
@@ -14,14 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.sis.internal.book;
+package org.apache.sis.buildtools.book;
 
 
 /**
  * Thrown when an error is found in the content of the HTML files used for building the developer guide.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.7
+ * @version 1.4
  * @since   0.7
  */
 @SuppressWarnings("serial")
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/Characters.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/Characters.java
similarity index 96%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/Characters.java
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/Characters.java
index 4ed6a15677..0b7028f873 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/Characters.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/Characters.java
@@ -14,14 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.sis.internal.book;
+package org.apache.sis.buildtools.book;
 
 
 /**
  * Utilities related to the handling of characters.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
+ * @version 1.4
  * @since   1.3
  */
 public final class Characters {
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/CodeColorizer.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/CodeColorizer.java
similarity index 99%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/CodeColorizer.java
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/CodeColorizer.java
index 7c50438599..bec70d3930 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/CodeColorizer.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/CodeColorizer.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.sis.internal.book;
+package org.apache.sis.buildtools.book;
 
 import java.util.HashMap;
 import java.util.Map;
@@ -36,7 +36,7 @@ import org.w3c.dom.NodeList;
  * standard, in GeoAPI or in Apache SIS.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
+ * @version 1.4
  * @since   0.7
  */
 public final class CodeColorizer {
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/GEOAPI.lst b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/GEOAPI.lst
similarity index 100%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/GEOAPI.lst
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/GEOAPI.lst
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/OGC.lst b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/OGC.lst
similarity index 100%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/OGC.lst
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/OGC.lst
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/SIS.lst b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/SIS.lst
similarity index 100%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/SIS.lst
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/SIS.lst
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/XML_PREFIX.lst b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/XML_PREFIX.lst
similarity index 100%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/XML_PREFIX.lst
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/XML_PREFIX.lst
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/package-info.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/package-info.java
similarity index 90%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/package-info.java
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/package-info.java
index 304e62cdc1..a4b5372b94 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/book/package-info.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/book/package-info.java
@@ -19,7 +19,7 @@
  * Takes the source HTML files in the {@code sis-site/main/content/developer-guide/} directory
  * and assembles them in a single file to be published in the {@code asf-staging/book/} directory.
  *
- * <p>The main class in this package is {@link org.apache.sis.internal.book.Assembler}.
+ * <p>The main class in this package is {@link org.apache.sis.buildtools.book.Assembler}.
  * Other classes are helper classes that should be ignored. Assuming the following directory layout:</p>
  *
  * <pre>&lt;current directory&gt;
@@ -35,7 +35,7 @@
  *
  * Then the command can be used as below on Unix systems:
  *
- * <pre>java -classpath master/core/sis-build-helper/target/classes org.apache.sis.internal.book.Assembler site</pre>
+ * <pre>java -classpath master/core/sis-build-helper/target/classes org.apache.sis.buildtools.book.Assembler site</pre>
  *
  * <h2>Future evolution</h2>
  * We may replace (at least partially) this tools by some more advanced open-source alternatives.
@@ -49,7 +49,7 @@
  * See <a href="http://www.xmlmind.com/tutorials/HTML5Books/HTML5Books.html">HTML5 as an alternative to DITA and DocBook</a>.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
+ * @version 1.4
  * @since   0.7
  */
-package org.apache.sis.internal.book;
+package org.apache.sis.buildtools.book;
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/Doclet.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/Doclet.java
similarity index 59%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/Doclet.java
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/Doclet.java
index e7c77981e7..a4e8d52549 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/Doclet.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/Doclet.java
@@ -14,19 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.sis.internal.doclet;
+package org.apache.sis.buildtools.doclet;
 
 import java.util.Set;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.nio.file.Path;
 import java.io.File;
-import java.io.FileNotFoundException;
 import java.io.UncheckedIOException;
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.io.StringWriter;
-import java.nio.file.Files;
 import javax.tools.Diagnostic;
 import jdk.javadoc.doclet.Reporter;
 import jdk.javadoc.doclet.Doclet.Option;
@@ -39,19 +37,14 @@ import jdk.javadoc.doclet.StandardDoclet;
  * The post-javadoc actions are:
  *
  * <ul>
- *   <li>Copy additional resources.</li>
+ *   <li>Insert hyphenation.</li>
  * </ul>
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.4
  * @since   0.5
  */
 public final class Doclet extends StandardDoclet {
-    /**
-     * The name of the SIS-specific stylesheet file.
-     */
-    private static final String STYLESHEET = "sis.css";
-
     /**
      * The directory where HTML pages will be written.
      */
@@ -112,8 +105,6 @@ public final class Doclet extends StandardDoclet {
         final boolean success = super.run(environment);
         if (success && outputDirectory != null) try {
             final Path output = Path.of(outputDirectory);
-            final Path resources = resources(output);
-            copyResources(resources, output);
             final Rewriter r = new Rewriter();
             for (final File file : output.toFile().listFiles()) {
                 if (file.isDirectory()) {       // Do not process files in the root directory, only in sub-directories.
@@ -130,63 +121,6 @@ public final class Doclet extends StandardDoclet {
         return success;
     }
 
-    /**
-     * Returns the {@code src/main/javadoc/} directory relative to the root of the Maven project.
-     * This method scans parents of the given directory until we find the root of the Maven project.
-     */
-    private static Path resources(Path directory) throws FileNotFoundException {
-        boolean isModuleDirectory = false;
-        while ((directory = directory.getParent()) != null && Files.isDirectory(directory)) {
-            if (Files.isRegularFile(directory.resolve("pom.xml"))) {
-                isModuleDirectory = true;
-                final Path candidate = directory.resolve("src").resolve("main").resolve("javadoc");
-                if (Files.isRegularFile(candidate.resolve(STYLESHEET))) {
-                    return candidate;
-                }
-            } else if (isModuleDirectory) {
-                // If we were in a Maven module and the parent directory does not
-                // have a pom.xml file, then we are no longer in the Maven project.
-                break;
-            }
-        }
-        throw new FileNotFoundException("Cannot locate \"src/main/javadoc\" from the root of this Maven project.");
-    }
-
-    /**
-     * Creates links to Javadoc resources in the top-level directory (not from "{@code doc-files}" subdirectories).
-     * While the Maven documentation said that the "{@code src/main/javadoc}" directory is copied by default, or a
-     * directory can be specified with {@code <javadocResourcesDirectory>}, I have been unable to make it work even
-     * with absolute paths.
-     *
-     * @param  inputFile        the directory containing resources.
-     * @param  outputDirectory  the directory where to copy the resource files.
-     * @throws IOException      if an error occurred while reading or writing.
-     */
-    private static void copyResources(final Path inputDirectory, final Path outputDirectory) throws IOException {
-        Files.walk(inputDirectory).forEach((input) -> {
-            if (Files.isRegularFile(input)) {
-                final Path file = input.getFileName();
-                switch (file.toString()) {
-                    case "overview.html":
-                    case STYLESHEET: break;
-                    default: {
-                        final Path output = outputDirectory.resolve(input.getFileName());
-                        if (!Files.exists(output)) try {
-                            try {
-                                Files.createLink(output, input);
-                            } catch (UnsupportedOperationException e) {
-                                Files.copy(input, output);
-                            }
-                        } catch (IOException e) {
-                            throw new UncheckedIOException(e);
-                        }
-                        break;
-                    }
-                }
-            }
-        });
-    }
-
     /**
      * Reports an I/O errors.
      */
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/Include.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/Include.java
similarity index 98%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/Include.java
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/Include.java
index 8d635b4535..6d88e28e6f 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/Include.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/Include.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.sis.internal.doclet;
+package org.apache.sis.buildtools.doclet;
 
 import java.nio.file.Path;
 import java.nio.file.Files;
@@ -36,7 +36,7 @@ import com.sun.source.doctree.DocTree;
  * just before the parts to copy in the javadoc.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
+ * @version 1.4
  * @since   0.5
  */
 public final class Include extends Taglet {
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/Rewriter.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/Rewriter.java
similarity index 96%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/Rewriter.java
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/Rewriter.java
index a97c4921c4..34eaeeb7f7 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/Rewriter.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/Rewriter.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.sis.internal.doclet;
+package org.apache.sis.buildtools.doclet;
 
 import java.io.File;
 import java.io.IOException;
@@ -24,7 +24,7 @@ import java.io.InputStreamReader;
 import java.io.OutputStreamWriter;
 import java.io.Reader;
 import java.io.Writer;
-import org.apache.sis.internal.book.Characters;
+import org.apache.sis.buildtools.book.Characters;
 
 
 /**
@@ -38,13 +38,10 @@ import org.apache.sis.internal.book.Characters;
  *       insert a zero-width space before the dot or underscore.</li>
  * </ul>
  *
- * The intent is to avoid large amount of white spaces in Javadoc when a line content long code.
- *
- * <p>Current version does not try to extends the standard doclet because the latter is under revision in JDK 9.
- * We may revisit when JDK 9 become available, if their new standard doclet API is public.</p>
+ * The intent is to avoid large amount of white spaces in Javadoc when a line contains long code.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
+ * @version 1.4
  * @since   0.7
  */
 final class Rewriter {
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/Taglet.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/Taglet.java
similarity index 99%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/Taglet.java
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/Taglet.java
index acb188f712..e8f9e87a97 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/Taglet.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/Taglet.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.sis.internal.doclet;
+package org.apache.sis.buildtools.doclet;
 
 import java.util.Set;
 import java.util.EnumSet;
@@ -38,7 +38,7 @@ import jdk.javadoc.doclet.StandardDoclet;
  * Taglets are assumed inline by default.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.4
  * @since   0.3
  */
 abstract class Taglet implements jdk.javadoc.doclet.Taglet {
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/package-info.java
similarity index 94%
copy from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java
copy to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/package-info.java
index 5817ad614a..f799e882ab 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/doclet/package-info.java
@@ -21,7 +21,7 @@
  * together with doclet performing post-processing after Javadoc generation.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.4
  * @since   0.5
  */
-package org.apache.sis.internal.doclet;
+package org.apache.sis.buildtools.doclet;
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/Assembler.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/Assembler.java
new file mode 100644
index 0000000000..35f49935fb
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/Assembler.java
@@ -0,0 +1,131 @@
+/*
+ * 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.buildtools.gradle;
+
+import java.io.File;
+import java.io.FilenameFilter;
+import java.io.FileNotFoundException;
+import java.io.UncheckedIOException;
+import java.io.IOException;
+import org.gradle.api.Task;
+import org.gradle.api.Project;
+import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
+
+
+/**
+ * Creates a ZIP file containing the Apache SIS binary distribution.
+ * The created file contains:
+ *
+ * <ul>
+ *   <li>the content of the <code>optional/src/org.apache.sis.gui/bundle</code> directory;</li>
+ *   <li>the content of the <code>endorsed/build/libs</code> and <code>optional/build/libs</code> directories.</li>
+ * </ul>
+ *
+ * Assemblies are created only for module having an {@code bundle} sub-directory.
+ * The module is hard-coded for now (a future version could perform a search if desired).
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @author  Quentin Bialota (Geomatys)
+ * @version 1.4
+ * @since   0.4
+ */
+final class Assembler extends ZipWriter.Apache implements FilenameFilter {
+    /**
+     * The module on which to apply this task.
+     */
+    private static final String MODULE = "org.apache.sis.gui";
+
+    /**
+     * The name of the sub-directory inside the ZIP file where the JAR files will be located.
+     * Note that we will not write in the real directory, but only in the directory structure
+     * which is reproduced in the ZIP file.
+     */
+    private static final String LIB_DIRECTORY = "lib/";
+
+    /**
+     * Creates an helper object for creating the assembly.
+     *
+     * @param  project  the project for which to create an assembly.
+     * @param  out      output stream of the ZIP file to create.
+     */
+    private Assembler(final Project project, final ZipArchiveOutputStream out) {
+        super(project, out);
+    }
+
+    /**
+     * Creates the distribution file.
+     *
+     * @param  task  the assembler task (in Gradle 8.2.1, it appears to be an opaque decorator that we cannot cast).
+     */
+    static void create(final Task task) {
+        final Project project = task.getProject();
+        final File sourceDirectory = getBundleSourceDirectory(project, MODULE);
+        if (sourceDirectory.isDirectory()) try {
+            File target = new File(project.getBuildDir(), BUNDLE_DIRECTORY);
+            mkdir(target);
+            String filename = FINALNAME_PREFIX + project.getVersion();
+            target = new File(target, filename + ".zip");
+            filename += '/';
+            try (ZipArchiveOutputStream out = new ZipArchiveOutputStream(target)) {
+                final var c = new Assembler(project, out);
+                c.writeDirectory(sourceDirectory, null, filename);
+                c.copyCompiledJARs(filename + LIB_DIRECTORY);
+            }
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    /**
+     * Copies all endorsed and optional JAR files compiled by the Apache SIS project.
+     *
+     * @param  libDir  name of the "lib" directory inside the ZIP file.
+     * @throws IOException if an error occurred while copying a file.
+     */
+    private void copyCompiledJARs(final String libDir) throws IOException {
+        writeDirectory(fileRelativeToBuild(LIBS_DIRECTORY), null, libDir);
+        final File endorsed = new File(new File(new File(project.getRootDir(), ENDORSED_SUBPROJECT), BUILD_DIRECTORY), LIBS_DIRECTORY);
+        final File[] deps = endorsed.listFiles(this);
+        if (deps == null) {
+            throw new FileNotFoundException("Missing directory: " + endorsed);
+        }
+        for (final File dep : deps) {
+            writeFile(dep, libDir);
+        }
+    }
+
+    /**
+     * Whether to include the given file in the ZIP file.
+     *
+     * @param  dir   directory of the file.
+     * @param  name  name of the file.
+     * @return whether to include the file.
+     */
+    @Override
+    public boolean accept(final File dir, final String name) {
+        if (!name.endsWith(".jar")) {
+            return false;
+        }
+        if (name.startsWith("org.apache.sis.cloud.aws")) {
+            return false;       // Excluded for avoiding AWS dependencies.
+        }
+        if (name.startsWith("org.apache.sis.profile.japan")) {
+            return false;       // Excluded for avoiding UCAR dependencies.
+        }
+        return name.startsWith("org.apache.sis.");
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/BuildHelper.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/BuildHelper.java
new file mode 100644
index 0000000000..c17df41d9e
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/BuildHelper.java
@@ -0,0 +1,180 @@
+/*
+ * 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.buildtools.gradle;
+
+import java.io.File;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.Map;
+import java.util.LinkedHashMap;
+import java.util.stream.Stream;
+import org.gradle.api.Project;
+import org.gradle.api.Plugin;
+import org.gradle.api.Task;
+import org.gradle.api.plugins.JavaPlugin;
+import org.gradle.api.tasks.SourceSet;
+import org.gradle.api.tasks.SourceSetContainer;
+import org.gradle.api.tasks.TaskContainer;
+import org.gradle.api.tasks.compile.JavaCompile;
+import org.gradle.api.tasks.javadoc.Javadoc;
+import org.gradle.api.tasks.bundling.Jar;
+import org.gradle.api.tasks.testing.Test;
+
+
+/**
+ * Registration of Gradle tasks for building Apache SIS.
+ *
+ * @author  Quentin Bialota (Geomatys)
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+public final class BuildHelper implements Plugin<Project> {
+    /**
+     * Roots of package hierarchy inside each Java module. Keys are module names and values are directories.
+     * Populated by {@link #setSourceModulePaths(Project)} and should not be modified after that method call.
+     */
+    private final Map<String,File> mainDirs, testDirs;
+
+    /**
+     * Creates the task.
+     */
+    public BuildHelper() {
+        mainDirs = new LinkedHashMap<>(32);
+        testDirs = new LinkedHashMap<>(32);
+    }
+
+    /**
+     * Sets the collection of sources as one directory per module.
+     * The expected hierarchy is:
+     *
+     * <pre>endorsed
+     *     ├─ build
+     *     └─ src
+     *         ├─ org.apache.sis.metadata
+     *         │    ├─ main
+     *         │    │   ├─ module-info.java
+     *         │    │   └─ org/apache/sis/metadata/…
+     *         │    └─ test
+     *         │        └─ org/apache/sis/metadata/…
+     *         ├─ org.apache.sis.referencing
+     *         │    ├─ main
+     *         │    │   ├─ module-info.java
+     *         │    │   └─ org/apache/sis/referencing/…
+     *         │    └─ test
+     *         │        └─ org/apache/sis/referencing/…
+     *         └─ etc.</pre>
+     *
+     * @param  project  the project for which to set the sources.
+     */
+    private void setSourceModulePaths(final Project project) {
+        final File[] modules = project.file(Conventions.SOURCE_DIRECTORY).listFiles(File::isDirectory);
+        if (modules == null) {
+            throw new IllegalArgumentException("Not a directory: " + Conventions.SOURCE_DIRECTORY);
+        }
+        for (final File module : modules) {
+            File p;
+            final String name = module.getName();
+            if ((p = new File(module, Conventions.MAIN_DIRECTORY)).isDirectory()) mainDirs.put(name, p);
+            if ((p = new File(module, Conventions.TEST_DIRECTORY)).isDirectory()) testDirs.put(name, p);
+        }
+        final SourceSetContainer sources = project.getExtensions().getByType(SourceSetContainer.class);
+        sources.getByName(SourceSet.MAIN_SOURCE_SET_NAME).getJava().setSrcDirs(mainDirs.values());
+        sources.getByName(SourceSet.TEST_SOURCE_SET_NAME).getJava().setSrcDirs(testDirs.values());
+    }
+
+    /**
+     * Returns the name of all main modules.
+     *
+     * @return module names. This collection is not cloned, <strong>do not modify</strong>.
+     */
+    final Set<String> getModuleNames() {
+        return mainDirs.keySet();
+    }
+
+    /**
+     * Returns the roots of package hierarchy inside each Java module.
+     * There is one directory per module.
+     *
+     * @param  tests  {@code true} for returning test directories, or {@code false} for main directories.
+     * @return the directories. This collection is not cloned, <strong>do not modify</strong>.
+     */
+    final Collection<File> getSourceDirectories(final boolean tests) {
+        return (tests ? testDirs : mainDirs).values();
+    }
+
+    /**
+     * {@return the main modules to patch with test classes}.
+     */
+    final Stream<Map.Entry<String,File>> getPatchedDirectories() {
+        return testDirs.entrySet().stream().filter((e) -> mainDirs.containsKey(e.getKey()));
+    }
+
+    /**
+     * Registers tasks potentially used for Apache SIS build.
+     * This method is invoked during the configuration phase of Gradle.
+     *
+     * @param  project  the project where to register the task.
+     */
+    @Override
+    public void apply(final Project project) {
+        project.getPluginManager().apply(JavaPlugin.class);         // This plugin depends on the Java plugin.
+        setSourceModulePaths(project);
+        /*
+         * Configurations to be applied before a task is executed.
+         * Tasks are compilation, tests and javadoc.
+         */
+        final TaskContainer tasks = project.getTasks();
+        tasks.withType(JavaCompile.class).forEach((task) -> {       // We usually have only one instance.
+            final var ext = new ModularCompilation(task.getProject());
+            task.doFirst((t) -> {
+                ModularCompilation.modularize(BuildHelper.this, (JavaCompile) t);
+                ext.compileOrcopyResources(BuildHelper.this, t);
+            });
+            task.doLast((t) -> {
+                ext.flushResources();
+                JavaMaker.execute(project);
+            });
+        });
+        tasks.withType(Test.class).forEach((task) -> {
+            task.doFirst((t) -> {
+                ModularTest.modularizeAndExportToJunit((Test) t);
+            });
+        });
+        tasks.withType(Javadoc.class).forEach((task) -> {
+            task.doFirst((t) -> {
+                new ModularJavadoc((Javadoc) t).modularize(BuildHelper.this);
+            });
+        });
+        tasks.withType(Jar.class).forEach((task) -> {
+            task.setActions(List.of((t) -> {            // Replace the default action by our own.
+                ModularJAR.execute(BuildHelper.this, (Jar) t);
+            }));
+        });
+        final Task assemble = tasks.getByPath("assemble");
+        assemble.dependsOn("jar");
+        assemble.doLast((t) -> {     // tasks.withType(Assembler.class) does not work.
+            Assembler.create(t);
+            UnoPkg.create(t);
+        });
+        /*
+         * Configuration of the publication to Maven repository.
+         */
+        new ModularPublishing(project).configure();
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/Conventions.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/Conventions.java
new file mode 100644
index 0000000000..bbfd9c1090
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/Conventions.java
@@ -0,0 +1,192 @@
+/*
+ * 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.buildtools.gradle;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import org.gradle.api.Project;
+
+
+/**
+ * An helper class working on hard-coded file and directory names.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   0.4
+ */
+abstract class Conventions {
+    /**
+     * The directory where source code is located, relative to sub-project directory.
+     * This is a Gradle convention (inherited from Maven).
+     */
+    static final String SOURCE_DIRECTORY = "src";
+
+    /**
+     * The directory where compilation result are saved, relative to sub-project directory.
+     * This is a Gradle convention.
+     */
+    static final String BUILD_DIRECTORY = "build";
+
+    /**
+     * The root directory of the build tools (custom Gradle plugin).
+     * This is a Gradle convention.
+     */
+    static final String BUILD_TOOLS_DIRECTORY = "buildSrc";
+
+    /**
+     * The sub-directory inside "{@value #SOURCE_DIRECTORY}" for main Java source code.
+     * This is a Gradle convention (inherited from Maven), except that the module name
+     * is inserted between "{@value #SOURCE_DIRECTORY}" and "{@value}".
+     */
+    static final String MAIN_DIRECTORY = "main";
+
+    /**
+     * The sub-directory inside "{@value #SOURCE_DIRECTORY}" for test Java source code.
+     * This is a Gradle convention (inherited from Maven), except that the module name
+     * is inserted between "{@value #SOURCE_DIRECTORY}" and "{@value}".
+     */
+    static final String TEST_DIRECTORY = "test";
+
+    /**
+     * The sub-directory inside "{@value #BUILD_DIRECTORY}" for compiled main class files.
+     * This is a Gradle convention.
+     */
+    static final String MAIN_CLASSES_DIRECTORY = "classes/java/" + MAIN_DIRECTORY;
+
+    /**
+     * The sub-directory inside "{@value #BUILD_DIRECTORY}" for compiled test class files.
+     * This is a Gradle convention.
+     */
+    static final String TEST_CLASSES_DIRECTORY = "classes/java/" + TEST_DIRECTORY;
+
+    /**
+     * The sub-directory inside "{@value #BUILD_DIRECTORY}" for the JAR files.
+     * This is a Gradle convention.
+     */
+    static final String LIBS_DIRECTORY = "libs";
+
+    /**
+     * The sub-directory inside "{@value #BUILD_DIRECTORY}" for the ZIP or OXT files (assemblies).
+     * Also the sibling directory of "{@value #MAIN_DIRECTORY}" and "{@value #TEST_DIRECTORY}"
+     * for the sources to copy in the ZIP file. This is an Apache SIS convention.
+     */
+    static final String BUNDLE_DIRECTORY = "bundle";
+
+    /**
+     * Name of the sub-project containing the JAR files that are included in Apache SIS releases.
+     */
+    static final String ENDORSED_SUBPROJECT = "endorsed";
+
+    /**
+     * The prefix of the final filename of bundles created by assembly tasks.
+     */
+    static final String FINALNAME_PREFIX = "apache-sis-";
+
+    /**
+     * The encoding to use everywhere (source code, generated HTML pages, …).
+     */
+    static final String ENCODING = "UTF-8";
+
+    /**
+     * The sub-project being compiled.
+     */
+    protected final Project project;
+
+    /**
+     * Creates the helper class.
+     *
+     * @param  project  the sub-project being compiled.
+     */
+    Conventions(final Project project) {
+        this.project = project;
+    }
+
+    /**
+     * Returns the directory of the source files for the bundle in the given module.
+     *
+     * @param  project  the sub-project being compiled.
+     * @param  module   the module for which to get the bundle source directory.
+     * @return the bundle source directory of the specified module.
+     */
+    static File getBundleSourceDirectory(final Project project, final String module) {
+        return new File(new File(new File(project.getProjectDir(), SOURCE_DIRECTORY), module), BUNDLE_DIRECTORY);
+    }
+
+    /**
+     * Returns a sub-directory of the directory where the compilation result is saved.
+     *
+     * @param  path  name of sub-directories, in order.
+     * @return path of the given name relative to build directory.
+     */
+    final File fileRelativeToBuild(final String... path) {
+        File file = project.getBuildDir();
+        for (final String p : path) {
+            file = new File(file, p);
+        }
+        return file;
+    }
+
+    /**
+     * Returns the absolute path of a file relative to the root of the whole project.
+     *
+     * @param  filename  name of the filename to return.
+     * @return path of the given name relative to project root.
+     */
+    final File fileRelativeToRoot(final String filename) {
+        return new File(project.getRootDir(), filename);
+    }
+
+    /**
+     * Creates a subdirectory if it does not already exists.
+     * The parent directory must exist, this method intentionally does not create it.
+     *
+     * @param  dir  the subdirectory to create.
+     * @throws IOException if the subdirectory cannot be created.
+     */
+    static void mkdir(final File dir) throws IOException {
+        if (!(dir.exists() || dir.mkdir())) {
+            throw new IOException("Failed to create \"" + dir.getName() + "\" directory.");
+        }
+    }
+
+    /**
+     * Potentially creates a pseudo-copy (using hard-link) of the given file.
+     * If the file system does not support hard links, then a real copy is done.
+     * If the target file already exists and is not older than the source, then this method does nothing.
+     *
+     * @param  source  the file to copy.
+     * @param  target  the destination file.
+     * @throws IOException if an error occurred while creating the hard-link or copying the file.
+     */
+    static void linkOrCopy(final Path source, final Path target) throws IOException {
+        if (Files.isRegularFile(target)) {
+            if (Files.getLastModifiedTime(source).compareTo(Files.getLastModifiedTime(target)) <= 0) {
+                return;     // File is assumed up-to-date.
+            }
+            Files.delete(target);
+        }
+        Files.createDirectories(target.getParent());
+        try {
+            Files.createLink(target, source);
+        } catch (UnsupportedOperationException e) {
+            Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING);
+        }
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/Dependency.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/Dependency.java
new file mode 100644
index 0000000000..f28b60752f
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/Dependency.java
@@ -0,0 +1,271 @@
+/*
+ * 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.buildtools.gradle;
+
+import java.util.Map;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Objects;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+import java.util.jar.Attributes;
+import java.util.zip.ZipEntry;
+import java.io.File;
+import java.io.InputStream;
+import java.io.IOException;
+import java.lang.module.ModuleDescriptor;
+import org.gradle.api.Project;
+import org.gradle.api.artifacts.Configuration;
+import org.gradle.api.artifacts.ConfigurationContainer;
+import org.gradle.api.artifacts.ModuleVersionIdentifier;
+import org.gradle.api.artifacts.ResolvedArtifact;
+import org.gradle.api.publish.maven.MavenPublication;
+
+
+/**
+ * Information about a dependency.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+public final class Dependency {
+    /**
+     * Workaround for what seems to be a Gradle bug. {@link MavenPublication#getArtifactId()}
+     * does not contain the value specified in the {@code build.gradle.kts} file.
+     */
+    private static final Map<String,String> MODULE_NAMES = Map.ofEntries(
+        Map.entry("util",                     "core:sis-utility"),
+        Map.entry("metadata",                 "core:sis-metadata"),
+        Map.entry("referencing",              "core:sis-referencing"),
+        Map.entry("referencing.gazetteer",    "core:sis-referencing-by-identifiers"),
+        Map.entry("feature",                  "core:sis-feature"),
+        Map.entry("portrayal",                "core:sis-portrayal"),
+        Map.entry("storage",                  "storage:sis-storage"),
+        Map.entry("storage.sql",              "storage:sis-sqlstore"),
+        Map.entry("storage.xml",              "storage:sis-xmlstore"),
+        Map.entry("storage.netcdf",           "storage:sis-netcdf"),
+        Map.entry("storage.geotiff",          "storage:sis-geotiff"),
+        Map.entry("storage.earthobservation", "storage:sis-earth-observation"),
+        Map.entry("cloud.aws",                "cloud:sis-cloud-aws"),
+        Map.entry("profile.france",           "profiles:sis-french-profile"),
+        Map.entry("profile.japan",            "profiles:sis-japan-profile"),
+        Map.entry("console",                  "application:sis-console"),
+        Map.entry("openoffice",               "application:sis-openoffice"),
+        Map.entry("gui",                      "application:sis-javafx"),            // Optional.
+        Map.entry("cql",                      "core:sis-cql"),                      // Incubator.
+        Map.entry("storage.shapefile",        "core:sis-shapefile"),
+        Map.entry("webapp",                   "application:sis-webapp")
+    );
+
+    /**
+     * The attribute for automatic module name in {@code META-INF/MANIFEST.MF} files.
+     */
+    private static final Attributes.Name AUTO_MODULE_NAME = new Attributes.Name("Automatic-Module-Name");
+
+    /**
+     * Path to the dependency JAR file.
+     */
+    public final File file;
+
+    /**
+     * Maven coordinates of the dependency.
+     *
+     * @see #writeAsMaven(StringBuilder, String)
+     */
+    private final String group, name, version;
+
+    /**
+     * Name of the JPMS module of the dependency, or {@code null} if unspecified.
+     */
+    public final String module;
+
+    /**
+     * Creates information about a publication to be handled as a dependency for other publication.
+     *
+     * @param  pub  the publication.
+     */
+    Dependency(final MavenPublication pub) {
+        final String patch = MODULE_NAMES.get(pub.getName());
+        if (patch == null) {
+            throw new SISBuildException("Unknown publication: " + pub.getName());
+        }
+        final int s = patch.indexOf(':');
+        group   = "org.apache.sis." + patch.substring(0, s);
+        name    = patch.substring(s+1);
+        version = pub.getVersion();
+        module  = "org.apache.sis." + pub.getName();
+        file    = null;
+    }
+
+    /**
+     * Creates a new dependency.
+     *
+     * @param  artifact  Gradle artifact to be converted to Maven one.
+     * @throws IOException if an I/O error occurred while reading the module description.
+     */
+    private Dependency(final ResolvedArtifact artifact) throws IOException {
+        final ModuleVersionIdentifier id = artifact.getModuleVersion().getId();
+        group   = id.getGroup();
+        name    = id.getName();
+        version = id.getVersion();
+        file    = artifact.getFile();
+        module  = getModuleName();
+    }
+
+    /**
+     * Returns the name of the JPMS module of the dependency.
+     *
+     * @return JPMS module name, or {@code null} if unspecified.
+     * @throws IOException if an I/O error occurred while reading the module description.
+     */
+    private String getModuleName() throws IOException {
+        try (JarFile zip = new JarFile(file, false, JarFile.OPEN_READ, JarFile.runtimeVersion())) {
+            final ZipEntry entry = zip.getEntry("module-info.class");
+            if (entry != null) {
+                try (InputStream in = zip.getInputStream(entry)) {
+                    final ModuleDescriptor desc = ModuleDescriptor.read(in);
+                    return desc.name();
+                }
+            }
+            final Manifest mf = zip.getManifest();
+            if (mf != null) {
+                final Object value = mf.getMainAttributes().get(AUTO_MODULE_NAME);
+                if (value instanceof String) {
+                    return (String) value;
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns the dependencies of the given project.
+     *
+     * @param  project  the project for which to find dependencies.
+     * @return dependencies of the specified project.
+     * @throws IOException if an I/O error occurred while reading the module description.
+     */
+    public static List<Dependency> find(final Project project) throws IOException {
+        final var dependencies = new ArrayList<Dependency>();
+        final ConfigurationContainer configurations = project.getConfigurations();
+        for (final String type : new String[] {"implementation","compileOnly","runtimeOnly"}) {
+            final Configuration config = configurations.getByName(type).copyRecursive();
+            config.setCanBeResolved(true);
+            for (final ResolvedArtifact artifact : config.getResolvedConfiguration().getResolvedArtifacts()) {
+                dependencies.add(new Dependency(artifact));
+            }
+        }
+        return dependencies;
+    }
+
+    /**
+     * Returns the JPMS dependencies of the given project.
+     * Keys are JPMS module names are values are dependency descriptions.
+     *
+     * @param  project      the project for which to find dependencies.
+     * @param  subProjects  the sub-projects, which also need to be included as dependencies across JPMS modules.
+     * @return dependencies of the specified project.
+     * @throws IOException if an I/O error occurred while reading the module description.
+     */
+    public static Map<String,Dependency> jpms(final Project project, final Collection<Dependency> subProjects) throws IOException {
+        final var dependencies = new HashMap<String,Dependency>();
+        final var warnings     = new HashSet<String>();
+        final var sources      = find(project);
+        sources.addAll(subProjects);
+        sources.forEach((dep) -> {
+            if (dep.module != null) {
+                final Dependency old = dependencies.putIfAbsent(dep.module, dep);
+                if (old != null && !old.equals(dep)) {
+                    final StringBuilder message = new StringBuilder(600).append("Inconsistent dependencies:\n");
+                    old.writeAsMaven(message, 1, false);
+                    dep.writeAsMaven(message, 1, false);
+                    final String s = message.toString();
+                    if (warnings.add(s)) {
+                        project.getLogger().warn(s);
+                    }
+                }
+            }
+        });
+        return dependencies;
+    }
+
+    /**
+     * Writes the Maven {@code <dependency>} element in the given buffer.
+     *
+     * @param  pom       where to write.
+     * @param  level     the initial indentation level.
+     * @param  optional  whether the dependency is optional.
+     */
+    public final void writeAsMaven(final StringBuilder pom, final int level, final boolean optional) {
+        final String indent = "    ";
+        final String margin = indent.repeat(level);
+        pom.append(margin).append("<dependency>\n");
+        if (module != null) {
+            pom.append(margin).append(indent).append("<!-- module ").append(module).append(" -->\n");
+        }
+        pom.append(margin).append(indent).append("<groupId>")   .append(group)  .append("</groupId>\n");
+        pom.append(margin).append(indent).append("<artifactId>").append(name)   .append("</artifactId>\n");
+        pom.append(margin).append(indent).append("<version>")   .append(version).append("</version>\n");
+        if (optional) {
+            pom.append(margin).append(indent).append("<optional>true</optional>\n");
+        }
+        pom.append(margin).append("</dependency>\n");
+    }
+
+    /**
+     * Returns a string representation for debugging purposes.
+     *
+     * @return string representation of the {@code <dependency>} element.
+     */
+    @Override
+    public String toString() {
+        final var pom = new StringBuilder(100);
+        writeAsMaven(pom, 0, false);
+        return pom.toString();
+    }
+
+    /**
+     * Compares this dependency with the given object for equality.
+     *
+     * @param  obj  the object to compare with this dependency.
+     * @return whether the two object are equal.
+     */
+    @Override
+    public boolean equals(final Object obj) {
+        if (!(obj instanceof Dependency)) {
+            return false;
+        }
+        final var other = (Dependency) obj;
+        return Objects.equals(file,    other.file)    &&
+               Objects.equals(group,   other.group)   &&
+               Objects.equals(name,    other.name)    &&
+               Objects.equals(version, other.version) &&
+               Objects.equals(module,  other.module);
+    }
+
+    /**
+     * {@return a hash code for this dependency}.
+     */
+    @Override
+    public int hashCode() {
+        return Objects.hash(file, group, name, version, module);
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/JavaMaker.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/JavaMaker.java
new file mode 100644
index 0000000000..0935c1c1c8
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/JavaMaker.java
@@ -0,0 +1,118 @@
+/*
+ * 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.buildtools.gradle;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Path;
+import java.nio.file.Files;
+import java.nio.file.FileVisitResult;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import org.gradle.api.Project;
+
+
+/**
+ * Compiles Java interfaces from OpenOffice IDL files.
+ *
+ * <p>In an ideal world, this task would execute {@code idlc} on the {@code *.idl} files,
+ * then {@code regmerge} on the generated {@code *.urd} files,
+ * then {@code javamaker} on the generated {@code *.rdb} files.
+ * However, since the above mentioned tools are native and would require a manual installation
+ * on every developer machine, current version just copies a pre-compiled class file.
+ * This copy must occurs after the compilation phase in order to overwrite the files
+ * generated by {@code javac}.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @author  Quentin Bialota (Geomatys)
+ * @version 1.4
+ * @since   0.8
+ */
+final class JavaMaker extends SimpleFileVisitor<Path> {
+    /**
+     * The root of source and target directories. Files below {@code source} will be copied
+     * with identical path (relative to {@code source}) under {@code target} directory.
+     */
+    private final Path source, target;
+
+    /**
+     * Number of files copied.
+     */
+    private int count;
+
+    /**
+     * Creates a new copier.
+     *
+     * @param unopkgDirectory  base directory of the class to "compile".
+     * @param outputDirectory  directory where the output class files are located.
+     */
+    private JavaMaker(final File unopkgDirectory, final File outputDirectory) {
+        source = unopkgDirectory.toPath();
+        target = outputDirectory.toPath();
+    }
+
+    /**
+     * Copies {@code *.class} files from source directory to output directory.
+     * The output directory shall already exist. It should be the case if all
+     * sources files have been compiled before this method is invoked.
+     *
+     * @param  project  the sub-project.
+     */
+    static void execute(final Project project) {
+        final File moduleDirectory = Conventions.getBundleSourceDirectory(project, UnoPkg.MODULE);
+        if (moduleDirectory.isDirectory()) try {
+            File outputDirectory;                       // Path to "./build/classes/java/main/org.apache.sis.openoffice/"
+            outputDirectory = project.getBuildDir();
+            outputDirectory = new File(outputDirectory, Conventions.MAIN_CLASSES_DIRECTORY);
+            outputDirectory = new File(outputDirectory, UnoPkg.MODULE);
+            final var c = new JavaMaker(moduleDirectory, outputDirectory);
+            Files.walkFileTree(c.source, c);
+            project.getLogger().debug("[sis-unopkg] Copied " + c.count + " pre-compiled class files.");
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    /**
+     * Determines whether the given directory should be visited.
+     * This method skips hidden directories.
+     */
+    @Override
+    public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) {
+        return dir.getFileName().toString().startsWith(".") ? FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE;
+    }
+
+    /**
+     * Invoked for a file in a directory. The destination shall already exists.
+     * It should be the case if this task is invoked after Java compilation.
+     */
+    @Override
+    public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException {
+        final String filename = file.getFileName().toString();
+        if (filename.endsWith(".class") || filename.endsWith(".CLASS")) {
+            final Path dst = target.resolve(source.relativize(file)).normalize();
+            if (!dst.startsWith(target)) {
+                throw new SISBuildException("Unexpected target path: " + dst);
+            }
+            Files.copy(file, dst, StandardCopyOption.REPLACE_EXISTING);
+            count++;
+        }
+        return FileVisitResult.CONTINUE;
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularCompilation.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularCompilation.java
new file mode 100644
index 0000000000..2042d8ab2d
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularCompilation.java
@@ -0,0 +1,283 @@
+/*
+ * 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.buildtools.gradle;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.util.Map;
+import java.util.Set;
+import java.util.List;
+import java.util.Queue;
+import java.util.HashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.gradle.api.Project;
+import org.gradle.api.Task;
+import org.gradle.api.tasks.compile.JavaCompile;
+import org.gradle.api.tasks.compile.CompileOptions;
+import org.apache.sis.buildtools.resources.IndexedResourceCompiler;
+
+
+/**
+ * Extension to Gradle {@link JavaCompile} task.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+final class ModularCompilation extends Conventions {
+    /**
+     * Resources to copy after the compilation is finished.
+     * We copy resources after compilation because otherwise, they seem to be deleted randomly.
+     */
+    private final Queue<Map<File,Object>> pendingResources;
+
+    /**
+     * Creates a new helper instance for Apache SIS compilation.
+     *
+     * @param  project  the sub-project being compiled.
+     */
+    ModularCompilation(final Project project) {
+        super(project);
+        pendingResources = new ConcurrentLinkedQueue<>();
+    }
+
+    /**
+     * Returns {@code true} if the specified compilation task is for compiling the tests.
+     *
+     * @param  task  the task to check.
+     * @return whether the given task is compiling tests.
+     */
+    private static boolean isCompileTest(final Task task) {
+        return task.getName().contains("Test");
+    }
+
+    /**
+     * Returns the value of the {@code --module-source-path} option.
+     * That option is critical for Module Source Hierarchy.
+     * The directory structure that we expect for Apache SIS project is:
+     *
+     * <pre>&lt;gradle sub-project&gt;
+     *     ├─ build
+     *     └─ src
+     *         ├─ org.apache.sis.metadata
+     *         │    ├─ main
+     *         │    │   ├─ module-info.java
+     *         │    │   └─ org/apache/sis/metadata/…
+     *         │    └─ test
+     *         │        └─ org/apache/sis/metadata/…
+     *         ├─ org.apache.sis.referencing
+     *         │    ├─ main
+     *         │    │   ├─ module-info.java
+     *         │    │   └─ org/apache/sis/referencing/…
+     *         │    └─ test
+     *         │        └─ org/apache/sis/referencing/…
+     *         └─ etc.</pre>
+     *
+     * @param  project        the Gradle project being compiled.
+     * @param  isCompileTest  whether to specify the main sources or the test sources.
+     * @return value of the {@code --module-source-path} option.
+     */
+    static String getModuleSourcePath(final Project project, final boolean isCompileTest) {
+        File path;
+        path = project.file(SOURCE_DIRECTORY);
+        path = new File(path, "*");        // Handled in a special way by `--module-source-path`.
+        path = new File(path, isCompileTest ? TEST_DIRECTORY : MAIN_DIRECTORY);
+        return path.getPath();
+    }
+
+    /**
+     * Invoked when the {@code compileJava} task is executed.
+     *
+     * @param  context  the extension which is invoking this task.
+     * @param  task     the {@link JavaCompile} task to configure.
+     */
+    static void modularize(final BuildHelper context, final JavaCompile task) {
+        final boolean  isCompileTest = isCompileTest(task);
+        final Project        project = task.getProject();
+        final CompileOptions options = task.getOptions();
+        options.setEncoding(ENCODING);      // The character encoding to be used when reading source files.
+        options.setDeprecation(true);       // Whether to log details of usage of deprecated members or classes.
+
+        final List<String> args = options.getCompilerArgs();
+        args.add("--module-source-path");
+        args.add(getModuleSourcePath(project, isCompileTest));
+        /*
+         * WORKAROUND FOR GRADLE 8.1 PROBLEM:
+         * The class-path and the module-path should be mutually exclusive set of path elements.
+         * In theory Gradle should decide itself which dependencies to put on the module path,
+         * since this property is true by default:
+         *
+         *     modularity.inferModulePath
+         *
+         * However it doesn't seem to work with Gradle 8.1.1 because Gradle does not recognize
+         * the Module Source Hierarchy. Instead of separating ourselves the path elements, an
+         * easier workaround is to put everything on the module path. Javac seems to manage.
+         *
+         * This code replace the following code in `build.gradle.kts` file:
+         *
+         *     options.compilerArgs.add("--module-path=${classpath.asPath}")
+         *     setClasspath(files())       // For avoiding duplication with the --module-path option.
+         */
+        args.add("--module-path");
+        args.add(task.getClasspath().getAsPath());
+        task.setClasspath(project.files());          // For avoiding duplication with the --module-path option.
+        /*
+         * Declare explicitly all modules to take in account.
+         * It avoid problems when recompiling only a few classes.
+         */
+        args.add("--add-modules");
+        args.add(String.join(",", context.getModuleNames()));
+        /*
+         * If compiling tests, declare that we are patching modules.
+         */
+        if (isCompileTest) {
+            context.getPatchedDirectories().forEach((entry) -> {
+                args.add("--patch-module");
+                args.add(entry.getKey() + '=' + entry.getValue());
+            });
+        }
+    }
+
+    /*
+     * ┌───────────────────────────────────────────────────────────────────┐
+     * │  Code below this point are for handling international resources.  │
+     * │  This is a non-standard feature specific to Apache SIS project.   │
+     * │  For modularisation of arbitrary projects, ignore the remaining.  │
+     * └───────────────────────────────────────────────────────────────────┘
+     */
+
+    /**
+     * Modules to exclude. This is a temporary workaround for a SIS module
+     * not yet well integrated with other SIS modules.
+     */
+    private static final Set<String> EXCLUDE_MODULES = Set.of("org.apache.sis.storage.shapefile");
+
+    /**
+     * File extensions of resources to not copy.
+     *
+     * @todo Reduce this list to IDL and Markdown by converting HTML and text files.
+     */
+    private static final Set<String> EXCLUDE_RESOURCES = Set.of(
+            "tmp", "bak", "log",    // Same extensions than in `.gitignore`.
+            "idl",                  // Source code for OpenOffice add-in.
+            "md",                   // Notes in Markdown format.
+            "html",                 // Richer alternative to Markdown.
+            "txt");                 // Simpler alternative to Markdown.
+
+    /**
+     * Compiles the international resources that are found in all modules.
+     * If the {@code *.properties} file is not a resource to compile,
+     * then the file will be linked or copied instead of being compiled.
+     *
+     * @param  context  the extension which is invoking this task.
+     * @param  task     the compilation task which will be executed after this method.
+     */
+    final void compileOrcopyResources(final BuildHelper context, final Task task) {
+        final boolean isCompileTest = isCompileTest(task);
+        pendingResources.clear();
+        /*
+         * There is probably a cleaner way to get the source directories than above hack.
+         * For now we use hard-coded paths for main and test classes.
+         */
+        int errors = 0;
+        @SuppressWarnings("LocalVariableHidesMemberVariable")       // Should be the same value.
+        final Project project = task.getProject();
+        final File outputDir = new File(project.getBuildDir(),
+                isCompileTest ? TEST_CLASSES_DIRECTORY
+                              : MAIN_CLASSES_DIRECTORY);
+        final var results = new HashMap<File,Object>();
+        for (final File sourceDirectory : context.getSourceDirectories(isCompileTest)) {
+            final var buildDirectory = new File(outputDir, sourceDirectory.getParentFile().getName());
+            final var compiler = new IndexedResourceCompiler(sourceDirectory, buildDirectory, results) {
+                @Override protected void info   (String message) {project.getLogger().info(message);}
+                @Override protected void warning(String message) {project.getLogger().warn(message);}
+                @Override protected void otherResource(File source, File target) throws IOException {
+                    if (include(source)) {
+                        results.put(target, source);
+                    }
+                }
+                @Override protected boolean acceptCompilableResource(File source) {
+                    while (source != null) {
+                        if (EXCLUDE_MODULES.contains(source.getName())) return false;
+                        source = source.getParentFile();
+                    }
+                    return true;
+                }
+            };
+            try {
+                errors += compiler.onJavaSourceDirectory();
+            } catch (IOException e) {
+                throw new UncheckedIOException(e);
+            }
+        }
+        if (errors != 0) {
+            throw new SISBuildException(String.valueOf(errors) + " errors in resources bundles.");
+        }
+        pendingResources.add(results);
+    }
+
+    /**
+     * Writes now the resources which were found by {@code #compileOrcopyResources(…)}.
+     * This operation should be done last, after compilation, because otherwise Gradle
+     * may delete the resources as part of a clean operation.
+     */
+    final void flushResources() {
+        Map<File,Object> resources;
+        while ((resources = pendingResources.poll()) != null) {
+            for (final Map.Entry<File,Object> entry : resources.entrySet()) try {
+                final File target = entry.getKey();
+                final Object data = entry.getValue();
+                if (data instanceof byte[]) {
+                    Files.write(target.toPath(), (byte[]) data);
+                } else {
+                    final File source = (File) data;
+                    linkOrCopy(source.toPath(), target.toPath());
+                }
+            } catch (IOException e) {
+                throw new UncheckedIOException(e);
+            }
+        }
+    }
+
+    /**
+     * Whether to copy the specified resource.
+     *
+     * @param  resource  the resource file to potentially copy.
+     * @return {@code true} for copying or {@code false} for ignoring.
+     */
+    private static boolean include(File resource) {
+        String ext = resource.getName();
+        ext = ext.substring(ext.lastIndexOf('.') + 1);
+        if (EXCLUDE_RESOURCES.contains(ext)) {
+            if (ext.equals("txt")) {
+                /*
+                 * Special case: include text file for tests.
+                 * TODO: remove that special case. See EXCLUDE_RESOURCES comment.
+                 */
+                while ((resource = resource.getParentFile()) != null) {
+                    if (resource.getName().equals(TEST_DIRECTORY)) {
+                        return true;
+                    }
+                }
+            }
+            return false;
+        }
+        return true;
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularJAR.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularJAR.java
new file mode 100644
index 0000000000..99802d3caa
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularJAR.java
@@ -0,0 +1,176 @@
+/*
+ * 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.buildtools.gradle;
+
+import java.util.Map;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.jar.Manifest;
+import java.util.jar.Attributes;
+import java.util.jar.JarOutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import org.gradle.api.Project;
+import org.gradle.api.tasks.bundling.Jar;
+
+
+/**
+ * Extension to Gradle {@link Jar} task. This extension generates one JAR file per module.
+ * If the {@code build.gradle} file specifies a {@code Main-Class} attribute, the latter
+ * can apply to only one module, which is identified in this class as the "main module".
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+final class ModularJAR extends ZipWriter.JDK {
+    /**
+     * Version of the GeoAPI dependency.
+     * This value depends on the Apache SIS branch being compiled.
+     *
+     * @todo should be specified in a property.
+     */
+    private static final String GEOAPI_VERSION = "4.0-SNAPSHOT";
+
+    /**
+     * Attributes where values are class names. Those attributes can be assigned
+     * to only one module, because the same class cannot appear in two modules.
+     */
+    private static final String[] CLASSNAME_ATTRIBUTES = {
+        Attributes.Name.MAIN_CLASS.toString(),
+        "RegistrationClassName"
+    };
+
+    /**
+     * Creates a helper instance.
+     *
+     * @param  project  the sub-project being compiled.
+     * @param  out      output stream of the JAR file to create.
+     */
+    private ModularJAR(final Project project, final JarOutputStream out) {
+        super(project, out);
+    }
+
+    /**
+     * Lists all compiled modules in the current Gradle sub-project.
+     *
+     * @return all modules in current Gradle sub-project.
+     */
+    private static File[] listModules(final Project project) {
+        return new File(project.getBuildDir(), MAIN_CLASSES_DIRECTORY).listFiles(File::isDirectory);
+    }
+
+    /**
+     * Invoked when the {@code jar} task is executed.
+     *
+     * @param  context  the extension which is invoking this task.
+     * @param  task     the {@link Javadoc} task to configure.
+     */
+    static void execute(final BuildHelper context, final Jar task) {
+        final Project       project    = task.getProject();
+        final Map<String,?> attributes = task.getManifest().getAttributes();
+        final var   filteredAttributes = new HashMap<String,String>();
+        final var  classnameAttributes = new HashMap<String,String>();
+        for (final String classname : CLASSNAME_ATTRIBUTES) {
+            final Object value = attributes.get(classname);
+            if (value instanceof String) {
+                final String path = ((String) value).replace('.', File.separatorChar) + ".class";
+                classnameAttributes.put(classname, path);
+            }
+        }
+        final File target = new File(project.getBuildDir(), LIBS_DIRECTORY);
+        for (final File module : listModules(project)) {
+            for (final Iterator<Map.Entry<String,String>> it = classnameAttributes.entrySet().iterator(); it.hasNext();) {
+                final Map.Entry<String,String> entry = it.next();
+                if (new File(module, entry.getValue()).isFile()) {
+                    final String key = entry.getKey();
+                    filteredAttributes.put(key, (String) attributes.get(key));
+                    it.remove();
+                }
+            }
+            try {
+                write(project, module, new File(target, module.getName() + ".jar"), filteredAttributes);
+            } catch (IOException e) {
+                throw new UncheckedIOException(e);
+            }
+            filteredAttributes.clear();
+        }
+        for (final Map.Entry<String,String> entry : classnameAttributes.entrySet()) {
+            task.getLogger().warn(entry.getKey() + " not found: " + entry.getValue());
+        }
+        try {
+            linkDependencies(project, target);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        } catch (UnsupportedOperationException e) {
+            project.getLogger().warn("Links to dependencies not provided because hard-links are not supported on this platform.", e);
+        }
+    }
+
+    /**
+     * Writes a JAR file for a single module.
+     *
+     * @param  project   the project for which to write a JAR file.
+     * @param  source    root directory of compiled class files for the module to package.
+     * @param  target    the file to write.
+     * @param  specific  attributes specific to this module.
+     * @throws IOException if an error occurred while reading the source or writing the JAR file.
+     */
+    private static void write(final Project project, final File source, final File target,
+                              final Map<String,String> specific) throws IOException
+    {
+        final var mf = new Manifest();
+        final Attributes attributes = mf.getMainAttributes();
+        attributes.put(Attributes.Name.MANIFEST_VERSION,       "1.0");
+        attributes.put(Attributes.Name.SPECIFICATION_TITLE,    "GeoAPI");
+        attributes.put(Attributes.Name.SPECIFICATION_VENDOR,   "Open Geospatial Consortium");
+        attributes.put(Attributes.Name.SPECIFICATION_VERSION,   GEOAPI_VERSION);
+        attributes.put(Attributes.Name.IMPLEMENTATION_TITLE,   "Apache Spatial Information System (SIS)");
+        attributes.put(Attributes.Name.IMPLEMENTATION_VENDOR,  "The Apache Software Foundation");
+        attributes.put(Attributes.Name.IMPLEMENTATION_VERSION,  project.getVersion().toString());
+        for (final Map.Entry<String,String> entry : specific.entrySet()) {
+            attributes.put(new Attributes.Name(entry.getKey()), entry.getValue());
+        }
+        try (JarOutputStream out = new JarOutputStream(new BufferedOutputStream(new FileOutputStream(target)), mf)) {
+            new ModularJAR(project, out).writeDirectory(source, null, "");
+        }
+    }
+
+    /**
+     * Adds hard-links to all dependencies.
+     *
+     * @param  project  the project for which to add dependencies.
+     * @param  target   where to write dependencies.
+     * @throws IOException if an I/O error occurred while reading the module description.
+     * @throws UnsupportedOperationException if links are not supported on this platform.
+     */
+    private static void linkDependencies(final Project project, final File target) throws IOException {
+        for (final Dependency dep : Dependency.find(project)) {
+            if (dep.module != null) {
+                final Path f = new File(target, dep.module + ".jar").toPath();
+                if (Files.notExists(f)) {
+                    Files.createLink(f, dep.file.toPath());
+                }
+            }
+        }
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularJavadoc.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularJavadoc.java
new file mode 100644
index 0000000000..c9371d71a8
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularJavadoc.java
@@ -0,0 +1,200 @@
+/*
+ * 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.buildtools.gradle;
+
+import java.util.List;
+import java.util.Arrays;
+import java.nio.file.Path;
+import java.nio.file.Files;
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import org.gradle.api.tasks.javadoc.Javadoc;
+import org.gradle.external.javadoc.StandardJavadocDocletOptions;
+
+
+/**
+ * Extension to Gradle {@link Javadoc} task.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+final class ModularJavadoc extends Conventions {
+    /**
+     * The javadoc task.
+     */
+    private final Javadoc task;
+
+    /**
+     * Creates a helper instance.
+     *
+     * @param  task the {@link Javadoc} task to configure.
+     */
+    ModularJavadoc(final Javadoc task) {
+        super(task.getProject());
+        this.task = task;
+    }
+
+    /**
+     * Invoked when the {@code javadoc} task is executed.
+     *
+     * @param  context  the extension which is invoking this task.
+     */
+    final void modularize(final BuildHelper context) {
+        final var options = (StandardJavadocDocletOptions) task.getOptions();
+        options.encoding     (ENCODING);        // Encoding of Java source file.
+        options.docEncoding  (ENCODING);        // Encoding of the generated HTML files.
+        options.charSet      (ENCODING);        // Encoding to declare in the HTML META tag.
+        options.locale       ("en");            // Locale for navigation bar, help file contents, etc.
+        options.breakIterator(true);            // Better boundary detection when determining the end of the first sentence.
+        options.noQualifiers ("all");           // Omit qualifying package name before class names in output.
+        options.author       (false);           // Excludes the authors text in the generated docs.
+        options.version      (false);           // Excludes the version text in the generated docs.
+        options.keyWords     (true);            // Adds HTML meta keyword tags to the generated files.
+        options.noTimestamp.setValue(false);    // Allow timestamp in generated HTML pages.
+        options.tags("category:X:\"Category:\"",
+                     "todo:a:\"TODO:\"");
+        options.links(
+                "https://docs.oracle.com/en/java/javase/11/docs/api",
+                "http://www.geoapi.org/snapshot/javadoc",
+                "http://unitsofmeasurement.github.io/unit-api/site/apidocs");
+        /*
+         * Taglet defined in this `buildSrc` sub-project.
+         */
+        final File buildSrc = fileRelativeToRoot(BUILD_TOOLS_DIRECTORY);
+        final File bClasses = new File(new File(buildSrc, BUILD_DIRECTORY), MAIN_CLASSES_DIRECTORY);
+        options.docletpath(bClasses);
+        options.tagletPath(bClasses);
+        options.setDoclet("org.apache.sis.buildtools.doclet.Doclet");
+        options.taglets("org.apache.sis.buildtools.doclet.Include");
+        /*
+         * Put only one '-' in given options because Gradles add a '-' itself.
+         */
+        options.addFileOption  ("-add-stylesheet", new File(new File(project.getRootDir(), "parent"), "sis.css"));
+        options.addStringOption("-module-source-path", ModularCompilation.getModuleSourcePath(project, false));
+        options.addStringOption("-add-modules", String.join(",", context.getModuleNames()));
+        /*
+         * Same workaround than for the compiler task.
+         */
+        options.modulePath(options.getClasspath());
+        options.setClasspath(List.of());
+        /*
+         * In current version, Javadoc generation fails because some dependencies are incorrectly
+         * put in class-path option instead of module-path option. The above workaround does not work
+         * because some class-path entries are added by Gradle only after this configuration phase.
+         * The reason for this behavior is unknown and we found no way to resolve this problem yet.
+         * For now, we create a patched `javadoc.options` file that user can execute from the command line.
+         * The new file is created using the file from the previous run (not this run).
+         */
+        try {
+            patchOptionsFile(task.getOptionsFile().toPath());
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+        project.getLogger().info(String.format(
+                "The `javadoc` task is known to not work in this configuration (tested with Gradle 8.2.1).%n" +
+                "Resolving this issue may require changes in the way that Gradle handles the module path.%n" +
+                "In the meantime, Javadoc can be generated with the following command line:%n" +
+                "%n" +
+                "    javadoc @%s%sbuild%<stmp%<sjavadoc%<sjavadoc.options.patched%n" +
+                "%n" +
+                "It may be necessary to run the `javadoc` task again for generating the patched file.%n" +
+                "%n", project.getProjectDir().getName(), File.separator));
+    }
+
+    /**
+     * Creates a copy of the given file with patched {@code --module-path} option.
+     * User can execute with the following command line:
+     *
+     * {@snippet lang="shell" :
+     *   javadoc @build/tmp/javadoc/javadoc.options.patched
+     *   }
+     *
+     * @param  file  the file to patch.
+     * @throws IOException if an error occurred while reading or writing the file.
+     */
+    private static void patchOptionsFile(final Path file) throws IOException {
+        if (!Files.isRegularFile(file)) {
+            return;
+        }
+        final String searchFor = "-classpath ";         // The option to search.
+        final String moveTo    = "--module-path ";      // The option where to move the classpath.
+
+        final List<String>  lines      = Files.readAllLines(file);
+        final String[]      rewrite    = new String[lines.size()];
+        final StringBuilder modulePath = new StringBuilder(16*1024).append(moveTo);
+        int     lineOfModulePath = -1;
+        int     currentLineIndex =  0;
+        boolean foundClassPath   = false;
+        boolean foundSomePath    = false;
+        for (final String line : lines) {
+            if (line.startsWith(searchFor)) {
+                appendWithoutQuotes(line, searchFor.length(), foundSomePath, modulePath);
+                foundClassPath = true;
+                foundSomePath  = true;
+            } else {
+                if (line.startsWith(moveTo)) {
+                    appendWithoutQuotes(line, moveTo.length(), foundSomePath, modulePath);
+                    foundSomePath = true;
+                    if (lineOfModulePath >= 0) continue;
+                    lineOfModulePath = currentLineIndex;
+                }
+                rewrite[currentLineIndex++] = line;
+            }
+        }
+        if (foundClassPath && lineOfModulePath >= 0) {
+            rewrite[lineOfModulePath] = modulePath.toString();
+            Files.write(file.getParent().resolve(file.getFileName().toString() + ".patched"),
+                        Arrays.asList(rewrite).subList(0, currentLineIndex));
+        }
+    }
+
+    /**
+     * Appends the given value to the string builder without the quotes around the values.
+     * This is used for merging many paths (which may be quoted) in a single unquoted path.
+     *
+     * @param  value      the value to unquote and append to the string builder.
+     * @param  start      first character to examine in the given value.
+     * @param  separator  whether to prepend a path separator.
+     * @param  appendTo   where to append the unquoted value.
+     */
+    private static void appendWithoutQuotes(final String value, int start, final boolean separator, final StringBuilder appendTo) {
+        int end = value.length();
+        while (end > 0) {
+            final int c = value.codePointBefore(end);
+            if (!Character.isWhitespace(c)) break;
+            end -= Character.charCount(c);
+        }
+        while (start < end) {
+            final int c = value.codePointAt(start);
+            if (!Character.isWhitespace(c)) break;
+            start += Character.charCount(c);
+        }
+        final int last = end - 1;
+        if (start < end && value.charAt(start) == '\'' && value.charAt(last) == '\'') {
+            start++;
+            end = last;
+        }
+        if (start < end) {
+            if (separator) {
+                appendTo.append(File.pathSeparatorChar);
+            }
+            appendTo.append(value, start, end);
+        }
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularPublishing.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularPublishing.java
new file mode 100644
index 0000000000..6be4bdcdd6
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularPublishing.java
@@ -0,0 +1,79 @@
+/*
+ * 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.buildtools.gradle;
+
+import java.util.Map;
+import java.util.HashMap;
+import org.gradle.api.Project;
+import org.gradle.api.publish.maven.MavenPom;
+import org.gradle.api.publish.maven.MavenPublication;
+import org.gradle.api.publish.PublishingExtension;
+import org.apache.sis.buildtools.maven.Generator;
+
+
+/**
+ * Extension to Gradle Maven publishing tasks.
+ * The publication name must be the JPMS module name without the {@code org.apache.sis} prefix.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+final class ModularPublishing {
+    /**
+     * The project to publish.
+     */
+    private final Project project;
+
+    /**
+     * The publications to handle as dependencies for other publications.
+     * Keys are JPMS module names.
+     *
+     * @todo It should not be a static field. But I didn't found another way
+     *       to get the compilation results of other sub-projects.
+     */
+    private static final Map<String,Dependency> publications = new HashMap<>();
+
+    /**
+     * Creates a helper instance.
+     *
+     * @param  project  the project to publish.
+     */
+    ModularPublishing(final Project project) {
+        this.project = project;
+    }
+
+    /**
+     * Gets the list of publications and register code for rewriting the {@code pom.xml} files.
+     */
+    final void configure() {
+        final PublishingExtension ext = project.getExtensions().findByType(PublishingExtension.class);
+        if (ext != null) {
+            ext.getPublications().configureEach((pub) -> {
+                if (pub instanceof MavenPublication) {
+                    final var mp  = (MavenPublication) pub;
+                    final var dep = new Dependency(mp);
+                    final MavenPom pom = mp.getPom();
+                    synchronized (publications) {
+                        publications.put(dep.module, dep);
+                        pom.withXml((xml) -> Generator.rewrite(project, publications.values(), mp, xml));
+                    }
+                }
+            });
+        }
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularTest.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularTest.java
new file mode 100644
index 0000000000..18f646c9f8
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ModularTest.java
@@ -0,0 +1,260 @@
+/*
+ * 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.buildtools.gradle;
+
+import java.util.Map;
+import java.util.List;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.lang.module.ModuleDescriptor;
+import org.gradle.api.tasks.testing.Test;
+import org.gradle.api.Project;
+
+
+/**
+ * Extension to Gradle {@link Test} task.
+ * Contrarily to other {@code Modular*} classes in this package,
+ * this extension replaces completely the Gradle's default task.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+final class ModularTest extends Conventions {
+    /**
+     * Sentinel value for packages that are exported to all.
+     */
+    private static final String EXPORTED = "#exported";
+
+    /**
+     * Root directories of compiled test class for each module. Keys are module names and values are directories.
+     * The name of the directory, as returned by {@link File#getName()}, should be the module name (i.e. the key).
+     */
+    private final Map<String,File> moduleDirectories;
+
+    /**
+     * All packages together with whether the package is visible to all.
+     * If the package is exported, then the associated value is {@link #EXPORTED}.
+     * Otherwise the associated value is the name of the module that contains the package.
+     * This information is needed for building an {@code --add-exports} JVM option.
+     */
+    private final Map<String,String> packageVisibility;
+
+    /**
+     * Packages containing at least one test classes.
+     */
+    private final List<String> testedPackages;
+
+    /**
+     * A buffer for building the package name as we iterate over the directory structure.
+     */
+    private final StringBuilder packageName;
+
+    /**
+     * The main and test directory with leading and trailing platform-specific name separator.
+     */
+    private final String mainDirectory, testDirectory;
+
+    /**
+     * Creates an object for holding temporary data.
+     *
+     * @param  project  the sub-project being tested.
+     */
+    private ModularTest(final Project project) {
+        super(project);
+        moduleDirectories = new LinkedHashMap<>(32);
+        packageVisibility = new LinkedHashMap<>(128);
+        testedPackages    = new ArrayList<>(64);
+        packageName       = new StringBuilder(64);
+        mainDirectory     = File.separator + MAIN_DIRECTORY + File.separator;
+        testDirectory     = File.separator + TEST_DIRECTORY + File.separator;
+    }
+
+    /**
+     * Replaces a path from test directory to a path to main directory.
+     * This method assumes that the "test" name to replace is the last path element.
+     * It should not be used if the path can contain arbitrary directories after "test".
+     *
+     * @param  file  a path to a file in the test directory.
+     * @return path to the same file, but in the main directory.
+     */
+    private File testToMain(File file) {
+        final StringBuilder path = new StringBuilder(file.getPath());
+        final int s = path.lastIndexOf(testDirectory);
+        if (s >= 0) {
+            file = new File(path.replace(s, s + testDirectory.length(), mainDirectory).toString());
+        }
+        return file;
+    }
+
+    /**
+     * Given a directory of modules, builds the list of {@code --add-exports} JVM arguments.
+     *
+     * @param  directory  output directory of test class. Sub-directories shall be module names.
+     * @return directories of test classes.
+     */
+    private void scanAllModules(final File directory) {
+        final File[] moduleCandidates = directory.listFiles(File::isDirectory);
+        if (moduleCandidates != null) try {
+            for (final File moduleRoot : moduleCandidates) {
+                final String moduleName = readModuleInfo(moduleRoot);
+                if (moduleName != null) {
+                    final File existing = moduleDirectories.put(moduleName, moduleRoot);
+                    if (existing != null) {
+                        throw new SISBuildException("Duplicated module name: " + moduleName +
+                                "\n    Found in: " + existing +
+                                "\n     Also in: " + moduleRoot);
+                    }
+                    for (final File headPackage : moduleRoot.listFiles(File::isDirectory)) {
+                        collectTestedPackages(moduleName, headPackage);
+                    }
+                }
+            }
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    /**
+     * Reads the export declarations of the given {@code module-info.class} file.
+     * Packages that <em>may</em> need to be specified in an {@code --add-exports}
+     * JVM argument are added to the {@link #packageVisibility} map.
+     *
+     * @param  moduleRoot  the module directory or compiled test class.
+     * @return module name, or {@code null} if {@code "module-info.class"} has not been found.
+     * @throws IOException if an error occurred while reading the file.
+     */
+    private String readModuleInfo(final File moduleRoot) throws IOException {
+        final File file = testToMain(new File(moduleRoot, "module-info.class"));
+        if (!file.isFile()) {
+            return null;
+        }
+        try (FileInputStream in = new FileInputStream(file)) {
+            final ModuleDescriptor module = ModuleDescriptor.read(in);
+            final String name = module.name();
+            module.packages().forEach((p) -> packageVisibility.put(p, name));
+            module.exports().forEach((descriptor) -> {
+                if (!descriptor.isQualified()) {
+                    packageVisibility.put(descriptor.source(), EXPORTED);
+                }
+            });
+            return name;
+        }
+    }
+
+    /**
+     * Collects a list of tested packages, regardless is exported or not.
+     * The package names are added to the {@link #testedPackages} list.
+     *
+     * @param  moduleName  name of the module being scanned.
+     * @param  file        directory or file to inspect.
+     * @return whether the given file was presumably a test class.
+     */
+    private boolean collectTestedPackages(final String moduleName, final File file) {
+        final File[] content = file.listFiles();
+        if (content == null) {
+            return file.getName().endsWith(".class");
+        }
+        final int oldLength = packageName.length();
+        packageName.append(file.getName()).append('.');
+        boolean hasTests = false;
+        for (final File c : content) {
+            hasTests |= collectTestedPackages(moduleName, c);
+        }
+        if (hasTests) {
+            final String name = packageName.substring(0, packageName.length() - 1);
+            packageVisibility.putIfAbsent(name, moduleName);
+            testedPackages.add(name);
+        }
+        packageName.setLength(oldLength);
+        return false;
+    }
+
+    /**
+     * Returns the {@code --add-module}, {@code --add-reads} and {@code --add-exports} statements
+     * to specify as JVM arguments. The export statements may be numerous, so this method tries to
+     * add only those that are necessary.
+     *
+     * @return the list of arguments to add to the JVM.
+     */
+    private List<String> moduleOptions() {
+        final var args = new ArrayList<String>(2 + 4*moduleDirectories.size() + 2*testedPackages.size());
+        args.add("--add-modules");
+        args.add(String.join(",", moduleDirectories.keySet()));
+        /*
+         * --patch-module ${module}=${buildDir}/classes/java/test/${it}
+         * --add-reads    ${module}=junit,ALL-UNNAMED
+         */
+        moduleDirectories.entrySet().forEach((entry) -> {
+            final String module = entry.getKey();
+            args.add("--patch-module"); args.add(module + '=' + entry.getValue());
+            args.add("--add-reads");    args.add(module + "=junit,ALL-UNNAMED");
+        });
+        /*
+         * --add-exports ${module}/${package}=junit
+         */
+        for (final String name : testedPackages) {
+            String module = packageVisibility.remove(name);
+            if (module != null && !EXPORTED.equals(module)) {
+                args.add("--add-exports");
+                args.add(module + '/' + name + "=junit");
+            }
+        }
+        return args;
+    }
+
+    /**
+     * Adds modularization arguments and exports to JUnit all non-exported packages.
+     *
+     * @param  tasks  the task where to add JVM arguments.
+     */
+    static void modularizeAndExportToJunit(final Test task) {
+        /*
+         * Create a module path which excludes the compiled test classes, because those
+         * classes will be specified using another JVM option (namely `--patch-module`).
+         */
+        var mainpath = task.getClasspath().filter((path) -> !path.getName().equals("test"));
+        task.jvmArgs(List.of("--module-path", mainpath.getAsPath()));
+        /*
+         * JPMS options.
+         */
+        final var t = new ModularTest(task.getProject());
+        task.getTestClassesDirs().forEach((dir) -> {
+            t.scanAllModules(dir);
+        });
+        task.jvmArgs(t.moduleOptions());
+        task.setTestClassesDirs(task.getProject().files(t.moduleDirectories.values().toArray()));
+        /*
+         * Custom options specific to Apache SIS.
+         * By default, the working directory is the sub-project root.
+         * We move the working directory to a sub-directory of `build`.
+         * It will be the location of the `derby.log` file for instance.
+         */
+        task.useJUnitPlatform();
+        task.include("**/*Test.class");
+        task.setScanForTestClasses(false);
+
+        final File workingDir = t.fileRelativeToBuild("test-output");
+        workingDir.mkdirs();
+        task.setWorkingDir(workingDir);
+        task.getEnvironment().putIfAbsent("SIS_DATA", workingDir.getPath());
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/util/resources/ResourceCompilerException.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/SISBuildException.java
similarity index 58%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/util/resources/ResourceCompilerException.java
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/SISBuildException.java
index 68c99b1045..26aa89b37c 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/util/resources/ResourceCompilerException.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/SISBuildException.java
@@ -14,35 +14,24 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.sis.util.resources;
-
-import org.apache.maven.plugin.MojoExecutionException;
+package org.apache.sis.buildtools.gradle;
 
 
 /**
- * Thrown when the {@link ResourceCompilerMojo} exit abnormally.
+ * Thrown when an error occurred with the Apache SIS build helper
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.3
- * @since   0.3
+ * @version 1.4
+ * @since   1.4
  */
 @SuppressWarnings("serial")
-final class ResourceCompilerException extends MojoExecutionException {
+public class SISBuildException extends RuntimeException {
     /**
-     * Creates an exception with the given detail message.
+     * Creates a new exception with the given message.
      *
-     * @param message  the detail message.
+     * @param  message  description of the error.
      */
-    ResourceCompilerException(final String message) {
+    public SISBuildException(final String message) {
         super(message);
     }
-
-    /**
-     * Creates an exception with the given cause.
-     *
-     * @param cause  the cause of this exception.
-     */
-    ResourceCompilerException(final Throwable cause) {
-        super(cause.getLocalizedMessage(), cause);
-    }
 }
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/UnoPkg.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/UnoPkg.java
new file mode 100644
index 0000000000..dafe0e3115
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/UnoPkg.java
@@ -0,0 +1,215 @@
+/*
+ * 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.buildtools.gradle;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.OutputStream;
+import java.io.BufferedReader;
+import java.io.BufferedOutputStream;
+import java.io.InputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Enumeration;
+import java.util.Set;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import java.util.zip.ZipOutputStream;
+import org.gradle.api.Task;
+import org.gradle.api.Project;
+
+
+/**
+ * Creates an {@code .oxt} package for <a href="http://www.openoffice.org">OpenOffice.org</a> addins.
+ * Those files are basically ZIP files with {@code META-INF/manifest.xml} and {@code .rdb} entries.
+ * The principle is similar to Java JAR files.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @author  Quentin Bialota (Geomatys)
+ * @version 1.4
+ * @since   0.8
+ */
+final class UnoPkg extends ZipWriter.JDK {
+    /**
+     * The module on which to apply this task.
+     */
+    static final String MODULE = "org.apache.sis.openoffice";
+
+    /**
+     * Module to decompress. We inflate the {@value} module because Derby is much slower
+     * when using an embedded database in a compressed ZIP file compared to flat storage.
+     * Since the JAR files are distributed in a ZIP file anyway, inflating that file has
+     * little impact on the final ZIP file size.
+     */
+    private static final String TO_INFLATE = "org.apache.sis.referencing.database";
+
+    /**
+     * Hard-coded set of libraries to include.
+     *
+     * @see #accept(File, String)
+     */
+    private static final Set<String> LIBRARIES = Set.of(
+            "java.measure",
+            "org.opengis.geoapi",
+            "org.opengis.geoapi.pending",
+            "org.apache.sis.util",
+            "org.apache.sis.metadata",
+            "org.apache.sis.referencing",
+            "org.apache.sis.feature",
+            "org.apache.sis.storage",
+            "org.apache.sis.openoffice",
+            "jakarta.activation",
+            "jakarta.xml.bind",
+            TO_INFLATE);
+
+    /**
+     * The string to replace by the final name.
+     */
+    private static final String FILTERED_NAME = "${project.build.finalName}";
+
+    /**
+     * The string to replace by the version number.
+     */
+    private static final String FILTERED_VERSION = "${project.version}";
+
+    /**
+     * Creates an helper object for creating the assembly.
+     *
+     * @param  project  the project for which to create an assembly.
+     * @param  out      output stream of the ZIP file to create.
+     */
+    private UnoPkg(final Project project, final ZipOutputStream out) {
+        super(project, out);
+    }
+
+    /**
+     * Generates the {@code .oxt} file from a selected amount if {@code .jar} files.
+     *
+     * @param  task  the assembler task (in Gradle 8.2.1, it appears to be an opaque decorator that we cannot cast).
+     */
+    static void create(final Task task) {
+        final Project project = task.getProject();
+        final File sourceDirectory = getBundleSourceDirectory(project, MODULE);
+        final File libsDirectory = new File(project.getBuildDir(), LIBS_DIRECTORY);
+        if (sourceDirectory.isDirectory() && libsDirectory.isDirectory()) try {
+            File target = new File(project.getBuildDir(), BUNDLE_DIRECTORY);
+            mkdir(target);
+            target = new File(target, FINALNAME_PREFIX + project.getVersion() + ".oxt");
+            try (ZipOutputStream out = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(target)))) {
+                final var c = new UnoPkg(project, out);
+                c.writeDirectory(sourceDirectory, UnoPkg::filterSource, "");
+                c.writeDirectory(libsDirectory, UnoPkg::filterDependency, "");
+            }
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    /**
+     * Tests if a specified file from the source directory should be included in the bundle.
+     *
+     * @param  file  the file to test.
+     * @return whether the file should be included.
+     */
+    private static boolean filterSource(final File file) {
+        return !file.getName().equals("org");
+    }
+
+    /**
+     * Tests if a specified file from the libraries directory should be included in the bundle.
+     *
+     * @param  file  the file to test.
+     * @return whether the file should be included.
+     */
+    private static boolean filterDependency(final File file) {
+        final String name = file.getName();
+        return name.endsWith(".jar") && LIBRARIES.contains(name.substring(0, name.length() - 4));
+    }
+
+    /**
+     * Writes the content of the specified file to the output stream of a ZIP entry.
+     */
+    @Override
+    protected void writeEntryContent(final File source, final OutputStream out) throws IOException {
+        final String filename = source.getName();
+        if (filename.endsWith(".xml")) {
+            copyFiltered(source, out);
+        } else if (filename.equals(TO_INFLATE + ".jar")) {
+            copyInflated(source, out);
+        } else {
+            super.writeEntryContent(source, out);
+        }
+    }
+
+    /**
+     * Copies the content of the specified ASCII file to the output stream.
+     * Occurrences of {@value #FILTERED_NAME} and {@value #FILTERED_VERSION}
+     * are replaced by actual values.
+     *
+     * @param  file    the regular file to copy inside the ZIP file.
+     * @param  name    the ZIP entry name.
+     */
+    private void copyFiltered(final File source, final OutputStream out) throws IOException {
+        final String version   = project.getVersion().toString();
+        final String finalName = FINALNAME_PREFIX + version;
+        final var    writer    = new OutputStreamWriter(out, ENCODING);
+        try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream(source), ENCODING))) {
+            String line;
+            while ((line = in.readLine()) != null) {
+                line = line.replace(FILTERED_NAME, finalName);
+                line = line.replace(FILTERED_VERSION, version);
+                writer.write(line);
+                writer.write('\n');
+            }
+        }
+        writer.flush();
+    }
+
+    /**
+     * Copies a JAR file in the ZIP file, but without compression.
+     * This is recommended for files in the {@code SIS_DATA} directory.
+     *
+     * @param  file  the JAR file to copy.
+     */
+    private void copyInflated(final File file, final OutputStream out) throws IOException {
+        final ZipOutputStream jar = new ZipOutputStream(out);
+        try (ZipFile in = new ZipFile(file)) {
+            final Enumeration<? extends ZipEntry> entries = in.entries();
+            while (entries.hasMoreElements()) {
+                final ZipEntry   entry    = entries.nextElement();
+                final String     name     = entry.getName();
+                final ZipEntry   outEntry = new ZipEntry(name);
+                if (name.startsWith("SIS_DATA")) {
+                    final long size = entry.getSize();
+                    outEntry.setMethod(ZipOutputStream.STORED);
+                    outEntry.setSize(size);
+                    outEntry.setCompressedSize(size);
+                    outEntry.setCrc(entry.getCrc());
+                }
+                try (InputStream inStream = in.getInputStream(entry)) {
+                    jar.putNextEntry(outEntry);
+                    inStream.transferTo(jar);
+                    jar.closeEntry();
+                }
+            }
+        }
+        jar.finish();
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ZipWriter.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ZipWriter.java
new file mode 100644
index 0000000000..082ff25623
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/ZipWriter.java
@@ -0,0 +1,286 @@
+/*
+ * 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.buildtools.gradle;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.zip.Deflater;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.gradle.api.Project;
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
+
+
+/**
+ * Base class of tasks producing a ZIP or JAR file.
+ * One of {@link JDK} or {@link Apache} inner classed should be used.
+ * The method to invoke is {@link #writeDirectory(File, FileFilter, String)}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+abstract class ZipWriter extends Conventions {
+    /**
+     * Name of the entry in process of being written in the output stream.
+     * Path separator is standardized to {@code '/'} (not platform-specific).
+     */
+    private final StringBuilder path;
+
+    /**
+     * Buffer for transferring file contents to ZIP or JAR entries.
+     * Allocated only once and reused for all entries.
+     */
+    private final byte[] buffer;
+
+    /**
+     * Creates a helper instance.
+     *
+     * @param  project  the sub-project being compiled.
+     * @param  out      output stream of the ZIP or JAR file to create.
+     */
+    private ZipWriter(final Project project) {
+        super(project);
+        this.path   = new StringBuilder();
+        this.buffer = new byte[8192];
+    }
+
+    /**
+     * {@return the ZIP output stream}. This is either the JDK {@link ZipArchiveEntry}
+     * or the Apache Common Compress {@link ZipArchiveOutputStream} implementation.
+     */
+    abstract OutputStream out();
+
+    /**
+     * Begins writing a new ZIP file entry.
+     *
+     * @param  source  file which will be copied in the entry.
+     * @param  name    name of the entry to add.
+     * @throws IOException if an error occurred.
+     */
+    abstract void putNextEntry(File source, String name) throws IOException;
+
+    /**
+     * Closes the current entry.
+     *
+     * @throws IOException if an error occurred.
+     */
+    abstract void closeEntry() throws IOException;
+
+    /**
+     * An output stream backed by the JDK implementation.
+     */
+    static class JDK extends ZipWriter {
+        /**
+         * Output stream of the ZIP or JAR file to create.
+         */
+        protected final ZipOutputStream out;
+
+        /**
+         * Creates a new ZIP output stream.
+         *
+         * @param  project  the sub-project being compiled.
+         * @param  out      the stream where to write ZIP entries.
+         */
+        JDK(final Project project, final ZipOutputStream out) {
+            super(project);
+            this.out = out;
+            out.setLevel(Deflater.BEST_COMPRESSION);
+        }
+
+        /** {@return the ZIP output stream}. */
+        @Override final OutputStream out() {
+            return out;
+        }
+
+        /** Begins writing a new ZIP file entry. */
+        @Override final void putNextEntry(final File source, final String name) throws IOException {
+            final var entry = new ZipEntry(name);
+            completeEntry(source, entry);
+            out.putNextEntry(entry);
+        }
+
+        /** Closes the current entry. */
+        @Override final void closeEntry() throws IOException {
+            out.closeEntry();
+        }
+    }
+
+    /**
+     * An output stream backed by the Apache implementation.
+     * This implementation is used when we need the capability to set Unix execution flag on some files.
+     */
+    static class Apache extends ZipWriter {
+        /**
+         * Output stream of the ZIP or JAR file to create.
+         */
+        protected final ZipArchiveOutputStream out;
+
+        /**
+         * Creates a new ZIP output stream.
+         *
+         * @param  project  the sub-project being compiled.
+         * @param  out      the stream where to write ZIP entries.
+         */
+        Apache(final Project project, final ZipArchiveOutputStream out) {
+            super(project);
+            this.out = out;
+            out.setLevel(Deflater.BEST_COMPRESSION);
+        }
+
+        /** {@return the ZIP output stream}. */
+        @Override final OutputStream out() {
+            return out;
+        }
+
+        /** Begins writing a new ZIP file entry. */
+        @Override final void putNextEntry(final File source, final String name) throws IOException {
+            final var entry = new ZipArchiveEntry(name);
+            completeEntry(source, entry);
+            if (source.canExecute()) {
+                entry.setUnixMode(0744);
+            }
+            out.putArchiveEntry(entry);
+        }
+
+        /** Closes the current entry. */
+        @Override final void closeEntry() throws IOException {
+            out.closeArchiveEntry();
+        }
+    }
+
+    /**
+     * Configures the entry for the file to be copied.
+     *
+     * @param  source  the file which will be written in the entry.
+     * @param  entry   the entry to configure.
+     */
+    private static void completeEntry(final File source, final ZipEntry entry) {
+        if (entry.isDirectory()) {
+            entry.setMethod(ZipEntry.STORED);
+            entry.setCompressedSize(0);
+            entry.setSize(0);
+            entry.setCrc(0);
+        } else {
+            entry.setMethod(ZipEntry.DEFLATED);
+            entry.setSize(source.length());
+        }
+        entry.setTime(source.lastModified());
+    }
+
+    /**
+     * List all files in the given directory excluding hidden files.
+     * This method sorts the elements with regular files before directories,
+     * then case-sensitive alphabetical order with upper-cases before lower cases.
+     *
+     * @param  directory  the directory from which to get the files.
+     * @return files in the directory, or {@code null} if the argument is not a directory.
+     */
+    private static File[] listIgnoreHidden(final File directory) {
+        final File[] files = directory.listFiles((f) -> !f.isHidden());
+        if (files != null) {
+            Arrays.sort(files, (f1, f2) -> {
+                int c = f1.isDirectory() ? 1 : 0;
+                if (f2.isDirectory()) c--;
+                if (c == 0) {
+                    c = f1.getName().compareTo(f2.getName());
+                }
+                return c;
+            });
+        }
+        return files;
+    }
+
+    /**
+     * Copies to the ZIP file the content of the given directory.
+     *
+     * @param  source  the directory to zip.
+     * @param  filter  filter to apply on the files, or {@code null} if none.
+     * @param  target  where to write the files in the ZIP file. Empty for writing in the root.
+     * @throws IOException if an error occurred while reading the source or writing the ZIP file.
+     */
+    protected final void writeDirectory(final File source, final FileFilter filter, final String target) throws IOException {
+        path.setLength(0);
+        path.append(target);
+        for (final File file : listIgnoreHidden(source)) {
+            if (filter == null || filter.accept(file)) {
+                appendRecursively(file);
+            }
+        }
+    }
+
+    /**
+     * Writes a single file or directory.
+     *
+     * @param  source  the file or directory to add.
+     * @param  target  name of the entry in the ZIP file.
+     * @throws IOException if an error occurred while reading the source or writing the ZIP file.
+     */
+    protected final void writeFile(final File source, final String target) throws IOException {
+        path.setLength(0);
+        path.append(target);
+        appendRecursively(source);
+    }
+
+    /**
+     * Appends the content of the given file. If the file is a directory,
+     * then the content of that directory will be appended recursively.
+     *
+     * @param  source  the file to append.
+     * @throws IOException if an error occurred while reading the source or writing the ZIP file.
+     */
+    private void appendRecursively(final File source) throws IOException {
+        final File[] files = listIgnoreHidden(source);
+        final int parentNameLength = path.length();
+        path.append(source.getName());
+        if (files != null) path.append('/');                    // Fixed by ZIP standard, not platform-dependent.
+        putNextEntry(source, path.toString());
+        if (files == null) {
+            writeEntryContent(source, out());
+        }
+        closeEntry();
+        if (files != null) {
+            for (final File sub : files) {
+                appendRecursively(sub);
+            }
+        }
+        path.setLength(parentNameLength);
+    }
+
+    /**
+     * Writes the content of the specified file to the output stream of a ZIP entry.
+     * Subclasses can override for filtering the content.
+     *
+     * @param  source  the file to open for providing an entry content.
+     * @param  out     where to write the (potentially filtered) file content.
+     * @throws IOException if an error occurred while reading the source or writing the ZIP file.
+     */
+    protected void writeEntryContent(final File source, final OutputStream out) throws IOException {
+        try (final InputStream in = new FileInputStream(source)) {
+            int n;
+            while ((n = in.read(buffer)) >= 0) {
+                out.write(buffer, 0, n);
+            }
+        }
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/maven/package-info.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/package-info.java
similarity index 63%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/maven/package-info.java
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/package-info.java
index 964f4cf62f..c03c0080af 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/maven/package-info.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/gradle/package-info.java
@@ -17,17 +17,14 @@
 
 
 /**
- * Maven plugins (others than {@link org.apache.sis.util.resources.ResourceCompilerMojo}) used
- * for building Apache SIS.
- *
- * <ul>
- *   <li>{@link org.apache.sis.internal.maven.JarCollector} collects all JAR files and their dependencies
- *     in a single {@code target/binaries} directory, using hard links instead of copying the files.</li>
- *   <li>{@link org.apache.sis.internal.maven.Assembler} builds the Apache SIS distribution file.</li>
- * </ul>
+ * Gradle plugin for building Apache SIS.
+ * This plugin assumes that the code is organized according Module Source Hierarchy.
+ * Some conventions on directory names and a few module names are hard-coded in the
+ * {@link org.apache.sis.buildtools.gradle.Conventions} class.
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 1.0
+ * @author  Quentin Bialota (Geomatys)
+ * @version 1.4
  * @since   0.3
  */
-package org.apache.sis.internal.maven;
+package org.apache.sis.buildtools.gradle;
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/Element.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/Element.java
new file mode 100644
index 0000000000..92cf9c5214
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/Element.java
@@ -0,0 +1,73 @@
+/*
+ * 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.buildtools.maven;
+
+
+/**
+ * An XML element to read from the original {@code pom.xml} file content.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+enum Element {
+    /**
+     * The group of the artifact to publish.
+     */
+    GROUB_ID("<groupId>", "${sis.groupId}"),
+
+    /**
+     * The identifier of the artifact to publish.
+     */
+    ARTIFACT_ID("<artifactId>", "${sis.artifactId}"),
+
+    /**
+     * The version of the artifact to publish.
+     */
+    VERSION("<version>", "${sis.version}"),
+
+    /**
+     * Human-readable name of the artifact to publish.
+     */
+    NAME("<name>", "${sis.name}"),
+
+    /**
+     * Human-readable description of the artifact to publish.
+     */
+    DESCRIPTION("<description>", "${sis.description}");
+
+    /**
+     * The XML tag where to read or write the element value.
+     */
+    final String tag;
+
+    /**
+     * The placeholder for the value to write.
+     */
+    final String property;
+
+    /**
+     * Creates a new enumeration value.
+     *
+     * @param tag       XML tag where to read or write the element value.
+     * @param property  placeholder for the value to write.
+     */
+    private Element(final String tag, final String property) {
+        this.tag      = tag;
+        this.property = property;
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/Generator.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/Generator.java
new file mode 100644
index 0000000000..a7a328f92b
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/Generator.java
@@ -0,0 +1,239 @@
+/*
+ * 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.buildtools.maven;
+
+import java.util.Map;
+import java.util.EnumMap;
+import java.util.LinkedHashMap;
+import java.util.Collection;
+import java.util.jar.JarFile;
+import java.util.zip.ZipEntry;
+import java.lang.module.ModuleDescriptor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import org.gradle.api.Project;
+import org.gradle.api.XmlProvider;
+import org.gradle.api.publish.maven.MavenArtifact;
+import org.gradle.api.publish.maven.MavenArtifactSet;
+import org.gradle.api.publish.maven.MavenPublication;
+import org.apache.sis.buildtools.gradle.Dependency;
+import org.apache.sis.buildtools.gradle.SISBuildException;
+
+
+/**
+ * Generates the {@code pom.xml} file.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+public final class Generator {
+    /**
+     * The template to use for generating the {@code pom.xml} file.
+     * Properties such as {@code ${sis.foo}} are enumerated in {@link Element}.
+     */
+    private static final String TEMPLATE = """
+        <?xml version="1.0" encoding="UTF-8"?>
+        <!--
+            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.
+        -->
+
+        <project xmlns              = "http://maven.apache.org/POM/4.0.0"
+                 xmlns:xsi          = "http://www.w3.org/2001/XMLSchema-instance"
+                 xsi:schemaLocation = "http://maven.apache.org/POM/4.0.0
+                                       http://maven.apache.org/xsd/maven-4.0.0.xsd">
+            <modelVersion>4.0.0</modelVersion>
+
+            <parent>
+                <groupId>org.apache.sis</groupId>
+                <artifactId>parent</artifactId>
+                <version>${sis.version}</version>
+            </parent>
+
+            <groupId>${sis.groupId}</groupId>
+            <artifactId>${sis.artifactId}</artifactId>
+            <name>${sis.name}</name>
+            <description>
+                ${sis.description}
+            </description>
+
+            <dependencies>
+            </dependencies>
+        </project>
+        """;
+
+    /**
+     * The {@code pom.xml} file content.
+     */
+    private final StringBuilder pom;
+
+    /**
+     * Information (such as Maven coordinates) of the artifact to publish.
+     * Those information are parsed from the original {@code pom.xml} file.
+     */
+    private final EnumMap<Element,String> info;
+
+    /**
+     * The required dependencies as JPML module name.
+     * Values are whether the dependency is optional.
+     */
+    private final Map<String,Boolean> requires;
+
+    /**
+     * Creates a new generator which will read and rewrite in the given buffer.
+     *
+     * @param  pom  the original {@code pom.xml} content to read, and the destination where the rewrite it.
+     */
+    private Generator(final StringBuilder pom) {
+        this.pom = pom;
+        info = new EnumMap<>(Element.class);
+        requires = new LinkedHashMap<>();
+    }
+
+    /**
+     * Parses the original {@code pom.xml} content.
+     * This content is minimalist, with no copyright header, no parent
+     * and no dependencies (in the way that we specified the artifact).
+     * The main things that we need to collect are Maven coordinates.
+     */
+    private void parseOriginalPOM() {
+        for (final Element element : Element.values()) {
+            int start = pom.indexOf(element.tag);
+            if (start >= 0) {
+                start += element.tag.length();
+                if (pom.indexOf(element.tag, start) >= 0) {
+                    throw new SISBuildException("Duplicated tag: " + element.tag);
+                }
+                final int end = pom.indexOf("<", start);
+                if (end < 0) {
+                    throw new SISBuildException("Unclosed tag: " + element.tag);
+                }
+                info.put(element, pom.substring(start, end).trim());
+            }
+        }
+    }
+
+    /**
+     * Overwrites the {@code pom.xml} with a new content derived from the template.
+     * The Maven coordinates will be written using the information found by {@link #parseOriginalPOM()}.
+     * Dependencies are not included, they must be added by {@link #addDependencies(Dependency[])}.
+     */
+    private void rewritePOM() {
+        pom.setLength(0);
+        pom.append(TEMPLATE);
+        for (final Element element : Element.values()) {
+            int start = pom.length();
+            while ((start = pom.lastIndexOf(element.property, start)) >= 0) {
+                final int end = start + element.property.length();
+                final String value = info.get(element);
+                if (value == null) {
+                    throw new SISBuildException("Tag not found: " + element.tag);
+                }
+                pom.replace(start, end, value);
+            }
+        }
+        final int start = pom.indexOf("${");
+        if (start >= 0) {
+            int end = pom.indexOf("}", start) + 1;
+            if (end <= 0) end = pom.length();
+            throw new SISBuildException("Unknown property: " + pom.substring(start, end));
+        }
+    }
+
+    /**
+     * Adds the given dependencies at the end of the {@code pom.xml}.
+     * An initial POM must have been written by {@link #rewritePOM()}.
+     * Dependencies must have been collected by {@link #findUsedDependencies(MavenArtifactSet)} first.
+     *
+     * @param  dependencies  the project dependencies to add.
+     */
+    private void addDependencies(final Map<String,Dependency> dependencies) {
+        final int start = pom.lastIndexOf("\n", pom.lastIndexOf("</dependencies>")) + 1;
+        if (start <= 0) {
+            throw new SISBuildException("Bad pom.xml template.");
+        }
+        final String suffix = pom.substring(start);
+        pom.setLength(start);
+        for (final Map.Entry<String,Boolean> entry : requires.entrySet()) {
+            final Dependency dep = dependencies.get(entry.getKey());
+            if (dep != null) {
+                dep.writeAsMaven(pom, 2, entry.getValue());
+            }
+        }
+        pom.append(suffix);
+    }
+
+    /**
+     * Collects the dependencies declared in {@code module-info.class} for the given artifacts.
+     * Artifact without {@code module-info.class} are ignored. Duplicated values are ignored.
+     * Dependencies are added in the {@link #requires} map.
+     *
+     * @param  artifacts  the artifacts for which to collect dependencies.
+     * @throws IOException if an error occurred while reading {@code module-info.class}.
+     */
+    private void findUsedDependencies(final MavenArtifactSet artifacts) throws IOException {
+        for (final MavenArtifact artifact : artifacts) {
+            try (JarFile zip = new JarFile(artifact.getFile(), false, JarFile.OPEN_READ, JarFile.runtimeVersion())) {
+                final ZipEntry entry = zip.getEntry("module-info.class");
+                if (entry != null) {
+                    try (InputStream in = zip.getInputStream(entry)) {
+                        final ModuleDescriptor module = ModuleDescriptor.read(in);
+                        for (final ModuleDescriptor.Requires r : module.requires()) {
+                            requires.put(r.name(), r.modifiers().contains(ModuleDescriptor.Requires.Modifier.STATIC));
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Reads and rewrites the {@code pom.xml} file content.
+     *
+     * @param  project      the project to be published.
+     * @param  subProjects  the sub-projects, which also need to be included as dependencies across JPMS modules.
+     * @param  pub          configuration of the artifact to publish in Maven format.
+     * @param  xml          provider of the original content, and where to write the result.
+     */
+    public static void rewrite(final Project project, final Collection<Dependency> subProjects, final MavenPublication pub, final XmlProvider xml) {
+        final var g = new Generator(xml.asString());
+        g.parseOriginalPOM();
+        g.rewritePOM();
+        try {
+            g.findUsedDependencies(pub.getArtifacts());
+            g.addDependencies(Dependency.jpms(project, subProjects));
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/package-info.java
similarity index 65%
copy from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java
copy to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/package-info.java
index 5817ad614a..d9afdfff4e 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/package-info.java
@@ -17,11 +17,14 @@
 
 
 /**
- * Javadoc taglets for processing custom tags in SIS source code comments,
- * together with doclet performing post-processing after Javadoc generation.
+ * Generates Maven POM from a template file. We do not use Gradle's API for this task
+ * (e.g. the setter methods in {@link org.gradle.api.publish.maven.MavenPom}) because
+ * as of Gradle 8.2.1, they do not provide sufficient control over the file content.
+ * In particular we didn't found the way to specify the parent POM,
+ * neither how to control the list of dependencies.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
- * @since   0.5
+ * @version 1.4
+ * @since   1.4
  */
-package org.apache.sis.internal.doclet;
+package org.apache.sis.buildtools.maven;
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/workaround/Argument.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/workaround/Argument.java
new file mode 100644
index 0000000000..4906946d22
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/workaround/Argument.java
@@ -0,0 +1,100 @@
+/*
+ * 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.buildtools.maven.workaround;
+
+import java.util.Arrays;
+import java.util.EnumMap;
+
+
+/**
+ * Command-line argument for {@link ProxyGenerator#main(String[])}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+enum Argument {
+    /**
+     * Directory where to write the generated files.
+     */
+    OUT("Directory where to write the generated files."),
+
+    /**
+     * Java package where to put generated Java code (if needed).
+     */
+    PACKAGE("Java package where to put generated Java code (if needed).");
+
+    /**
+     * Explanation shown in help screen for this option.
+     */
+    private final String description;
+
+    /**
+     * Creates a new option.
+     *
+     * @param  description  explanation shown in help screen for this option.
+     */
+    private Argument(final String description) {
+        this.description = description;
+    }
+
+    /**
+     * Parses the options. For each parsed option, the corresponding element in the given array
+     * is set to {@code null}. Non-null elements that are left after this method completion are
+     * JAR files to process.
+     *
+     * @param  args  command line options.
+     * @return parsed options, or an empty map in case of parsing error.
+     */
+    @SuppressWarnings("UseOfSystemOutOrSystemErr")
+    static EnumMap<Argument,String> parse(final String[] args) {
+        String message = "Illegal option: %s%n";
+        final EnumMap<Argument,String> map = new EnumMap<>(Argument.class);
+        for (int i=0; i<args.length; i++) {
+            String arg = args[i];
+            if (arg.startsWith("--")) {
+                args[i] = null;
+                final int s = arg.indexOf('=');
+                if (s >= 0) try {
+                    final var option = Argument.valueOf(arg.substring(2,s).trim().toUpperCase());
+                    if (map.put(option, arg.substring(s+1).trim()) == null) {
+                        continue;
+                    }
+                    message = "--%s cannot be repeated.%n";
+                    arg = option.name().toLowerCase();
+                } catch (IllegalArgumentException e) {
+                    arg = arg.substring(0, s).trim();
+                }
+                System.err.printf(message, arg);
+                Arrays.fill(args, null);
+                map.clear();
+                break;
+            }
+        }
+        return map;
+    }
+
+    /**
+     * Prints all available options.
+     */
+    @SuppressWarnings("UseOfSystemOutOrSystemErr")
+    static void help() {
+        System.out.println("Syntax: [option=value] --out=<dir> jar...");
+        System.out.println("Options:");
+        for (final Argument option : values()) {
+            System.out.printf("  --%-8s %s%n", option.name().toLowerCase(), option.description);
+        }
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/workaround/ProxyGenerator.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/workaround/ProxyGenerator.java
new file mode 100644
index 0000000000..6938795b54
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/workaround/ProxyGenerator.java
@@ -0,0 +1,279 @@
+/*
+ * 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.buildtools.maven.workaround;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.BufferedWriter;
+import java.io.InputStream;
+import java.io.IOException;
+import java.util.Map;
+import java.util.List;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.ArrayList;
+import java.util.jar.JarFile;
+import java.util.zip.ZipEntry;
+import java.lang.module.ModuleDescriptor;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+
+
+/**
+ * Generates {@code META-INF/services} declarations and, if needed, proxy classes.
+ * The input is one or more JAR files containing {@code module-info.class} files.
+ * The output is {@code META-INF/services} and (optionally) Java wrapper classes
+ * written in the specified target directory. See MNG-7855 for more information.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @see <a href="https://issues.apache.org/jira/browse/MNG-7855">MNG-7855 on JIRA issue tracker</a>
+ */
+@SuppressWarnings({"UseOfSystemOutOrSystemErr", "CallToPrintStackTrace"})
+public class ProxyGenerator {
+    /**
+     * Executes from the command-line. The arguments shall contain {@code --out=dir}
+     * where {@code dir} is the target directory where to write generated files.
+     * Other arguments that are not options are JAR files.
+     *
+     * @param  args  command-line arguments as {@code <option>=<value>} pairs followed by JAR files.
+     * @throws IOException if an error occurred while reading a JAR file or writing the generated files.
+     */
+    public static void main(final String[] args) throws IOException {
+        final EnumMap<Argument,String> options = Argument.parse(args);
+        final String target = options.get(Argument.OUT);
+        ProxyGenerator generator = null;
+        if (target != null) {
+            for (final String arg : args) {
+                if (arg != null) {
+                    if (generator == null) {
+                        generator = new ProxyGenerator(new File(target), options.get(Argument.PACKAGE));
+                    }
+                    generator.collectProviders(new File(arg));
+                }
+            }
+        }
+        if (generator != null) {
+            generator.checkStaticProviderMethods();
+            generator.generate();
+        } else {
+            Argument.help();
+        }
+    }
+
+    /**
+     * Class names of all service providers found so far.
+     * Keys are names of the classes or interfaces defining the services.
+     * Values are names of the implementation classes.
+     */
+    private final Map<String, List<String>> providers;
+
+    /**
+     * {@code true} if we found at least one provider which is not on the classpath.
+     * This is used for avoiding to flood the console with too many warning messages.
+     */
+    private boolean providerNotOnClasspath;
+
+    /**
+     * {@code true} if at least one public static {@code provider()} method has been found.
+     * This is used for avoiding to flood the console with too many warning messages.
+     */
+    private boolean providerStaticMethodFound;
+
+    /**
+     * Name of the Java package to use if proxy Java classed need to be generated.
+     * Null if the {@link Argument#PACKAGE} option has not been specified.
+     */
+    private final String packageName;
+
+    /**
+     * Number of the proxy generated, used in class name.
+     */
+    private int proxyNumber;
+
+    /**
+     * Destination directory where to write the generated files.
+     */
+    private final File target;
+
+    /**
+     * Creates a new generator of {@code META-INF/services} declarations.
+     *
+     * @param  target       destination directory where to write the generated files.
+     * @param  packageName  name of the Java package to use if proxy Java classed need to be generated.
+     */
+    private ProxyGenerator(final File target, final String packageName) {
+        this.target      = target;
+        this.packageName = packageName;
+        this.providers   = new HashMap<>();
+    }
+
+    /**
+     * Collects all providers declared in the {@code module-info.class} entry of the specified JAR file.
+     *
+     * @param  file  the file for which to collect service providers.
+     * @throws IOException if an error occurred while reading the JAR file.
+     */
+    private void collectProviders(final File file) throws IOException {
+        try (JarFile zip = new JarFile(file, false, JarFile.OPEN_READ, JarFile.runtimeVersion())) {
+            final ZipEntry entry = zip.getEntry("module-info.class");
+            if (entry != null) {
+                try (InputStream in = zip.getInputStream(entry)) {
+                    final ModuleDescriptor desc = ModuleDescriptor.read(in);
+                    desc.provides().forEach((provides) -> {
+                        providers.computeIfAbsent(provides.service(), (service) -> new ArrayList<>())
+                                          .addAll(provides.providers());
+                    });
+                }
+            }
+        }
+    }
+
+    /**
+     * Verifies is any service provider declares a public static {@code provider()} method.
+     * Those methods are incompatible with {@code META-INF/services} and need to be replaced
+     * by a proxy class.
+     *
+     * @throws IOException if an error occurred while generating a proxy class.
+     */
+    private void checkStaticProviderMethods() throws IOException {
+        for (final Map.Entry<String, List<String>> entry : providers.entrySet()) {
+            final List<String> providers = entry.getValue();
+            for (int i=0; i < providers.size(); i++) {
+                final Class<?> service, provider;
+                try {
+                    provider = Class.forName(providers.get(i));
+                    service  = Class.forName(entry.getKey());   // Should not fail if above line didn't fail.
+                    if (!Modifier.isStatic(provider.getMethod("provider", (Class[]) null).getModifiers())) {
+                        continue;
+                    }
+                } catch (ClassNotFoundException e) {
+                    if (!providerNotOnClasspath) {
+                        providerNotOnClasspath = true;
+                        System.err.printf("The implementation class of at least one service provider could not be found.%n"
+                                        + "ProxyGenerator cannot verify if a public static provider() method exists.%n"
+                                        + "If this check is desired, add the provider implementations on the classpath.%n"
+                                        + "The error is: %s%n", e);
+                    }
+                    continue;
+                } catch (NoSuchMethodException e) {
+                    // No public `provider()` method. This is normal.
+                    continue;
+                }
+                if (packageName != null) {
+                    final String replacement = writeJavaProxyClass(service, provider);
+                    providers.set(i, replacement);
+                } else if (!providerStaticMethodFound) {
+                    providerStaticMethodFound = true;
+                    System.out.printf("A public static provider() method was found%n"
+                                    + "but the --package option was not specified.%n"
+                                    + "Provider is: %s%n", provider.getCanonicalName());
+                }
+            }
+        }
+    }
+
+    /**
+     * Generates Java code for the proxy class.
+     *
+     * @param  service   the service to provide.
+     * @param  provider  the provider to wrap.
+     * @return fully qualified name of the proxy class.
+     * @throws IOException if an error occurred while writing the proxy class.
+     */
+    private String writeJavaProxyClass(final Class<?> service, final Class<?> provider) throws IOException {
+        final String proxyName = String.format("Proxy%03d", ++proxyNumber);
+        File file = new File(new File(target, "java"), packageName.replace('.', File.separatorChar));
+        file.mkdirs();
+        file = new File(file, proxyName);
+        try (final var out = new BufferedWriter(new FileWriter(file))) {
+            final String ls = System.lineSeparator();
+            out.append("package ").append(packageName).append(';').append(ls)
+               .append(ls)
+               .append("public final class ").append(proxyName)
+               .append(" implements ").append(service.getCanonicalName()).append(" {").append(ls)
+               .append("    private final ").append(service.getCanonicalName()).append(" provider;").append(ls)
+               .append("    public ").append(proxyName).append("() {").append(ls)
+               .append("        provider = ").append(provider.getCanonicalName()).append(".provider();").append(ls)
+               .append("    }").append(ls);
+            for (final Method m : service.getMethods()) {
+                out.append("    public ").append(m.getReturnType().getCanonicalName())
+                   .append(' ').append(m.getName()).append('(');
+                final Class<?>[] args = m.getParameterTypes();
+                for (int i=0; i<args.length; i++) {
+                    if (i != 0) out.append(", ");
+                    out.append(args[i].getCanonicalName()).append(" p").append(Integer.toString(i));
+                }
+                out.append(')');
+                String separator = " throws ";
+                for (final Class<?> c : m.getExceptionTypes()) {
+                    out.append(separator).append(c.getCanonicalName());
+                    separator = ", ";
+                }
+                out.append(" {").append(ls).append("        ");
+                if (m.getReturnType() != Void.TYPE) {
+                    out.append("return ");
+                }
+                out.append("provider.").append(m.getName()).append('(');
+                for (int i=0; i<args.length; i++) {
+                    if (i != 0) out.append(", ");
+                    out.append('p').append(Integer.toString(i));
+                }
+                out.append(");").append(ls)
+                   .append("    }").append(ls);
+            }
+            out.append('}').append(ls);
+        }
+        return packageName + '.' + proxyName;
+    }
+
+    /**
+     * Generates the {@code META-INF/services} files in the target directory.
+     *
+     * @throws IOException if an error occurred while writing a file.
+     */
+    private void generate() throws IOException {
+        final File services = new File(target, "resources/META-INF/services");
+        services.mkdirs();
+        for (final Map.Entry<String, List<String>> entry : providers.entrySet()) {
+            try (BufferedWriter out = new BufferedWriter(new FileWriter(new File(services, entry.getKey())))) {
+                out.write("# Workaround for Maven bug https://issues.apache.org/jira/browse/MNG-7855");        out.newLine();
+                out.write("# The content of this file is automatically derived from module-info.class file."); out.newLine();
+                out.write("# Should be used only if the JAR file was on class-path rather than module-path."); out.newLine();
+                for (final String provider : entry.getValue()) {
+                    out.write(provider);
+                    out.write(System.lineSeparator());
+                }
+            }
+        }
+    }
+
+    /**
+     * {@returns the class names of all service providers found so far}.
+     * This information is provided for debugging purposes only.
+     */
+    @Override
+    public String toString() {
+        final var sb = new StringBuilder(1000);
+        providers.entrySet().forEach((entry) -> {
+            sb.append(entry.getKey()).append(':').append(System.lineSeparator());
+            entry.getValue().forEach((provider) -> {
+                sb.append("    ").append(provider).append(System.lineSeparator());
+            });
+        });
+        return sb.toString();
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/workaround/package-info.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/workaround/package-info.java
new file mode 100644
index 0000000000..8976baf299
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/maven/workaround/package-info.java
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+
+/**
+ * Generates {@code META-INF/services} files with the content of {@code module-info.class} files.
+ * This is used for compatibility when a modularized dependency is declared on the class-path
+ * rather than the module-path. This need occurs when dependencies are automatically dispatched
+ * between {@code --class-path} and {@code --module-path} options by a tool on which we have no
+ * control, such as Maven 3.8.6 or Gradle 8.2.1.
+ *
+ * <p>Note that this workaround does not fix the real issue,
+ * which is that dependencies are loaded as unnamed modules when they should not.
+ * The workaround allows libraries and applications to find some service providers despite this problem,
+ * sometime not in the way that the providers should be (because of wrappers).
+ * But any other features that depend on named modules are still broken.</p>
+ *
+ * <p>The classes in this package are not used during Apache SIS builds.
+ * They are used only if the content of some {@code module-info.java} files changes
+ * and the {@code META-INF/services/*} files need to be regenerated.</p>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @see <a href="https://issues.apache.org/jira/browse/MNG-7855">MNG-7855 on JIRA issue tracker</a>
+ */
+package org.apache.sis.buildtools.maven.workaround;
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/util/resources/IndexedResourceCompiler.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/resources/IndexedResourceCompiler.java
similarity index 68%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/util/resources/IndexedResourceCompiler.java
rename to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/resources/IndexedResourceCompiler.java
index 879d261cce..872158b366 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/util/resources/IndexedResourceCompiler.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/resources/IndexedResourceCompiler.java
@@ -14,37 +14,54 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.sis.util.resources;
-
-import java.io.*;
-import java.util.*;
-import java.text.MessageFormat;
+package org.apache.sis.buildtools.resources;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import java.util.HashMap;
+import java.util.TreeMap;
+import java.util.SortedMap;
+import java.util.Iterator;
+import java.util.Properties;
 import java.util.regex.Pattern;
+import java.text.MessageFormat;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.OutputStreamWriter;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.Writer;
 
 
 /**
  * Reads a given list of {@code .properties} files and copies their content to {@code .utf} files using UTF-8 encoding.
- * This class also checks for key validity and checks values for {@link MessageFormat} compatibility.
+ * This class also checks for key validity and checks the property values for {@link MessageFormat} compatibility.
  * Finally, it writes the key values in the Java source files.
  *
- * <p>This class is independent of any Mojo and could be executed from the command-line.
- * For now we keep it package-private, but we could consider to enable execution from
- * the command-line in a future version if this happen to be useful.</p>
+ * <p>Instances of this class are not thread safe.</p>
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 0.8
+ * @version 1.4
  * @since   0.3
  */
-class IndexedResourceCompiler implements FilenameFilter, Comparator<Object> {
+public class IndexedResourceCompiler {
     /**
      * Extension for java source files.
      */
-    static final String JAVA_EXT = ".java";
+    private static final String JAVA_EXT = ".java";
 
     /**
      * Extension for properties source files.
      */
-    static final String PROPERTIES_EXT = ".properties";
+    private static final String PROPERTIES_EXT = ".properties";
 
     /**
      * Extension for resource target files.
@@ -83,7 +100,12 @@ class IndexedResourceCompiler implements FilenameFilter, Comparator<Object> {
     private static final String KEY_MARGIN = "        ";        // 8 spaces
 
     /**
-     * The source directory of the java and properties files.
+     * Directory to exclude when parsing sub-directories recursively.
+     */
+    private static final String EXCLUDE_DIRECTORY = "doc-files";
+
+    /**
+     * The source directory of the Java and properties files.
      */
     private final File sourceDirectory;
 
@@ -105,57 +127,170 @@ class IndexedResourceCompiler implements FilenameFilter, Comparator<Object> {
     private final Map<Integer,String> allocatedIDs = new HashMap<>();
 
     /**
-     * Resource keys and their localized values. This map will be cleared for each language
-     * in a resource bundle.
+     * Resource keys and their localized values.
+     * This map will be cleared for each language in a resource bundle.
      */
     private final Map<Object,Object> resources = new HashMap<>();
 
     /**
-     * The resources bundle base classes.
+     * Buffer to use for writing UTF data in an array of bytes.
+     *
+     * @see #writeUTF()
      */
-    private final File[] resourcesToProcess;
+    private final ByteArrayOutputStream bufferUTF = new ByteArrayOutputStream(1024 * 8);
 
     /**
-     * Number of errors found.
+     * The result of compiling UTF files. Keys are the files where to write the results,
+     * and values are the bytes to write for the associated file.
+     */
+    private final Map<File, ? super byte[]> destination;
+
+    /**
+     * Number of logical errors found.
      */
     private int errors;
 
     /**
      * Constructs a new {@code IndexedResourceCompiler}.
+     * The compilation results are not written immediately to the build directory.
+     * Instead they are saved in the given map and written later by the caller.
      *
-     * @param sourceDirectory     the source directory.
-     * @param buildDirectory      the target directory where to write UTF files.
-     * @param resourcesToProcess  the resource bundle base classes
-     *                            (e.g. {@code org/apache/sis/util/resources/Vocabulary.java}).
-     */
-    IndexedResourceCompiler(final File sourceDirectory, final File buildDirectory, final File[] resourcesToProcess) {
-        this.sourceDirectory    = sourceDirectory;
-        this.buildDirectory     = buildDirectory;
-        this.resourcesToProcess = resourcesToProcess;
+     * @param  sourceDirectory  the source directory.
+     * @param  buildDirectory   the target directory where to write UTF files.
+     * @param  destination      where to write the compilation results.
+     */
+    public IndexedResourceCompiler(final File sourceDirectory, final File buildDirectory,
+                                   final Map<File, ? super byte[]> destination)
+    {
+        this.sourceDirectory = sourceDirectory;
+        this.buildDirectory  = buildDirectory;
+        this.destination     = destination;
     }
 
     /**
-     * Runs the resource compiler.
+     * Flags the properties files which seem to be about internationalized resources.
+     * For example if the given map contains the following files:
+     * <ul>
+     *   <li>{@code "Errors.properties"}</li>
+     *   <li>{@code "Errors_en.properties"}</li>
+     *   <li>{@code "Errors_fr.properties"}</li>
+     *   <li>{@code "Messages.properties"}</li>
+     *   <li>{@code "Messages_en.properties"}</li>
+     *   <li>{@code "Messages_fr.properties"}</li>
+     *   <li>{@code "NotAnInternationalResource.properties"}</li>
+     * </ul>
+     *
+     * Then this method will set to {@code Boolean.TRUE} the values associated to the following files
+     * and remove the entries for their language variants:
+     * <ul>
+     *   <li>{@code "Errors.properties"}</li>
+     *   <li>{@code "Messages.properties"}</li>
+     * </ul>
      *
-     * @throws ResourceCompilerException if an error occurred.
-     * @return the number of errors found.
+     * The entries that do not seem to be about internationalized resources will be left unchanged.
+     * Their associated values should be {@code Boolean.FALSE}.
+     *
+     * @param  resourcesToProcess  the files to filter. This map will be modified in-place.
+     * @param  checkSourceExists   whether to check that the Java source file exists.
      */
-    @SuppressWarnings("ForLoopReplaceableByForEach")
-    public int run() throws ResourceCompilerException {
-        if (!sourceDirectory.isDirectory()) {
-            throw new ResourceCompilerException(sourceDirectory + " not found or is not a directory.");
+    static void filterLanguages(final SortedMap<File,Boolean> resourcesToProcess, final boolean checkSourceExists) {
+        final Iterator<Map.Entry<File,Boolean>> it = resourcesToProcess.entrySet().iterator();
+        Map.Entry<File,Boolean> baseEntry = null;
+        String baseName = null;
+        while (it.hasNext()) {
+            final Map.Entry<File,Boolean> entry = it.next();
+            final File file = entry.getKey();
+            if (baseName != null && file.getName().startsWith(baseName)) {
+                if (baseEntry != null) {
+                    baseEntry.setValue(Boolean.TRUE);
+                    it.remove();
+                }
+                continue;
+            }
+            baseEntry  = entry;
+            baseName   = getBaseName(file);
+            var source = new File(file.getParentFile(), baseName + JAVA_EXT);
+            baseName  += '_';
+            if (checkSourceExists && !source.isFile()) {
+                baseEntry = null;
+            }
         }
-        for (int i=0; i<resourcesToProcess.length; i++) {
-            bundleClass = resourcesToProcess[i];
-            allocatedIDs.clear();
-            resources.clear();
-            try {
-                loadKeyValues();
-                scanForResources();
-            } catch (IOException exception) {
-                throw new ResourceCompilerException(exception);
+    }
+
+    /**
+     * Returns the file name without the {@value #PROPERTIES_EXT} extension.
+     * For multilingual resources, this is the base resource name.
+     */
+    private static String getBaseName(final File file) {
+        final String name = file.getName();
+        return name.substring(0, name.length() - PROPERTIES_EXT.length());
+    }
+
+    /**
+     * Recursively scans the source directory and finds all Java classes having a property files of the same name.
+     * Then invokes the resource compiler for those files by calls to {@link #onJavaSource(File)}.
+     * Property files that can not be compiled are linked or copied verbatim to the destination directory.
+     *
+     * @return the number of logical errors found in properties.
+     * @throws IOException if an error occurred while reading or writing a file.
+     */
+    public int onJavaSourceDirectory() throws IOException {
+        onJavaSourceDirectory(sourceDirectory);
+        return errors;
+    }
+
+    /**
+     * Implementation of {@link #onJavaSourceDirectory()} invoked recursively for scanning down the tree.
+     *
+     * @param  directory  root directory of Java source files for a single module.
+     * @throws IOException if an error occurred while reading or writing a file.
+     */
+    private void onJavaSourceDirectory(final File directory) throws IOException {
+        final File[] files = directory.listFiles((dir, name) -> !name.endsWith(JAVA_EXT));
+        if (files == null) {
+            throw new FileNotFoundException(directory + " not found or is not a directory.");
+        }
+        final var resourcesToProcess = new TreeMap<File,Boolean>();
+        for (final File file : files) {
+            final String name = file.getName();
+            if (!name.isEmpty() && name.charAt(0) != '.') {
+                if (file.isDirectory()) {
+                    if (!EXCLUDE_DIRECTORY.equalsIgnoreCase(name)) {
+                        onJavaSourceDirectory(file);
+                    }
+                } else if (name.endsWith(PROPERTIES_EXT)) {
+                    resourcesToProcess.put(file, Boolean.FALSE);
+                } else {
+                    otherResource(file, sourceToTarget(file));
+                }
+            }
+        }
+        filterLanguages(resourcesToProcess, true);
+        for (final Map.Entry<File,Boolean> entry : resourcesToProcess.entrySet()) {
+            final File source = entry.getKey();
+            if (entry.getValue() && acceptCompilableResource(source)) {
+                onJavaSource(new File(source.getParentFile(), getBaseName(source) + JAVA_EXT));
+            } else {
+                otherResource(source, sourceToTarget(source));
             }
         }
+    }
+
+    /**
+     * Runs the resource compiler on the given Java source file.
+     * The source file may be modified.
+     *
+     * @param  resourceToProcess  the resource bundle base classes
+     *         (e.g. {@code org/apache/sis/util/resources/Vocabulary.java}).
+     * @return the number of logical errors found in properties.
+     * @throws IOException if an error occurred while reading or writing a file.
+     */
+    public int onJavaSource(final File resourceToProcess) throws IOException {
+        bundleClass = resourceToProcess;
+        allocatedIDs.clear();
+        resources.clear();
+        loadKeyValues();
+        scanForResources();
         return errors;
     }
 
@@ -197,11 +332,14 @@ class IndexedResourceCompiler implements FilenameFilter, Comparator<Object> {
     }
 
     /**
-     * Returns the path of the given file relative to the given directory.
+     * Converts a path relative to the source directory into a path relative to the target directory.
+     *
+     * @param  file  an absolute path to a source file.
+     * @return absolute path to the destination file in the build directory.
      */
-    private static String relative(final File directory, final File file) throws IOException {
+    private File sourceToTarget(final File file) throws IOException {
         String path = file.getPath();
-        final String expected = directory.getPath();
+        final String expected = sourceDirectory.getPath();
         if (!path.startsWith(expected)) {
             throw new IOException(path + " is not relative to " + expected);
         }
@@ -209,16 +347,16 @@ class IndexedResourceCompiler implements FilenameFilter, Comparator<Object> {
         if (!path.startsWith(File.separator)) {
             path = path.substring(File.separator.length());
         }
-        return path;
+        return new File(buildDirectory, path);
     }
 
     /**
-     * Scans the package of {@link #bundleClass} for its {@code .properties} files.
+     * Searches {@code .properties} files in the package which contains {@link #bundleClass}.
      * The following methods are invoked by this method:
      *
      * <ul>
      *   <li>{@link #loadProperties(File)}</li>
-     *   <li>{@link #writeUTF(File)}</li>
+     *   <li>{@link #writeUTF()}</li>
      *   <li>{@link #writeJavaSource()}</li>
      * </ul>
      *
@@ -228,7 +366,7 @@ class IndexedResourceCompiler implements FilenameFilter, Comparator<Object> {
         String classname = bundleClass.getName();
         classname = classname.substring(0, classname.lastIndexOf('.'));
         final File srcDir = bundleClass.getParentFile();
-        final File utfDir = new File(buildDirectory, relative(sourceDirectory, srcDir));
+        final File utfDir = sourceToTarget(srcDir);
         if (!srcDir.isDirectory()) {
             throw new FileNotFoundException("\"" + srcDir + "\" is not a directory.");
         }
@@ -236,16 +374,15 @@ class IndexedResourceCompiler implements FilenameFilter, Comparator<Object> {
             throw new FileNotFoundException("\"" + utfDir + "\" is not a directory.");
         }
         File defaultLanguage = null;
-        for (final File file : srcDir.listFiles(this)) {
+        final String prefix = classname;
+        for (final File file : srcDir.listFiles((directory, name) -> name.endsWith(PROPERTIES_EXT) && name.startsWith(prefix))) {
+            loadProperties(file);
             final String filename = file.getName();
-            if (filename.startsWith(classname)) {
-                loadProperties(file);
-                final String noExt = filename.substring(0, filename.length() - PROPERTIES_EXT.length());
-                final File utfFile = new File(utfDir, noExt + RESOURCES_EXT);
-                writeUTF(utfFile);
-                if (noExt.equals(classname)) {
-                    defaultLanguage = file;
-                }
+            final String noExt = filename.substring(0, filename.length() - PROPERTIES_EXT.length());
+            final File utfFile = new File(utfDir, noExt + RESOURCES_EXT);
+            destination.put(utfFile, writeUTF());
+            if (noExt.equals(classname)) {
+                defaultLanguage = file;
             }
         }
         if (defaultLanguage != null) {
@@ -255,18 +392,6 @@ class IndexedResourceCompiler implements FilenameFilter, Comparator<Object> {
         writeJavaSource();
     }
 
-    /**
-     * Returns {@code true} if the given file is a property file.
-     *
-     * @param  directory  the directory (ignored).
-     * @param  name       the file name.
-     * @return {@code true} if the given file is a property file.
-     */
-    @Override
-    public final boolean accept(final File directory, final String name) {
-        return name.endsWith(PROPERTIES_EXT);
-    }
-
     /**
      * Loads the specified property file. No processing are performed on them.
      *
@@ -353,7 +478,7 @@ class IndexedResourceCompiler implements FilenameFilter, Comparator<Object> {
          * because some classes use key value 0 for meaning "no message".
          */
         final String[] keys = resources.keySet().toArray(new String[resources.size()]);
-        Arrays.sort(keys, this);
+        Arrays.sort(keys);
         int freeID = 0;
         for (final String key : keys) {
             if (!allocatedIDs.containsValue(key)) {
@@ -423,29 +548,27 @@ search: for (int i=0; i<buffer.length(); i++) {                 // Length of `bu
     }
 
     /**
-     * Writes UTF file. The following methods must be invoked before this one:
+     * Writes UTF data in an array.
+     * The following methods must be invoked before this one:
      *
      * <ul>
      *   <li>{@link #loadKeyValues()}</li>
      *   <li>{@link #loadProperties(File)}</li>
      * </ul>
      *
-     * @param  file  the destination file.
      * @throws IOException if an input/output operation failed.
      */
-    private void writeUTF(final File file) throws IOException {
-        final File directory = file.getParentFile();
-        if (!directory.isDirectory() && !directory.mkdirs()) {
-            throw new IOException("Cannot create the " + directory + " directory.");
-        }
+    private byte[] writeUTF() throws IOException {
+        bufferUTF.reset();
         final int count = allocatedIDs.isEmpty() ? 0 : Collections.max(allocatedIDs.keySet());
-        try (DataOutputStream out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) {
+        try (DataOutputStream out = new DataOutputStream(bufferUTF)) {
             out.writeInt(count);
             for (int i=1; i<=count; i++) {
                 final String value = (String) resources.get(allocatedIDs.get(i));
                 out.writeUTF((value != null) ? value : "");
             }
         }
+        return bufferUTF.toByteArray();
     }
 
     /**
@@ -505,7 +628,7 @@ search: for (int i=0; i<buffer.length(); i++) {                 // Length of `bu
              */
             int startLineToCompare = buffer.length();
             final Map.Entry<?,?>[] entries = allocatedIDs.entrySet().toArray(new Map.Entry<?,?>[allocatedIDs.size()]);
-            Arrays.sort(entries, this);
+            Arrays.sort(entries, (o1, o2) -> ((String) o1.getValue()).compareTo((String) o2.getValue()));
             for (final Map.Entry<?,?> entry : entries) {
                 buffer.append(lineSeparator);
                 final String key = (String) entry.getValue();
@@ -589,21 +712,27 @@ search: for (int i=0; i<buffer.length(); i++) {                 // Length of `bu
     }
 
     /**
-     * Compares two resource keys. Object {@code o1} and {@code o2} are usually {@link String}
-     * objects representing resource keys (for example, "{@code MISMATCHED_DIMENSION}"), but
-     * may also be {@link java.util.Map.Entry}.
+     * Invoked for each resource file that {@code IndexedResourceCompiler} thinks is compilable.
+     * Subclasses can override this method for excluding some false positives.
+     * The default implementation always returns {@code true}.
+     *
+     * @param  source  the {@code .properties} file that may be compiled.
+     * @return whether to compile the specified file.
+     */
+    protected boolean acceptCompilableResource(final File source) {
+        return true;
+    }
+
+    /**
+     * Invoked when a file is not a Java source file, a properties file being compiled or a directory.
+     * The default implementation does nothing.
+     * Subclasses can override for copying of filtering the resource if desired.
      *
-     * @param  o1  the resource key to compare.
-     * @param  o2  the second resource key to compare.
-     * @return -1, 0 or +1 based on the alphabetic order of resource keys.
-     */
-    @Override
-    public final int compare(Object o1, Object o2) {
-        if (o1 instanceof Map.Entry<?,?>) o1 = ((Map.Entry<?,?>) o1).getValue();
-        if (o2 instanceof Map.Entry<?,?>) o2 = ((Map.Entry<?,?>) o2).getValue();
-        final String key1 = (String) o1;
-        final String key2 = (String) o2;
-        return key1.compareTo(key2);
+     * @param  source  the file which is not a Java source of properties file being compiled.
+     * @param  target  destination file in the build directory if the implementation wants to copy.
+     * @throws IOException if an error occurred while processing the resource.
+     */
+    protected void otherResource(File source, File target) throws IOException {
     }
 
     /**
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/resources/package-info.java
similarity index 75%
copy from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java
copy to buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/resources/package-info.java
index 5817ad614a..17e61860dc 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java
+++ b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/buildtools/resources/package-info.java
@@ -17,11 +17,10 @@
 
 
 /**
- * Javadoc taglets for processing custom tags in SIS source code comments,
- * together with doclet performing post-processing after Javadoc generation.
+ * Build resources for internationalization in Apache SIS.
  *
- * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
- * @since   0.5
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.4
+ * @since   1.4
  */
-package org.apache.sis.internal.doclet;
+package org.apache.sis.buildtools.resources;
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/maven/Assembler.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/maven/Assembler.java
deleted file mode 100644
index 1478df82a0..0000000000
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/maven/Assembler.java
+++ /dev/null
@@ -1,270 +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.maven;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.FilenameFilter;
-import java.io.FileInputStream;
-import java.io.InputStream;
-import java.util.Map;
-import java.util.Set;
-import java.util.Enumeration;
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.zip.ZipFile;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipOutputStream;
-import org.apache.maven.artifact.Artifact;
-import org.apache.maven.plugin.AbstractMojo;
-import org.apache.maven.plugin.MojoExecutionException;
-import org.apache.maven.plugins.annotations.Mojo;
-import org.apache.maven.plugins.annotations.Parameter;
-import org.apache.maven.plugins.annotations.LifecyclePhase;
-import org.apache.maven.plugins.annotations.ResolutionScope;
-import org.apache.maven.project.MavenProject;
-import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
-import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
-
-import static org.apache.sis.internal.maven.Filenames.*;
-
-
-/**
- * Creates a ZIP file containing the Apache SIS binary distribution.
- * The created file contains:
- *
- * <ul>
- *   <li>the content of the <code>application/sis-javafx/src/main/artifact</code> directory;</li>
- *   <li>the JAR files of all modules and their dependencies, without their native resources;</li>
- *   <li>the native resources in a separated {@code lib/} directory.</li>
- * </ul>
- *
- * This MOJO can be invoked from the command line in the {@code sis-javafx} module as below:
- *
- * <blockquote><code>mvn package org.apache.sis.core:sis-build-helper:dist</code></blockquote>
- *
- * <h2>Limitation</h2>
- * The current implementation uses some hard-coded paths and filenames.
- * See the <cite>Distribution file</cite> section in
- * <a href="http://sis.apache.org/build.html">Build from source</a> page
- * for more information.</p>
- *
- * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
- * @since   0.4
- */
-@Mojo(name = "dist", defaultPhase = LifecyclePhase.INSTALL, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME)
-public final class Assembler extends AbstractMojo implements FilenameFilter {
-    /**
-     * Project information (name, version, URL).
-     */
-    @Parameter(property="project", required=true, readonly=true)
-    private MavenProject project;
-
-    /**
-     * Base directory of the module to compile.
-     * Artifact content is expected in the {@code "src/main/artifact"} subdirectory.
-     */
-    @Parameter(property="basedir", required=true, readonly=true)
-    private String baseDirectory;
-
-    /**
-     * The root directory (without the "<code>target/binaries</code>" sub-directory) where JARs
-     * are to be copied. It should be the directory of the root <code>pom.xml</code>.
-     */
-    @Parameter(property="session.executionRootDirectory", required=true)
-    private String rootDirectory;
-
-    /**
-     * Invoked by reflection for creating the MOJO.
-     */
-    public Assembler() {
-    }
-
-    /**
-     * Creates the distribution file.
-     *
-     * @throws MojoExecutionException if the plugin execution failed.
-     */
-    @Override
-    public void execute() throws MojoExecutionException {
-        final File sourceDirectory = new File(baseDirectory, ARTIFACT_PATH);
-        if (!sourceDirectory.isDirectory()) {
-            throw new MojoExecutionException("Directory not found: " + sourceDirectory);
-        }
-        final String artifactBase = FINALNAME_PREFIX + project.getVersion();
-        final File targetFile = distributionFile(rootDirectory, artifactBase + ".zip");
-        try (ZipArchiveOutputStream zip = new ZipArchiveOutputStream(targetFile)) {
-            zip.setLevel(9);
-            appendRecursively(sourceDirectory, artifactBase, zip);
-            /*
-             * At this point, all the "application/sis-javafx/src/main/artifact" and sub-directories
-             * have been zipped. Now append the JAR files for each module and their dependencies.
-             */
-            final Map<String,byte[]> nativeFiles = new LinkedHashMap<>();
-            for (final File file : files(project)) {
-                ZipArchiveEntry entry = new ZipArchiveEntry(artifactBase + '/' + LIB_DIRECTORY + '/' + file.getName());
-                zip.putArchiveEntry(entry);
-                appendJAR(file, zip, nativeFiles);
-                zip.closeArchiveEntry();
-            }
-            /*
-             * At this point we finished creating all entries in the ZIP file, except native resources.
-             * Copy them now.
-             */
-            for (final Map.Entry<String,byte[]> nf : nativeFiles.entrySet()) {
-                ZipArchiveEntry entry = new ZipArchiveEntry(artifactBase + '/' + LIB_DIRECTORY + '/' + nf.getKey());
-                entry.setUnixMode(0555);        // Readable and executable for all, but not writable.
-                zip.putArchiveEntry(entry);
-                zip.write(nf.getValue());
-                zip.closeArchiveEntry();
-                nf.setValue(null);
-            }
-        } catch (IOException e) {
-            throw new MojoExecutionException(e.getLocalizedMessage(), e);
-        }
-    }
-
-    /**
-     * Returns all files to include for the given Maven project.
-     */
-    private static Set<File> files(final MavenProject project) throws MojoExecutionException {
-        final Set<File> files = new LinkedHashSet<>();
-        files.add(project.getArtifact().getFile());
-        for (final Artifact dep : project.getArtifacts()) {
-            final String scope = dep.getScope();
-            if (Artifact.SCOPE_COMPILE.equalsIgnoreCase(scope) ||
-                Artifact.SCOPE_RUNTIME.equalsIgnoreCase(scope))
-            {
-                files.add(dep.getFile());
-            }
-        }
-        if (files.remove(null)) {
-            throw new MojoExecutionException("Invocation of this MOJO shall be done together with a \"package\" Maven phase.");
-        }
-        return files;
-    }
-
-    /**
-     * Adds the given file in the ZIP file. If the given file is a directory, then this method
-     * recursively adds all files contained in this directory. This method is invoked for zipping
-     * the "application/sis-javafx/src/main/artifact" directory and sub-directories before to zip.
-     */
-    private void appendRecursively(final File file, String relativeFile, final ZipArchiveOutputStream out) throws IOException {
-        if (file.isDirectory()) {
-            relativeFile += '/';
-        }
-        final ZipArchiveEntry entry = new ZipArchiveEntry(file, relativeFile);
-        if (file.canExecute()) {
-            entry.setUnixMode(0744);
-        }
-        out.putArchiveEntry(entry);
-        if (!entry.isDirectory()) {
-            try (FileInputStream in = new FileInputStream(file)) {
-                in.transferTo(out);
-            }
-        }
-        out.closeArchiveEntry();
-        if (entry.isDirectory()) {
-            for (final String filename : file.list(this)) {
-                appendRecursively(new File(file, filename), relativeFile.concat(filename), out);
-            }
-        }
-    }
-
-    /**
-     * The filter to use for selecting the files to be included in the ZIP file.
-     *
-     * @param  directory  the directory.
-     * @param  filename   the filename.
-     * @return {@code true} if the given file should be included in the ZIP file.
-     */
-    @Override
-    public boolean accept(final File directory, final String filename) {
-        return !filename.isEmpty() && filename.charAt(0) != '.' && !filename.equals(CONTENT_FILE);
-    }
-
-    /**
-     * Returns {@code true} if the given JAR file contains at least one resource in the
-     * {@value Filenames#NATIVE_DIRECTORY} directory. Those files will need to be rewritten
-     * in order to exclude those resources.
-     */
-    private static boolean hasNativeResources(final ZipFile in) {
-        final Enumeration<? extends ZipEntry> entries = in.entries();
-        while (entries.hasMoreElements()) {
-            if (entries.nextElement().getName().startsWith(NATIVE_DIRECTORY)) {
-                return true;
-            }
-        }
-        return false;
-    }
-
-    /**
-     * Copies a JAR file in the given ZIP file, except the native resources which are stored in the given map.
-     *
-     * @param  file         the JAR file to copy.
-     * @param  bundle       destination where to copy the JAR file.
-     * @param  nativeFiles  where to store the native resources.
-     */
-    private void appendJAR(final File file, final ZipArchiveOutputStream bundle, final Map<String,byte[]> nativeFiles)
-            throws IOException
-    {
-        try (ZipFile in = new ZipFile(file)) {
-            if (hasNativeResources(in)) {
-                final ZipOutputStream out = new ZipOutputStream(bundle);
-                out.setLevel(9);
-                final Enumeration<? extends ZipEntry> entries = in.entries();
-                while (entries.hasMoreElements()) {
-                    final ZipEntry entry = entries.nextElement();
-                    final String    name = entry.getName();
-                    try (InputStream eis = in.getInputStream(entry)) {
-                        if (!name.startsWith(NATIVE_DIRECTORY)) {
-                            out.putNextEntry(new ZipEntry(name));
-                            eis.transferTo(out);                            // Copy the entry verbatim.
-                            out.closeEntry();
-                        } else if (!entry.isDirectory()) {
-                            final long size = entry.getSize();              // For memorizing the entry without copying it now.
-                            if (size <= 0 || size > Integer.MAX_VALUE) {
-                                throw new IOException(String.format("Errors while copying %s:%n"
-                                        + "Unsupported size for \"%s\" entry: %d", file, name, size));
-                            }
-                            final byte[] content = new byte[(int) size];
-                            final int actual = eis.read(content);
-                            if (actual != size) {
-                                throw new IOException(String.format("Errors while copying %s:%n"
-                                        + "Expected %d bytes in \"%s\" but found %d", file, size, name, actual));
-                            }
-                            if (nativeFiles.put(name.substring(NATIVE_DIRECTORY.length()), content) != null) {
-                                throw new IOException(String.format("Errors while copying %s:%n"
-                                        + "Duplicated entry: %s", file, name));
-                            }
-                        }
-                    }
-                }
-                out.finish();
-                return;
-            }
-        }
-        /*
-         * If the JAR file has no native resources to exclude, we can copy the JAR file verbatim.
-         * This is faster than inflating and deflating again the JAR file.
-         */
-        try (FileInputStream in = new FileInputStream(file)) {
-            in.transferTo(bundle);
-        }
-    }
-}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/maven/Filenames.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/maven/Filenames.java
deleted file mode 100644
index 828dfcecf5..0000000000
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/maven/Filenames.java
+++ /dev/null
@@ -1,111 +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.maven;
-
-import java.io.File;
-import org.apache.maven.plugin.MojoExecutionException;
-
-
-/**
- * Hard-coded file and directory names used by this package.
- *
- * <p><b>Reminder:</b>
- * if the above constants are modified, please remind to edit the <cite>Distribution file</cite>
- * section in the <code>site/content/build.mdtext</code> file.</p>
- *
- * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
- * @since   0.4
- */
-final class Filenames {
-    /**
-     * The target directory. This directory name is hard-coded instead of using a property annotated
-     * by {@code @Parameter(defaultValue="${project.build.directory}")} - or alternatively by invoking
-     * {@code MavenProject.getModel().getBuild().getDirectory()}, because we need the target directory
-     * of the project root rather than the directory of the module being built.
-     */
-    static final String TARGET_DIRECTORY = "target";
-
-    /**
-     * The sub-directory inside {@value #TARGET_DIRECTORY} for binaries.
-     */
-    static final String BINARIES_DIRECTORY = "binaries";
-
-    /**
-     * The name of the file inside {@value #BINARIES_DIRECTORY} where to list SIS JAR files and their
-     * dependencies on platforms that do not support hard links. Also the file to ignore when copying
-     * entries in a ZIP file.
-     */
-    static final String OTHER_DEPENDENCIES_FILE = "other_dependencies.txt";
-
-    /**
-     * The file to ignore when copying entries in a ZIP file.
-     * Those files appear in the {@value #ARTIFACT_PATH} directory.
-     */
-    static final String CONTENT_FILE = "content.txt";
-
-    /**
-     * The sub-directory inside {@value #TARGET_DIRECTORY} containing pack files.
-     * This directory will be automatically created if it does not already exist.
-     */
-    private static final String DISTRIBUTION_DIRECTORY = "distribution";
-
-    /**
-     * The path to the directory (relative to the project directory) to zip for creating the distribution ZIP file.
-     */
-    static final String ARTIFACT_PATH = "src/main/artifact";
-
-    /**
-     * The name of the sub-directory inside {@value #ARTIFACT_PATH} where the JAR files will be located.
-     * Note that we will not write in the real directory, but only in the directory structure which is
-     * reproduced in the ZIP file.
-     */
-    static final String LIB_DIRECTORY = "lib";
-
-    /**
-     * The prefix of native resources in JAR files. All those resources will be excluded from
-     * the JAR copied in the zip file and stored in a {@code nativeFiles} map instead.
-     */
-    static final String NATIVE_DIRECTORY = "native/";
-
-    /**
-     * The prefix of the final filename. This is hard coded for now.
-     */
-    static final String FINALNAME_PREFIX = "apache-sis-";
-
-    /**
-     * Do not allow instantiation of this class.
-     */
-    private Filenames() {
-    }
-
-    /**
-     * Returns the distribution file, creating its directory if needed.
-     *
-     * @param  rootDirectory  the project root directory.
-     * @param  filename       name of the file to create.
-     */
-    static File distributionFile(final String rootDirectory, final String filename) throws MojoExecutionException {
-        final File outDirectory = new File(new File(rootDirectory, TARGET_DIRECTORY), DISTRIBUTION_DIRECTORY);
-        if (!outDirectory.isDirectory()) {
-            if (!outDirectory.mkdir()) {
-                throw new MojoExecutionException("Cannot create the \"" + DISTRIBUTION_DIRECTORY + "\" directory.");
-            }
-        }
-        return new File(outDirectory, filename);
-    }
-}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/maven/JarCollector.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/maven/JarCollector.java
deleted file mode 100644
index 367f86a204..0000000000
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/maven/JarCollector.java
+++ /dev/null
@@ -1,282 +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.maven;
-
-import java.io.File;
-import java.io.FileFilter;
-import java.io.FileReader;
-import java.io.FileWriter;
-import java.io.BufferedReader;
-import java.io.BufferedWriter;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.nio.file.Files;
-import java.nio.file.FileSystemException;
-import java.util.Set;
-import java.util.LinkedHashSet;
-import org.apache.maven.plugin.AbstractMojo;
-import org.apache.maven.plugin.MojoExecutionException;
-import org.apache.maven.plugins.annotations.Mojo;
-import org.apache.maven.plugins.annotations.Parameter;
-import org.apache.maven.plugins.annotations.LifecyclePhase;
-import org.apache.maven.plugins.annotations.ResolutionScope;
-import org.apache.maven.project.MavenProject;
-import org.apache.maven.artifact.Artifact;
-
-import static org.apache.sis.internal.maven.Filenames.*;
-
-
-/**
- * Collects <code>.jar</code> files in a single "{@code target/binaries}" directory.
- * Dependencies are collected as well, except if already presents. This mojo uses hard links
- * on platforms that support them. If hard links are not supported, then this mojo will instead
- * creates a "{@code target/binaries/other_dependencies.txt}" file listing the dependencies.
- *
- * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
- * @since   0.3
- */
-@Mojo(name = "collect-jars",
-      defaultPhase = LifecyclePhase.PACKAGE,
-      requiresDependencyResolution = ResolutionScope.RUNTIME)
-public final class JarCollector extends AbstractMojo implements FileFilter {
-    /**
-     * The Maven project running this plugin.
-     */
-    @Parameter(property="project", required=true, readonly=true)
-    private MavenProject project;
-
-    /**
-     * The root directory (without the "<code>target/binaries</code>" sub-directory) where JARs
-     * are to be copied. It should be the directory of the root <code>pom.xml</code>.
-     */
-    @Parameter(property="session.executionRootDirectory", required=true)
-    private String rootDirectory;
-
-    /**
-     * Invoked by reflection for creating the MOJO.
-     */
-    public JarCollector() {
-    }
-
-    /**
-     * Copies the {@code *.jar} files to the collect directory.
-     *
-     * @throws MojoExecutionException if the plugin execution failed.
-     */
-    @Override
-    public void execute() throws MojoExecutionException {
-        if (rootDirectory == null || rootDirectory.startsWith("${")) {
-            getLog().warn("Unresolved directory: " + rootDirectory);
-            return;
-        }
-        /*
-         * Now collects the JARs.
-         */
-        try {
-            collect();
-        } catch (IOException e) {
-            throw new MojoExecutionException("Error collecting the JAR files.", e);
-        }
-    }
-
-    /**
-     * Implementation of the {@link #execute()} method.
-     */
-    private void collect() throws MojoExecutionException, IOException {
-        /*
-         * Make sure that we are collecting the JAR file from a module which produced
-         * such file. Some modules use pom packaging, which do not produce any JAR file.
-         */
-        final File jarFile = getProjectFile();
-        if (jarFile == null) {
-            return;
-        }
-        /*
-         * Get the "target" directory of the parent pom.xml and make sure it exists.
-         */
-        File collect = new File(rootDirectory, TARGET_DIRECTORY);
-        if (!collect.exists()) {
-            if (!collect.mkdir()) {
-                throw new MojoExecutionException("Failed to create \"" + TARGET_DIRECTORY + "\" directory.");
-            }
-        }
-        if (collect.getCanonicalFile().equals(jarFile.getParentFile().getCanonicalFile())) {
-            /*
-             * The parent's directory is the same one than this module's directory.
-             * In other words, this plugin is not executed from the parent POM. Do
-             * not copy anything, since this is not the place where we want to
-             * collect the JAR files.
-             */
-            return;
-        }
-        /*
-         * Creates a "binaries" subdirectory inside the "target" directory, then copy the
-         * JAR file compiled by Maven. If an JAR file already existed, it will be deleted.
-         */
-        collect = new File(collect, BINARIES_DIRECTORY);
-        if (!collect.exists()) {
-            if (!collect.mkdir()) {
-                throw new MojoExecutionException("Failed to create \"" + BINARIES_DIRECTORY + "\" directory.");
-            }
-        }
-        File copy = new File(collect, jarFile.getName());
-        copy.delete();
-        linkFileToDirectory(jarFile, copy);
-        /*
-         * Copies the dependencies.
-         */
-        final Set<Artifact> dependencies = project.getArtifacts();
-        if (dependencies != null) {
-            for (final Artifact artifact : dependencies) {
-                final String scope = artifact.getScope();
-                if (scope != null &&  // Maven 2.0.6 bug?
-                   (scope.equalsIgnoreCase(Artifact.SCOPE_COMPILE) ||
-                    scope.equalsIgnoreCase(Artifact.SCOPE_RUNTIME)))
-                {
-                    final File file = artifact.getFile();
-                    if (file != null) {                     // I'm not sure why the file is sometimes null.
-                        copy = new File(collect, getFinalName(file, artifact));
-                        if (!copy.exists()) {
-                            /*
-                             * Copies the dependency only if it was not already copied. Note that
-                             * the module's JAR was copied unconditionally above (because it may
-                             * be the result of a new compilation).
-                             */
-                            linkFileToDirectory(file, copy);
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    /**
-     * Filters the content of the "target" directory in order to keep only the project
-     * build result. We scan the directory because the final name may be different than
-     * the actual file name, because a classifier may have been added to the name.
-     *
-     * <p>The {@code .jar} extension is not quite appropriate for source and Javadoc files;
-     * a better extension would be {@code .zip}. Unfortunately, the {@code .jar} extension
-     * for those content is a very common practice, so we have to filter them.</p>
-     */
-    @Override
-    public boolean accept(final File pathname) {
-        final String name = pathname.getName();
-        return name.endsWith(".jar")         &&
-              !name.endsWith("-tests.jar")   &&
-              !name.endsWith("-sources.jar") &&
-              !name.endsWith("-javadoc.jar") &&
-               name.startsWith(project.getBuild().getFinalName()) &&
-               pathname.isFile();
-    }
-
-    /**
-     * Returns the JAR file, or {@code null} if none.
-     * In case of doubt, conservatively returns {@code null}.
-     */
-    private File getProjectFile() {
-        final File[] files = new File(project.getBuild().getDirectory()).listFiles(this);
-        if (files != null && files.length == 1) {
-            return files[0];
-        }
-        return null;
-    }
-
-    /**
-     * Returns the name of the given file. If the given file is a snapshot, then the
-     * {@code "SNAPSHOT"} will be replaced by the timestamp if possible.
-     *
-     * @param  file      the file from which to get the filename.
-     * @param  artifact  the artifact that produced the given file.
-     * @return the filename to use.
-     */
-    private static String getFinalName(final File file, final Artifact artifact) {
-        String filename = file.getName();
-        final String baseVersion = artifact.getBaseVersion();
-        if (baseVersion != null) {
-            final int pos = filename.lastIndexOf(baseVersion);
-            if (pos >= 0) {
-                final String version = artifact.getVersion();
-                if (version != null && !baseVersion.equals(version)) {
-                    filename = filename.substring(0, pos) + version + filename.substring(pos + baseVersion.length());
-                }
-            }
-        }
-        return filename;
-    }
-
-    /**
-     * Creates a link from the given source file to the given target file.
-     * On platforms that do not support links, this method rather updates
-     * the {@value Filenames#OTHER_DEPENDENCIES_FILE} file.
-     *
-     * @param  file  the source file to read.
-     * @param  copy  the destination file to create.
-     */
-    private static void linkFileToDirectory(final File file, final File copy) throws IOException {
-        final Path source = file.toPath();
-        final Path target = copy.toPath();
-        try {
-            Files.createLink(target, source);
-            return;
-        } catch (UnsupportedOperationException | FileSystemException e) {
-            /*
-             * If hard links are not supported, edit the "other_dependencies.txt" file instead.
-             * Note that a hard link may be unsupported because the source and target
-             * are on different Windows drives or mount points, in which case we get
-             * a FileSystemException instead of UnsupportedOperationException.
-             */
-        }
-        /*
-         * If we cannot use hard links, creates or updates a "target/other_dependencies.txt" file instead.
-         * This file will contains the list of all dependencies, without duplicated values.
-         */
-        final File dependenciesFile = new File(copy.getParentFile(), OTHER_DEPENDENCIES_FILE);
-        final Set<String> dependencies = loadDependencyList(dependenciesFile);
-        if (dependencies.add(file.getPath())) {
-            // Save the dependencies list only if it has been modified.
-            try (BufferedWriter out = new BufferedWriter(new FileWriter(dependenciesFile))) {
-                for (final String dependency : dependencies) {
-                    out.write(dependency);
-                    out.newLine();
-                }
-            }
-        }
-    }
-
-    /**
-     * Loads the {@value #OTHER_DEPENDENCIES_FILE} from the given directory, if it exists.
-     * Otherwise returns an empty but modifiable set. This method is invoked on platforms
-     * that do not support hard links.
-     */
-    private static Set<String> loadDependencyList(final File dependenciesFile) throws IOException {
-        final Set<String> dependencies = new LinkedHashSet<>();
-        if (dependenciesFile.exists()) {
-            try (BufferedReader in = new BufferedReader(new FileReader(dependenciesFile))) {
-                String line;
-                while ((line = in.readLine()) != null) {
-                    line = line.trim();
-                    if (!line.isEmpty()) {
-                        dependencies.add(line);
-                    }
-                }
-            }
-        }
-        return dependencies;
-    }
-}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/unopkg/JavaMaker.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/unopkg/JavaMaker.java
deleted file mode 100644
index 7fd86e08a8..0000000000
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/unopkg/JavaMaker.java
+++ /dev/null
@@ -1,148 +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.unopkg;
-
-import java.io.IOException;
-import java.nio.file.Path;
-import java.nio.file.Files;
-import java.nio.file.FileVisitResult;
-import java.nio.file.SimpleFileVisitor;
-import java.nio.file.StandardCopyOption;
-import java.nio.file.attribute.BasicFileAttributes;
-
-import org.apache.maven.plugin.AbstractMojo;
-import org.apache.maven.plugin.MojoExecutionException;
-import org.apache.maven.plugins.annotations.LifecyclePhase;
-import org.apache.maven.plugins.annotations.Mojo;
-import org.apache.maven.plugins.annotations.Parameter;
-
-
-/**
- * Compiles Java interfaces from OpenOffice IDL files.
- *
- * <p>In an ideal world, this plugin would execute {@code idlc} on the {@code *.idl} files,
- * then {@code regmerge} on the generated {@code *.urd} files,
- * then {@code javamaker} on the generated {@code *.rdb} files.
- * However, since the above mentioned tools are native and would require a manual installation
- * on every developer machine, current version just copies a pre-compiled class file.
- * This copy must occurs after the compilation phase (in order to overwrite the files generated
- * by {@code javac}), which is why the usual Maven resources mechanism doesn't fit.
- *
- * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 1.0
- * @since   0.8
- */
-@Mojo(name = "javamaker", defaultPhase = LifecyclePhase.PROCESS_CLASSES)
-public final class JavaMaker extends AbstractMojo {
-    /**
-     * Base directory of the module to compile.
-     */
-    @Parameter(property="basedir", required=true, readonly=true)
-    private String baseDirectory;
-
-    /**
-     * Directory where the output Java files will be located.
-     */
-    @Parameter(property="project.build.outputDirectory", required=true, readonly=true)
-    private String outputDirectory;
-
-    /**
-     * Invoked by reflection for creating the MOJO.
-     */
-    public JavaMaker() {
-    }
-
-    /**
-     * Copies the {@code .class} files generated by OpenOffice.
-     *
-     * @throws MojoExecutionException if the plugin execution failed.
-     */
-    @Override
-    public void execute() throws MojoExecutionException {
-        final Copier c = new Copier(baseDirectory, outputDirectory);
-        try {
-            c.run();
-        } catch (IOException e) {
-            throw new MojoExecutionException("Failed to copy *.class files.", e);
-        }
-        getLog().info("[geotk-unopkg] Copied " + c.count + " pre-compiled class files.");
-    }
-
-    /**
-     * Copies {@code *.class} files from source directory to output directory.
-     * The output directory should already exist. It should be the case if all
-     * sources files have been compiled before this method is invoked.
-     */
-    private static final class Copier extends SimpleFileVisitor<Path> {
-        /**
-         * The root of source and target directories. Files below {@code source} will be copied
-         * with identical path (relative to {@code source}) under {@code target} directory.
-         */
-        private final Path source, target;
-
-        /**
-         * Number of files copied.
-         */
-        int count;
-
-        /**
-         * Creates a new copier.
-         *
-         * @param baseDirectory    base directory of the module to compile.
-         * @param outputDirectory  directory where the output Java files will be located.
-         */
-        Copier(final String baseDirectory, final String outputDirectory) {
-            source = Path.of(baseDirectory).resolve(UnoPkg.SOURCE_DIRECTORY);
-            target = Path.of(outputDirectory);
-        }
-
-        /**
-         * Executes the copy operation.
-         */
-        void run() throws IOException {
-            Files.walkFileTree(source, this);
-        }
-
-        /**
-         * Determines whether the given directory should be visited.
-         * This method skips hidden directories.
-         */
-        @Override
-        public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) {
-            return dir.getFileName().toString().startsWith(".") ? FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE;
-        }
-
-        /**
-         * Invoked for a file in a directory. This method creates the directory if it does not exist
-         * and performs the actual copy operation.
-         */
-        @Override
-        public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException {
-            final String filename = file.getFileName().toString();
-            if (filename.endsWith(".class") || filename.endsWith(".CLASS")) {
-                final Path dst = target.resolve(source.relativize(file)).normalize();
-                if (!dst.startsWith(target)) {
-                    throw new IOException("Unexpected target path: " + dst);
-                }
-                Files.createDirectories(dst.getParent());
-                Files.copy(file, dst, StandardCopyOption.REPLACE_EXISTING);
-                count++;
-            }
-            return FileVisitResult.CONTINUE;
-        }
-    }
-}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/unopkg/UnoPkg.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/unopkg/UnoPkg.java
deleted file mode 100644
index 7ff07b06af..0000000000
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/unopkg/UnoPkg.java
+++ /dev/null
@@ -1,295 +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.unopkg;
-
-import java.io.*;
-import java.util.Enumeration;
-import java.util.zip.CRC32;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipFile;
-import java.util.zip.ZipOutputStream;
-
-import org.apache.maven.plugin.AbstractMojo;
-import org.apache.maven.plugin.MojoExecutionException;
-import org.apache.maven.project.MavenProject;
-import org.apache.maven.artifact.Artifact;
-import org.apache.maven.plugins.annotations.LifecyclePhase;
-import org.apache.maven.plugins.annotations.Mojo;
-import org.apache.maven.plugins.annotations.Parameter;
-import org.apache.maven.plugins.annotations.ResolutionScope;
-
-
-/**
- * Creates an {@code .oxt} package for <a href="http://www.openoffice.org">OpenOffice.org</a> addins.
- *
- * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 1.0
- * @since   0.8
- */
-@Mojo(name = "unopkg", defaultPhase = LifecyclePhase.PACKAGE, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME)
-public final class UnoPkg extends AbstractMojo implements FilenameFilter {
-    /**
-     * The subdirectory (relative to {@link #baseDirectory}) where the UNO files are expected.
-     */
-    static final String SOURCE_DIRECTORY = "src/main/unopkg";
-
-    /**
-     * Module to decompress. We inflate the {@value} module because Derby is much slower
-     * when using an embedded database in a compressed ZIP file compared to flat storage.
-     * Since the JAR files are distributed in a ZIP file anyway, inflating that file has
-     * little impact on the final ZIP file size.
-     */
-    private static final String TO_INFLATE = "sis-embedded-data";
-
-    /**
-     * The encoding for text files to read and write.
-     */
-    private static final String ENCODING = "UTF-8";
-
-    /**
-     * The string to replace by the final name.
-     */
-    private static final String FILTERED_NAME = "${project.build.finalName}";
-
-    /**
-     * The string to replace by the version number.
-     */
-    private static final String FILTERED_VERSION = "${project.version}";
-
-    /**
-     * Base directory of the module to compile.
-     * The UNO files are expected in the {@code "src/main/unopkg"} subdirectory.
-     * The plugin will look for the {@code META-INF/manifest.xml} and {@code *.rdb} files in that directory.
-     */
-    @Parameter(property="basedir", required=true, readonly=true)
-    private String baseDirectory;
-
-    /**
-     * Directory where the output {@code .oxt} file will be located.
-     */
-    @Parameter(property="project.build.directory", required=true, readonly=true)
-    private String outputDirectory;
-
-    /**
-     * In {@code META-INF/manifest.xml}, all occurrences of {@code ${project.build.finalName}}
-     * will be replaced by this value.
-     */
-    @Parameter(property="project.build.finalName", required=true, readonly=true)
-    private String finalName;
-
-    /**
-     * In {@code description.xml}, all occurrences of {@code ${project.version}}
-     * will be replaced by this value.
-     */
-    @Parameter(property="project.version", required=true, readonly=true)
-    private String version;
-
-    /**
-     * The name for the {@code .oxt} file to create, without the {@code ".oxt"} filename extension.
-     */
-    @Parameter(property="project.build.finalName", required=true, readonly=true)
-    private String oxtName;
-
-    /**
-     * The Maven project running this plugin.
-     */
-    @Parameter(property="project", required=true, readonly=true)
-    private MavenProject project;
-
-    /**
-     * Invoked by reflection for creating the MOJO.
-     */
-    public UnoPkg() {
-    }
-
-    /**
-     * Tests if a specified file should be included in a file list.
-     *
-     * @param  directory  the directory in which the file was found.
-     * @param  name       the name of the file.
-     */
-    @Override
-    public boolean accept(final File directory, final String name) {
-        if (name.endsWith("-sources.jar") || name.endsWith("-tests.jar") || name.endsWith("-javadoc.jar")) {
-            return false;
-        }
-        return name.endsWith(".jar") || name.endsWith(".JAR") ||
-               name.endsWith(".rdb") || name.endsWith(".RDB") ||
-               name.endsWith(".xml") || name.endsWith(".XML") ||
-               name.endsWith(".xcu") || name.endsWith(".XCU") ||
-               name.endsWith(".txt") || name.endsWith(".TXT") ||
-               name.endsWith(".png") || name.endsWith(".PNG");
-    }
-
-    /**
-     * Generates the {@code .oxt} file from all {@code .jar} files found in the target directory.
-     *
-     * @throws MojoExecutionException if the plugin execution failed.
-     */
-    @Override
-    public void execute() throws MojoExecutionException {
-        final String  manifestName = "META-INF/manifest.xml";
-        final File sourceDirectory = new File(baseDirectory, SOURCE_DIRECTORY);
-        final File outputDirectory = new File(this.outputDirectory);
-        final File         zipFile = new File(outputDirectory, oxtName + ".oxt");
-        final File    manifestFile = new File(sourceDirectory, manifestName);
-        final File[]          jars = outputDirectory.listFiles(this);
-        final File[]          rdbs = sourceDirectory.listFiles(this);
-        try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(zipFile))) {
-            out.setLevel(9);
-            if (manifestFile.isFile()) {
-                copyFiltered(manifestFile, out, manifestName);
-            }
-            /*
-             * Copies files from "unopkg" source directory. This include binary files like RDB,
-             * but also XML file. We apply filtering on the "description.xml" file in order to
-             * set the version number automatically.
-             */
-            for (final File rdb : rdbs) {
-                final String name = rdb.getName();
-                if (name.endsWith(".xml")) {
-                    copyFiltered(rdb, out, name);
-                } else {
-                    copy(rdb, out);
-                }
-            }
-            /*
-             * Copies the JAR for this module and any additional JARs provided in the output directory.
-             */
-            for (final File jar : jars) {
-                copy(jar, out);
-            }
-            /*
-             * Copies the dependencies.
-             */
-            for (final Artifact artifact : project.getArtifacts()) {
-                final String scope = artifact.getScope();
-                if (Artifact.SCOPE_COMPILE.equalsIgnoreCase(scope) ||
-                    Artifact.SCOPE_RUNTIME.equalsIgnoreCase(scope))
-                {
-                    final File file = artifact.getFile();
-                    final String name = file.getName();
-                    if (name.startsWith(TO_INFLATE)) {
-                        copyInflated(file, out);
-                    } else {
-                        copy(file, out);
-                    }
-                }
-            }
-        } catch (IOException e) {
-            throw new MojoExecutionException("Error creating the oxt file.", e);
-        }
-    }
-
-    /**
-     * Copies the content of the specified binary file to the specified output stream.
-     *
-     * @param  file    the regular file to copy inside the ZIP file.
-     * @param  bundle  the ZIP file where to copy the given regular file.
-     */
-    private static void copy(final File file, final ZipOutputStream bundle) throws IOException {
-        final String name = file.getName();
-        final ZipEntry entry = new ZipEntry(name);
-        if (name.endsWith(".png")) {
-            final long size = file.length();
-            entry.setMethod(ZipOutputStream.STORED);
-            entry.setSize(size);
-            entry.setCompressedSize(size);
-            entry.setCrc(getCRC32(file));
-        }
-        bundle.putNextEntry(entry);
-        try (InputStream in = new FileInputStream(file)) {
-            in.transferTo(bundle);
-        }
-        bundle.closeEntry();
-    }
-
-    /**
-     * Copies the content of the specified ASCII file to the specified output stream.
-     *
-     * @param  file    the regular file to copy inside the ZIP file.
-     * @param  bundle  the ZIP file where to copy the given regular file.
-     * @param  name    the ZIP entry name.
-     */
-    private void copyFiltered(final File file, final ZipOutputStream bundle, final String name) throws IOException {
-        final ZipEntry entry = new ZipEntry(name);
-        bundle.putNextEntry(entry);
-        final Writer writer = new OutputStreamWriter(bundle, ENCODING);
-        try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream(file), ENCODING))) {
-            String line;
-            while ((line = in.readLine()) != null) {
-                line = line.replace(FILTERED_NAME, finalName);
-                line = line.replace(FILTERED_VERSION, version);
-                writer.write(line);
-                writer.write('\n');
-            }
-        }
-        writer.flush();
-        bundle.closeEntry();
-    }
-
-    /**
-     * Copies a JAR file in the given ZIP file, but without compression for the files
-     * in {@code SIS_DATA} directory.
-     *
-     * @param  file    the JAR file to copy.
-     * @param  bundle  destination where to copy the JAR file.
-     */
-    private static void copyInflated(final File file, final ZipOutputStream bundle) throws IOException {
-        final ZipEntry entry = new ZipEntry(file.getName());
-        bundle.putNextEntry(entry);
-        final ZipOutputStream out = new ZipOutputStream(bundle);
-        out.setLevel(9);
-        try (ZipFile in = new ZipFile(file)) {
-            final Enumeration<? extends ZipEntry> entries = in.entries();
-            while (entries.hasMoreElements()) {
-                final ZipEntry   inEntry  = entries.nextElement();
-                final String     name     = inEntry.getName();
-                final ZipEntry   outEntry = new ZipEntry(name);
-                if (name.startsWith("SIS_DATA")) {
-                    final long size = inEntry.getSize();
-                    outEntry.setMethod(ZipOutputStream.STORED);
-                    outEntry.setSize(size);
-                    outEntry.setCompressedSize(size);
-                    outEntry.setCrc(inEntry.getCrc());
-                }
-                try (InputStream inStream = in.getInputStream(inEntry)) {
-                    out.putNextEntry(outEntry);
-                    inStream.transferTo(out);
-                    out.closeEntry();
-                }
-            }
-        }
-        out.finish();
-        bundle.closeEntry();
-    }
-
-    /**
-     * Computes CRC32 for the given file.
-     */
-    private static long getCRC32(final File file) throws IOException {
-        final CRC32 crc = new CRC32();
-        try (InputStream in = new FileInputStream(file)) {
-            final byte[] buffer = new byte[4*1024];
-            int length;
-            while ((length = in.read(buffer)) >= 0) {
-                crc.update(buffer, 0, length);
-            }
-        }
-        return crc.getValue();
-    }
-}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/unopkg/package-info.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/unopkg/package-info.java
deleted file mode 100644
index ae5e6e1317..0000000000
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/unopkg/package-info.java
+++ /dev/null
@@ -1,87 +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.
- */
-
-
-/**
- * Creates a {@code .oxt} file for <a href="http://www.openoffice.org">OpenOffice.org</a> add-in.
- * Those files are basically ZIP files with {@code META-INF/manifest.xml} and {@code .rdb} entries.
- * The principle is similar to Java JAR files.
- *
- * <p>This Maven plugin has be written for building the Apache SIS add-in for OpenOffice.org,
- * but can also be used for other projects. See the Apache SIS add-in module for an example
- * about how a module can be configured in order to use this Maven plugin.</p>
- *
- * <h2>Usage</h2>
- * Below is an example of {@code pom.xml} build configuration.
- * The following strings need to be replaced:
- *
- * <ul>
- *   <li>{@code com.myproject.Registration} —
- *       The fully qualified class name of your class which contain the {@code __getServiceFactory(…)}
- *       and {@code __writeRegistryServiceInfo(…)} public static methods.</li>
- *   <li>{@code myAddinFilename} —
- *       The final filename, without the {@code .oxt} extension.</li>
- * </ul>
- *
- * <h2>Maven project file</h2>
- * {@snippet lang="xml" :
- *   <dependencies>
- *     <!-- Put all your project dependencies here, including transitive dependencies. -->
- *   </dependencies>
- *
- *   <build>
- *     <plugins>
- *       <!-- Add a manifest entry for add-ins registration in OpenOffice -->
- *       <plugin>
- *         <groupId>org.apache.maven.plugins</groupId>
- *         <artifactId>maven-jar-plugin</artifactId>
- *         <configuration>
- *           <archive>
- *             <manifestEntries>
- *               <RegistrationClassName>
- *                 com.myproject.Registration
- *               </RegistrationClassName>
- *             </manifestEntries>
- *           </archive>
- *         </configuration>
- *       </plugin>
- *
- *       <!-- Create the oxt file. -->
- *       <plugin>
- *         <groupId>org.apache.sis.core</groupId>
- *         <artifactId>sis-build-helper</artifactId>
- *         <configuration>
- *           <oxtName>myAddinFilename</oxtName>
- *         </configuration>
- *         <executions>
- *           <execution>
- *             <goals>
- *               <goal>javamaker</goal>
- *               <goal>unopkg</goal>
- *             </goals>
- *           </execution>
- *         </executions>
- *       </plugin>
- *     </plugins>
- *   </build>
- * }
- *
- * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 1.0
- * @since   0.8
- */
-package org.apache.sis.internal.unopkg;
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/util/resources/ResourceCompilerMojo.java b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/util/resources/ResourceCompilerMojo.java
deleted file mode 100644
index 826374573d..0000000000
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/util/resources/ResourceCompilerMojo.java
+++ /dev/null
@@ -1,283 +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.util.resources;
-
-import java.io.File;
-import java.io.FilenameFilter;
-import java.util.Arrays;
-import java.util.List;
-
-import org.apache.maven.model.Resource;
-import org.apache.maven.plugin.AbstractMojo;
-import org.apache.maven.plugin.MojoExecutionException;
-import org.apache.maven.plugins.annotations.Mojo;
-import org.apache.maven.plugins.annotations.Component;
-import org.apache.maven.plugins.annotations.Parameter;
-import org.apache.maven.plugins.annotations.LifecyclePhase;
-import org.apache.maven.project.MavenProject;
-import org.codehaus.plexus.util.Scanner;
-import org.sonatype.plexus.build.incremental.BuildContext;
-
-import static org.apache.sis.util.resources.IndexedResourceCompiler.JAVA_EXT;
-import static org.apache.sis.util.resources.IndexedResourceCompiler.PROPERTIES_EXT;
-
-
-/**
- * Compiles the international resources that are found in the module from which this mojo is invoked.
- * See the <code>sis-build-helper</code> module for more information.
- *
- * @author  Martin Desruisseaux (Geomatys)
- * @author  Olivier Nouguier (Geomatys)
- * @version 0.7
- * @since   0.3
- */
-@Mojo(name = "compile-resources", defaultPhase = LifecyclePhase.GENERATE_RESOURCES)
-public class ResourceCompilerMojo extends AbstractMojo implements FilenameFilter {
-    /**
-     * Pattern to filter properties files that were modified.
-     */
-    private static final String[] PROPERTIES_PATTERN = new String[] {"**/*.properties"};
-
-    /**
-     * Project information (name, version, URL).
-     */
-    @Parameter(property="project", required=true, readonly=true)
-    private MavenProject project;
-
-    /**
-     * A handler for the Eclipse workspace, used for declaring new resources.
-     * When Maven is run from the command line, this object does nothing.
-     *
-     * @see <a href="http://wiki.eclipse.org/M2E_compatible_maven_plugins">M2E compatible maven plugins</a>
-     */
-    @Component
-    private BuildContext buildContext;
-
-    /**
-     * The source directories containing the sources to be compiled.
-     */
-    @Parameter(property="project.compileSourceRoots", required=true, readonly=true)
-    private List<String> compileSourceRoots;
-
-    /**
-     * Directory containing the generated class files.
-     *
-     * <p><b>Note:</b> at the time of writing, we found no well-established convention for generated resources.
-     * The conventions that we found were rather for generated sources. In the latter case, the conventions use
-     * a different directory for each Maven plugin, e.g. <code>"generated-sources/xxx"</code>. But in our case
-     * (for resources), such separation seems of limited use since the resources are copied verbatim in the JAR
-     * file, so preventing clash in the <code>generated-resources</code> directory would not prevent clash in
-     * the JAR file anyway.</p>
-     */
-    @Parameter(defaultValue="${project.build.directory}/generated-resources", required=true)
-    private File outputDirectory;
-
-    /**
-     * The <code>compileSourceRoots</code> named "java" as a <code>File</code>.
-     */
-    private File javaDirectoryFile;
-
-    /**
-     * Constructs a new resource compiler MOJO.
-     */
-    public ResourceCompilerMojo() {
-    }
-
-    /**
-     * Executes the mojo.
-     *
-     * @throws MojoExecutionException if the plugin execution failed.
-     */
-    @Override
-    public void execute() throws MojoExecutionException {
-        final boolean isIncremental = buildContext.isIncremental();
-        declareOutputDirectory();
-
-        int errors = 0;
-        for (final String sourceDirectory : compileSourceRoots) {
-            final File directory = new File(sourceDirectory);
-            if (directory.getName().equals("java")) {
-                /*
-                 * Check if we can skip the resources compilation (Eclipse environment only).
-                 *
-                 * Scanner.getIncludedFiles() returns an array of modified files. For now we ignore the array
-                 * content and unconditionally re-compile all resource files as soon as at least one file has
-                 * been modified. This is okay for now since changes in resource files are rare and compiling
-                 * them is very fast.
-                 */
-                if (isIncremental) {
-                    Scanner scanner = buildContext.newScanner(directory);
-                    scanner.setIncludes(PROPERTIES_PATTERN);
-                    scanner.scan();
-                    final String[] includedFiles = scanner.getIncludedFiles();
-                    if (includedFiles == null || includedFiles.length == 0) {
-                        continue;
-                    }
-                }
-                javaDirectoryFile = directory;
-                errors += processAllResourceDirectories(directory);
-                buildContext.refresh(outputDirectory);
-            }
-        }
-        if (errors != 0) {
-            throw new ResourceCompilerException(String.valueOf(errors) + " errors in resources bundles.");
-        }
-    }
-
-    /**
-     * Declares {@link #outputDirectory} as resource, for inclusion by Maven in the JAR file.
-     */
-    private void declareOutputDirectory() {
-        final Resource resource = new Resource();
-        resource.setDirectory(outputDirectory.getPath());
-        project.addResource(resource);
-    }
-
-    /**
-     * Recursively scans the directories and find all Java classes having a property files of the same name.
-     * Then invokes the resource compiler for those files.
-     */
-    private int processAllResourceDirectories(final File directory) throws ResourceCompilerException {
-        int errors = 0;
-        final File[] subdirs = directory.listFiles();
-        if (subdirs != null) {                  // Appears to be sometimes null with auto-generated sub-directories.
-            for (final File subdir : subdirs) {
-                if (subdir.isDirectory()) {
-                    File[] resourcesToProcess = subdir.listFiles(this);
-                    int count = filterLanguages(resourcesToProcess);
-                    if (count != 0) {
-                        count = toJavaSourceFiles(resourcesToProcess, count);
-                        if (count != 0) {
-                            resourcesToProcess = Arrays.copyOf(resourcesToProcess, count);
-                            errors += new Compiler(resourcesToProcess).run();
-                        }
-                    }
-                    errors += processAllResourceDirectories(subdir);
-                }
-            }
-        }
-        return errors;
-    }
-
-    /**
-     * Accepts all {@code "*.properties"} files.
-     *
-     * @param  directory  the directory.
-     * @param  name       the file name.
-     * @return {@code true} if the given file is a property file.
-     */
-    @Override
-    public final boolean accept(final File directory, final String name) {
-        return name.endsWith(PROPERTIES_EXT);
-    }
-
-    /**
-     * Retains only the properties files which seems to be about internationalized resources.
-     * For example if the given array contains the following files:
-     * <ul>
-     *   <li>{@code "Errors.properties"}</li>
-     *   <li>{@code "Errors_en.properties"}</li>
-     *   <li>{@code "Errors_fr.properties"}</li>
-     *   <li>{@code "Messages.properties"}</li>
-     *   <li>{@code "Messages_en.properties"}</li>
-     *   <li>{@code "Messages_fr.properties"}</li>
-     *   <li>{@code "NotAnInternationalResource.properties"}</li>
-     * </ul>
-     *
-     * Then this method will retain the following files:
-     * <ul>
-     *   <li>{@code "Errors.properties"}</li>
-     *   <li>{@code "Messages.properties"}</li>
-     * </ul>
-     *
-     * @param  resourcesToProcess  the files to filter. This array will be overwritten in-place.
-     * @return number of valid elements in the {@code resourcesToProcess} after this method completion.
-     */
-    static int filterLanguages(final File[] resourcesToProcess) {
-        int count = 0;
-        if (resourcesToProcess != null) {
-            Arrays.sort(resourcesToProcess);
-            for (int i=0; i<resourcesToProcess.length;) {
-                final File file = resourcesToProcess[i];
-                String name = file.getName();
-                name = name.substring(0, name.length() - PROPERTIES_EXT.length()) + '_';
-                final int fileIndex = i;
-                while (++i < resourcesToProcess.length) {
-                    if (!resourcesToProcess[i].getName().startsWith(name)) {
-                        break;
-                    }
-                }
-                /*
-                 * Accepts the property file only if we found at least one language.
-                 * Example: "Messages.properties" and "Messages_en.properties".
-                 */
-                if (i - fileIndex >= 2) {
-                    resourcesToProcess[count++] = file;
-                }
-            }
-        }
-        return count;
-    }
-
-    /**
-     * Converts the given property files into Java source file, provided that the latter exists.
-     * The given array is overwritten in place.
-     *
-     * @param  resourcesToProcess  the filtered resource files, as returned by {@link #filterLanguages(File[])}.
-     * @param  count  number of valid elements in {@code resourcesToProcess}.
-     * @return number of valid elements after this method completion.
-     */
-    private static int toJavaSourceFiles(final File[] resourcesToProcess, final int count) {
-        int n = 0;
-        for (int i=0; i<count; i++) {
-            File file = resourcesToProcess[i];
-            String name = file.getName();
-            name = name.substring(0, name.length() - PROPERTIES_EXT.length());
-            name += JAVA_EXT;
-            file = new File(file.getParentFile(), name);
-            if (file.isFile()) {
-                resourcesToProcess[n++] = file;
-            }
-        }
-        return n;
-    }
-
-    /**
-     * A resource compiler that delegates the messages to the Mojo logger.
-     */
-    private final class Compiler extends IndexedResourceCompiler {
-        Compiler(File[] resourcesToProcess) {
-            super(javaDirectoryFile, outputDirectory, resourcesToProcess);
-        }
-
-        /**
-         * Logs the given message at the {@code INFO} level.
-         */
-        @Override
-        protected void info(final String message) {
-            getLog().info(message);
-        }
-
-        /**
-         * Logs the given message at the {@code WARNING} level.
-         */
-        @Override
-        protected void warning(final String message) {
-            getLog().warn(message);
-        }
-    }
-}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/util/resources/package-info.txt b/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/util/resources/package-info.txt
deleted file mode 100644
index a0130859a7..0000000000
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/util/resources/package-info.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-This directory has no "package-info.java" file
-because it is defined in the sis-utility module.
diff --git a/buildSrc/src/org.apache.sis.buildtools/test/org/apache/sis/buildtools/resources/IndexedResourceCompilerTest.java b/buildSrc/src/org.apache.sis.buildtools/test/org/apache/sis/buildtools/resources/IndexedResourceCompilerTest.java
new file mode 100644
index 0000000000..b845e934df
--- /dev/null
+++ b/buildSrc/src/org.apache.sis.buildtools/test/org/apache/sis/buildtools/resources/IndexedResourceCompilerTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.buildtools.resources;
+
+import java.io.File;
+import java.util.Map;
+import java.util.TreeMap;
+import org.junit.Test;
+
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertEquals;
+
+
+/**
+ * Tests {@link IndexedResourceCompiler}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @author  Quentin Bialota (Geomatys)
+ * @version 1.4
+ * @since   0.5
+ */
+public class IndexedResourceCompilerTest {
+    /**
+     * Tests {@link ResourceCompiler#filterLanguages(File[])}.
+     */
+    @Test
+    public void testFilterLanguages() {
+        final var resourcesToProcess = new TreeMap<File,Boolean>();
+        for (final String file : new String[] {
+                "Errors_en.properties",
+                "Messages_fr.properties",
+                "Errors.properties",
+                "Errors_fr.properties",
+                "ShallIgnore.properties",
+                "Messages_en.properties",
+                "Messages.properties"})
+        {
+            assertNull(resourcesToProcess.put(new File(file), Boolean.FALSE));
+        }
+        IndexedResourceCompiler.filterLanguages(resourcesToProcess, false);
+        final var it = resourcesToProcess.entrySet().iterator();
+        Map.Entry<File,Boolean> entry;
+
+        entry = it.next(); assertEquals("Errors.properties",      entry.getKey().getName()); assertTrue (entry.getValue());
+        entry = it.next(); assertEquals("Messages.properties",    entry.getKey().getName()); assertTrue (entry.getValue());
+        entry = it.next(); assertEquals("ShallIgnore.properties", entry.getKey().getName()); assertFalse(entry.getValue());
+        assertFalse(it.hasNext());
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/test/org/apache/sis/util/resources/ResourceCompilerMojoTest.java b/buildSrc/src/org.apache.sis.buildtools/test/org/apache/sis/util/resources/ResourceCompilerMojoTest.java
deleted file mode 100644
index 0eb7ab9dee..0000000000
--- a/buildSrc/src/org.apache.sis.buildtools/test/org/apache/sis/util/resources/ResourceCompilerMojoTest.java
+++ /dev/null
@@ -1,51 +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.util.resources;
-
-import java.io.File;
-import org.junit.Test;
-
-import static org.junit.Assert.*;
-
-
-/**
- * Tests {@link ResourceCompilerMojo}.
- *
- * @author  Martin Desruisseaux (Geomatys)
- * @version 0.5
- * @since   0.5
- */
-public class ResourceCompilerMojoTest {
-    /**
-     * Tests {@link ResourceCompilerMojo#filterLanguages(File[])}.
-     */
-    @Test
-    public void testFilterLanguages() {
-        final File[] files = {
-            new File("Errors_en.properties"),
-            new File("Messages_fr.properties"),
-            new File("Errors.properties"),
-            new File("Errors_fr.properties"),
-            new File("ShallIgnore.properties"),
-            new File("Messages_en.properties"),
-            new File("Messages.properties")
-        };
-        assertEquals(2, ResourceCompilerMojo.filterLanguages(files));
-        assertEquals("Errors.properties",   files[0].getName());
-        assertEquals("Messages.properties", files[1].getName());
-    }
-}
diff --git a/endorsed/build.gradle.kts b/endorsed/build.gradle.kts
new file mode 100644
index 0000000000..28a3b4e04f
--- /dev/null
+++ b/endorsed/build.gradle.kts
@@ -0,0 +1,392 @@
+/*
+ * 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.
+ */
+group = "org.apache.sis"
+version = "2.0-SNAPSHOT"
+
+/*
+ * "org.apache.sis.buildtools" is a custom Gradle plugin for building a project with Module Source Hierarchy
+ * as specified in https://docs.oracle.com/en/java/javase/20/docs/specs/man/javac.html#directory-hierarchies
+ * The expected hierarchy is:
+ *
+ *   endorsed
+ *     ├─ build
+ *     └─ src
+ *         ├─ org.apache.sis.metadata
+ *         │    ├─ main
+ *         │    │   ├─ module-info.java
+ *         │    │   └─ org/apache/sis/metadata/…
+ *         │    └─ test
+ *         │        └─ org/apache/sis/metadata/…
+ *         ├─ org.apache.sis.referencing
+ *         │    ├─ main
+ *         │    │   ├─ module-info.java
+ *         │    │   └─ org/apache/sis/referencing/…
+ *         │    └─ test
+ *         │        └─ org/apache/sis/referencing/…
+ *         └─ etc.
+ */
+plugins {
+    `java-library`
+    `maven-publish`
+    id("org.apache.sis.buildtools")
+}
+
+/*
+ * All JAR files that may potentially be used by a module.
+ * For each Apache SIS module, the dependencies actually
+ * used are declared in the `module-info.java` file.
+ */
+dependencies {
+    // Mandatory dependencies
+    api(libs.units)
+    api(libs.geoapi)
+    api(libs.jaxb.api)
+
+    // Optional dependencies
+    runtimeOnly(libs.jaxb.impl)
+    compileOnly(libs.jts.core)
+    compileOnly(libs.esri.geometry)
+    compileOnly(libs.libreoffice)
+    compileOnly(libs.ucar)
+    compileOnly(libs.aws.s3)
+    compileOnly(drivers.postgres)
+
+    // Test dependencies
+    testImplementation(tests.junit4)
+    testImplementation(tests.geoapi)
+    testImplementation(tests.jama)
+    testImplementation(tests.geographiclib)
+    testImplementation(libs.jts.core)           // We need to repeat optional dependencies.
+    testImplementation(libs.esri.geometry)      // Idem.
+    testImplementation(libs.libreoffice)
+    testImplementation(libs.ucar)
+    testImplementation(libs.aws.s3)
+    testImplementation(drivers.derby.tools)
+    testImplementation(drivers.derby.core)
+    testImplementation(drivers.postgres)
+    testImplementation(drivers.hsql)
+    testImplementation(drivers.h2)
+
+    // For test execution
+    testRuntimeOnly(tests.junit)
+    testRuntimeOnly(tests.junitLauncher)
+    testRuntimeOnly(tests.slf4j)
+}
+
+/*
+ * Compile main and tests classes from Module Source Hierarchy.
+ * The test classes require some additional dependencies declared in the `org.apache.sis.test` module.
+ */
+var srcDir = file("src")            // Must be the same as the hard-coded value in `BuildHelper.java`.
+tasks.compileJava {
+    options.release.set(11)         // The version of both Java source code and compiled byte code.
+}
+tasks.compileTestJava {
+    srcDir.list().forEach {
+        addRead(options.compilerArgs, it, "org.apache.sis.test,junit")
+    }
+    addExportForTests(options.compilerArgs)
+}
+
+/*
+ * Adds a JVM argument for adding dependencies to a module.
+ * This is for dependencies not declared in `module-info`
+ * but needed for test compilation or test execution.
+ */
+fun addRead(args : MutableList<String>, module : String, dependencies : String) {
+    args.add("--add-reads")
+    args.add(module + '=' + dependencies)
+}
+
+/*
+ * Adds a JVM argument for making an internal package accessible to another module.
+ * This is for making internal packages accessible to JUnit or to some test classes
+ * defined in other modules.
+ */
+fun addExport(args : MutableList<String>, module : String, pkg : String, consumers : String) {
+    args.add("--add-exports")
+    args.add(module + '/' + pkg + '=' + consumers)
+}
+
+/*
+ * Add compiler and runtime options for patching the Apache SIS main modules with the test classes.
+ * The same options are required for both compiling and executing the tests.
+ */
+fun addExportForTests(args : MutableList<String>) {
+    var allModules = srcDir.list().joinToString(separator=",")
+
+    // ――――――――――――― Module name ――――――――――――――――――――――― Package to export ―――――――――――――――
+    addExport(args, "org.apache.sis.util",              "org.apache.sis.test",
+                     allModules)
+
+    addExport(args, "org.apache.sis.metadata",          "org.apache.sis.test.mock",
+                    "org.apache.sis.referencing")
+
+    addExport(args, "org.apache.sis.metadata",          "org.apache.sis.test.sql",
+                    "org.apache.sis.referencing," +
+                    "org.apache.sis.storage.sql")
+
+    addExport(args, "org.apache.sis.metadata",          "org.apache.sis.test.xml",
+                    "org.apache.sis.referencing," +
+                    "org.apache.sis.profile.france")
+
+    addExport(args, "org.apache.sis.storage",           "org.apache.sis.test.storage",
+                    "org.apache.sis.storage.geotiff," +
+                    "org.apache.sis.storage.netcdf")
+    /*
+     * Some test classes need access to more internal packages than requested by the main classes.
+     * The following lines may need to be edited when export statements are added or removed in a
+     * module-info.java file of main source code, or when a test class starts using or stop using
+     * an internal API.
+     */
+    // ――――――――――――― Module name ――――――――――――――――――――――― Package to export ―――――――――――――――
+    addExport(args, "org.apache.sis.metadata",          "org.apache.sis.internal.metadata",
+                    "org.apache.sis.referencing.gazetteer")
+
+    addExport(args, "org.apache.sis.metadata",          "org.apache.sis.metadata.xml",
+                    "org.apache.sis.storage," +
+                    "org.apache.sis.console")
+
+    addExport(args, "org.apache.sis.metadata",          "org.apache.sis.internal.xml",
+                    "org.apache.sis.storage.geotiff")
+
+    addExport(args, "org.apache.sis.metadata",          "org.apache.sis.internal.jaxb.gcx",
+                    "org.apache.sis.referencing")
+
+    addExport(args, "org.apache.sis.feature",           "org.apache.sis.internal.feature.jts",
+                    "org.apache.sis.storage.sql," +
+                    "org.apache.sis.portrayal")
+
+    addExport(args, "org.apache.sis.storage",           "org.apache.sis.internal.storage",
+                    "org.apache.sis.portrayal")
+}
+
+/*
+ * Discover and execute JUnit-based tests.
+ */
+tasks.test {
+    val args = mutableListOf("-enableassertions")
+    addExportForTests(args)
+    /*
+     * Dependencies needed only for executing the tests (not for compilation).
+     */
+    addRead(args, "org.apache.sis.metadata",    "org.apache.derby.tools,com.h2database,org.hsqldb")
+    addRead(args, "org.apache.sis.referencing", "jama,GeographicLib.Java")
+    addRead(args, "org.apache.sis.storage",     "esri.geometry.api")
+    addRead(args, "org.apache.sis.storage.xml", "esri.geometry.api")
+    /*
+     * JAXB implementation needs permissions to do reflection.
+     */
+    args.add("--add-opens")
+    args.add("org.apache.sis.metadata/org.apache.sis.test.mock=jakarta.xml.bind")
+    setAllJvmArgs(args)
+    /*
+     * Request full stack trace in case of failure. The short stack trace is not enough
+     * for identifying the failure cause.
+     */
+    testLogging {
+        events("FAILED", "STANDARD_OUT", "STANDARD_ERROR")
+        setExceptionFormat("FULL")
+    }
+}
+
+/*
+ * Javadoc generation. Most of the configuration is specified in the `../buildSrc` plugin.
+ * This file contains only the subproject-dependent parts.
+ */
+tasks.javadoc {
+    setTitle("Apache SIS ${version} API")
+}
+
+/*
+ * Configuration of some META-INF/MANIFEST.MF attributes.
+ * Other attributes are hard-coded in `../buildSrc`.
+ */
+tasks.jar {
+    manifest {
+        attributes["Main-Class"]            = "org.apache.sis.console.Command"
+        attributes["RegistrationClassName"] = "org.apache.sis.openoffice.Registration"
+    }
+}
+
+/*
+ * Configuration of the modules to deploy as Maven artifacts.
+ */
+publishing {
+    publications {
+        create<MavenPublication>("util") {
+            groupId    = "org.apache.sis.core"
+            artifactId = "sis-utility"
+            artifact(file("${buildDir}/libs/org.apache.sis.util.jar"))
+            pom {
+                name        = "Apache SIS utilities"
+                description = "Units of measurement and miscellaneous utility methods required by Apache SIS."
+            }
+        }
+        create<MavenPublication>("metadata") {
+            groupId    = "org.apache.sis.core"
+            artifactId = "sis-metadata"
+            artifact(file("${buildDir}/libs/org.apache.sis.metadata.jar"))
+            pom {
+                name        = "Apache SIS metadata"
+                description = "Implementations of metadata derived from ISO 19115. " +
+                              "This module provides both an implementation of the metadata interfaces defined in GeoAPI, " +
+                              "and a framework for handling those metadata through Java reflection."
+            }
+        }
+        create<MavenPublication>("referencing") {
+            groupId    = "org.apache.sis.core"
+            artifactId = "sis-referencing"
+            artifact(file("${buildDir}/libs/org.apache.sis.referencing.jar"))
+            pom {
+                name        = "Apache SIS referencing"
+                description = "Implementations of Coordinate Reference Systems (CRS), " +
+                              "conversion and transformation services derived from ISO 19111."
+            }
+        }
+        create<MavenPublication>("referencing.gazetteer") {
+            groupId    = "org.apache.sis.core"
+            artifactId = "sis-referencing-by-identifiers"
+            artifact(file("${buildDir}/libs/org.apache.sis.referencing.gazetteer.jar"))
+            pom {
+                name        = "Apache SIS referencing by geographic identifiers"
+                description = "Implementations of Spatial Reference Systems using Geographic Identifiers " +
+                              "gazetteer services derived from ISO 19112."
+            }
+        }
+        create<MavenPublication>("feature") {
+            groupId    = "org.apache.sis.core"
+            artifactId = "sis-feature"
+            artifact(file("${buildDir}/libs/org.apache.sis.feature.jar"))
+            pom {
+                name        = "Apache SIS features"
+                description = "Representations of geographic features. " +
+                              "Includes access to both vector and raster data."
+            }
+        }
+        create<MavenPublication>("portrayal") {
+            groupId    = "org.apache.sis.core"
+            artifactId = "sis-portrayal"
+            artifact(file("${buildDir}/libs/org.apache.sis.portrayal.jar"))
+            pom {
+                name        = "Apache SIS portrayal"
+                description = "Symbology and map representations, together with a rendering engine for display."
+            }
+        }
+        create<MavenPublication>("storage") {
+            groupId    = "org.apache.sis.storage"
+            artifactId = "sis-storage"
+            artifact(file("${buildDir}/libs/org.apache.sis.storage.jar"))
+            pom {
+                name        = "Apache SIS common storage"
+                description = "Provides the interfaces and base classes to be implemented by various storage formats."
+            }
+        }
+        create<MavenPublication>("storage.xml") {
+            groupId    = "org.apache.sis.storage"
+            artifactId = "sis-xmlstore"
+            artifact(file("${buildDir}/libs/org.apache.sis.storage.xml.jar"))
+            pom {
+                name        = "Apache SIS XML storage"
+                description = "Read and write files in the GPX format."
+            }
+        }
+        create<MavenPublication>("storage.netcdf") {
+            groupId    = "org.apache.sis.storage"
+            artifactId = "sis-netcdf"
+            artifact(file("${buildDir}/libs/org.apache.sis.storage.netcdf.jar"))
+            pom {
+                name        = "Apache SIS netCDF storage"
+                description = "Bridge between netCDF Climate and Forecast (CF) convention and ISO 19115 metadata."
+            }
+        }
+        create<MavenPublication>("storage.geotiff") {
+            groupId    = "org.apache.sis.storage"
+            artifactId = "sis-geotiff"
+            artifact(file("${buildDir}/libs/org.apache.sis.storage.geotiff.jar"))
+            pom {
+                name        = "Apache SIS GeoTIFF storage"
+                description = "Cloud Optimized GeoTIFF reader and bridge to ISO 19115 metadata."
+            }
+        }
+        create<MavenPublication>("storage.earthobservation") {
+            groupId    = "org.apache.sis.storage"
+            artifactId = "sis-earth-observation"
+            artifact(file("${buildDir}/libs/org.apache.sis.storage.earthobservation.jar"))
+            pom {
+                name        = "Apache SIS Earth Observation storage"
+                description = "Read a directory of Landsat files as a single resource."
+            }
+        }
+        create<MavenPublication>("storage.sql") {
+            groupId    = "org.apache.sis.storage"
+            artifactId = "sis-sqlstore"
+            artifact(file("${buildDir}/libs/org.apache.sis.storage.sql.jar"))
+            pom {
+                name        = "Apache SIS SQL storage"
+                description = "Read and write features from SQL databases."
+            }
+        }
+        create<MavenPublication>("cloud.aws") {
+            groupId    = "org.apache.sis.cloud"
+            artifactId = "sis-cloud-aws"
+            artifact(file("${buildDir}/libs/org.apache.sis.cloud.aws.jar"))
+            pom {
+                name        = "Apache SIS storage from Amazon AWS S3"
+                description = "Provides access to Amazon AWS S3 storage from Apache SIS data stores."
+            }
+        }
+        create<MavenPublication>("profile.france") {
+            groupId    = "org.apache.sis.profiles"
+            artifactId = "sis-french-profile"
+            artifact(file("${buildDir}/libs/org.apache.sis.profile.france.jar"))
+            pom {
+                name        = "Apache SIS French profiles"
+                description = "Extensions to ISO-19115 metadata mandated by the French government."
+            }
+        }
+        create<MavenPublication>("profile.japan") {
+            groupId    = "org.apache.sis.profiles"
+            artifactId = "sis-japan-profile"
+            artifact(file("${buildDir}/libs/org.apache.sis.profile.japan.jar"))
+            pom {
+                name        = "Apache SIS Japanese profiles"
+                description = "Extensions to netCDF reader for file formats published by Japanese Aerospace Exploration Agency (JAXA)."
+            }
+        }
+        create<MavenPublication>("console") {
+            groupId    = "org.apache.sis.application"
+            artifactId = "sis-console"
+            artifact(file("${buildDir}/libs/org.apache.sis.console.jar"))
+            pom {
+                name        = "Apache SIS console"
+                description = "Console application."
+            }
+        }
+        create<MavenPublication>("openoffice") {
+            groupId    = "org.apache.sis.application"
+            artifactId = "sis-openoffice"
+            artifact(file("${buildDir}/libs/org.apache.sis.openoffice.jar"))
+            pom {
+                name        = "Bridges to Apache OpenOffice or LibreOffice"
+                description = "Provides some Apache SIS functionalities as Apache OpenOffice addins. " +
+                              "For example, addins provide coordinate operation services as formulas " +
+                              "inside the Calc spreadsheet."
+            }
+        }
+    }
+}
diff --git a/endorsed/src/org.apache.sis.openoffice/bundle/build-instruction.html b/endorsed/src/org.apache.sis.openoffice/bundle/build-instruction.html
index 2ebc99958a..82684a9f30 100644
--- a/endorsed/src/org.apache.sis.openoffice/bundle/build-instruction.html
+++ b/endorsed/src/org.apache.sis.openoffice/bundle/build-instruction.html
@@ -77,9 +77,9 @@ idlc -C -cid -w -I $OO_SDK_HOME/idl org/apache/sis/openoffice/*.idl</pre></block
   most users will not have the necessary installation for producing those files themselves
   (including the <code>*.class</code> files, which is not the usual <code>javac</code> output).
 </p>
-<blockquote><pre>regmerge ../unopkg/sis.rdb /UCR org/apache/sis/openoffice/*.urd
+<blockquote><pre>regmerge ../bundle/sis.rdb /UCR org/apache/sis/openoffice/*.urd
 rm org/apache/sis/openoffice/*.urd
-cd ../unopkg
+cd ../bundle
 javamaker -BUCR sis.rdb $OFFICE_BASE_HOME/program/types.rdb -Torg.apache.sis.openoffice.XReferencing</pre></blockquote>
 
 
diff --git a/endorsed/src/org.apache.sis.openoffice/main/org/apache/sis/openoffice/XReferencing.java b/endorsed/src/org.apache.sis.openoffice/main/org/apache/sis/openoffice/XReferencing.java
index 19a5d171d4..c1957b5175 100644
--- a/endorsed/src/org.apache.sis.openoffice/main/org/apache/sis/openoffice/XReferencing.java
+++ b/endorsed/src/org.apache.sis.openoffice/main/org/apache/sis/openoffice/XReferencing.java
@@ -29,7 +29,7 @@ import com.sun.star.lang.IllegalArgumentException;
  * This source file exists only for convenience: for avoiding that IDEs report errors, and opportunistically for
  * Javadoc purpose. But the {@code XReferencing.class} file compiled from this source file <strong>must</strong>
  * be overwritten by the {@code XReferencing.class} file generated by {@code javamaker}.
- * See {@code application/sis-openoffice/src/main/unopkg/build-instruction.html} for more information.</p>
+ * See {@code endorsed/src/org.apache.sis.openoffice/bundle/build-instruction.html} for more information.</p>
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
  * @version 0.8
diff --git a/endorsed/src/org.apache.sis.openoffice/main/org/apache/sis/openoffice/package-info.java b/endorsed/src/org.apache.sis.openoffice/main/org/apache/sis/openoffice/package-info.java
index f1aed8371d..8e0786e80c 100644
--- a/endorsed/src/org.apache.sis.openoffice/main/org/apache/sis/openoffice/package-info.java
+++ b/endorsed/src/org.apache.sis.openoffice/main/org/apache/sis/openoffice/package-info.java
@@ -22,7 +22,7 @@
  *
  * <p>Note that the {@code X*.class} files to be included in the final JAR file
  * shall be compiled from the {@code X*.idl} files, not from the {@code X*.java} files.
- * See {@code application/sis-openoffice/src/main/unopkg/build-instruction.html} for more information.</p>
+ * See {@code endorsed/src/org.apache.sis.openoffice/bundle/build-instruction.html} for more information.</p>
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
  * @version 0.8
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java b/endorsed/src/org.apache.sis.test/test/module-info.java
similarity index 76%
copy from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java
copy to endorsed/src/org.apache.sis.test/test/module-info.java
index 5817ad614a..565c714bb9 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java
+++ b/endorsed/src/org.apache.sis.test/test/module-info.java
@@ -15,13 +15,15 @@
  * limitations under the License.
  */
 
-
 /**
- * Javadoc taglets for processing custom tags in SIS source code comments,
- * together with doclet performing post-processing after Javadoc generation.
+ * Additional dependencies needed for Apache SIS tests.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
- * @since   0.5
+ * @version 1.4
+ * @since   1.4
  */
-package org.apache.sis.internal.doclet;
+module org.apache.sis.test {
+    requires transitive org.opengis.geoapi.conformance;
+    requires transitive org.apache.derby.tools;
+    requires transitive org.hsqldb;
+}
diff --git a/incubator/build.gradle.kts b/incubator/build.gradle.kts
new file mode 100644
index 0000000000..be719b4b30
--- /dev/null
+++ b/incubator/build.gradle.kts
@@ -0,0 +1,144 @@
+/*
+ * 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.
+ */
+group = "org.apache.sis"
+version = "2.0-SNAPSHOT"
+
+/*
+ * "org.apache.sis.buildtools" is a custom Gradle plugin for building a project with Module Source Hierarchy.
+ * See the Gradle build script in the `endorsed` directory for more information.
+ */
+plugins {
+    `java-library`
+    `maven-publish`     // For local deployment only. Not to be published on Maven Central.
+    id("org.apache.sis.buildtools")
+    antlr
+}
+
+/*
+ * All JAR files that may potentially be used by a module.
+ * For each Apache SIS module, the dependencies actually
+ * used are declared in the `module-info.java` file.
+ */
+dependencies {
+    // Mandatory dependencies
+    api(libs.units)
+    api(libs.geoapi)
+    api(libs.jaxb.api)                  // Transitive dependency.
+    api(libs.jts.core)                  // Should be an optional dependency.
+    api(libs.esri.geometry)             // Idem.
+    api(files("../endorsed/build/classes/java/main/org.apache.sis.util"))
+    api(files("../endorsed/build/classes/java/main/org.apache.sis.metadata"))
+    api(files("../endorsed/build/classes/java/main/org.apache.sis.referencing"))
+    api(files("../endorsed/build/classes/java/main/org.apache.sis.feature"))
+    api(files("../endorsed/build/classes/java/main/org.apache.sis.storage"))
+
+    // Test dependencies
+    testImplementation(tests.geoapi)
+    testImplementation(tests.junit4)
+    testRuntimeOnly   (tests.junit)
+    testRuntimeOnly   (tests.junitLauncher)
+    /*
+     * Dependencies used only by incubated modules. The dependencies are not declared
+     * in the global `settings.gradle.kts` because incubated modules are not released.
+     */
+    implementation(group = "org.antlr",       name = "antlr4-maven-plugin", version = "4.11.1")
+    compileOnly   (group = "jakarta.servlet", name = "jakarta.servlet-api", version = "6.0.0")
+    compileOnly   (group = "org.osgi",        name = "osgi.core",           version = "8.0.0")
+    antlr         (group = "org.antlr",       name = "antlr4",              version = "4.11.1")
+}
+
+/*
+ * Generate ANTLR4 source files, then compile main and tests classes from Module Source Hierarchy.
+ * The test classes require some additional dependencies declared in the `org.apache.sis.test` module.
+ *
+ * Note: as of Gradle 8.2, the ANTLR task does not work well with arbitrary source directories.
+ * We have to keep default convention even if it does not fit well in Module Source Hierarchy.
+ * Another problem is that we didn't found the right compiler options for combining a directory
+ * of generated sources with the main sources in a Module Source Hierarchy, so we have to write
+ * the output directly in main source directory. This is hopefully temporary, since we hope to
+ * replace ANTLR generated code by hand-written code in a future version.
+ */
+var srcDir = file("src")        // Must be the same as the hard-coded value in `BuildHelper.java`.
+tasks.generateGrammarSource {
+    setOutputDirectory(file("${srcDir}/org.apache.sis.cql/main"))
+}
+tasks.compileJava {
+    dependsOn(":endorsed:compileJava")
+    options.release.set(11)         // The version of both Java source code and compiled byte code.
+}
+tasks.compileTestJava {
+    srcDir.list().forEach {
+        addRead(options.compilerArgs, it, "org.apache.sis.test,junit")
+    }
+}
+
+/*
+ * Adds a JVM argument for adding dependencies to a module.
+ * This is for dependencies not declared in `module-info`
+ * but needed for test compilation or test execution.
+ */
+fun addRead(args : MutableList<String>, module : String, dependencies : String) {
+    args.add("--add-reads")
+    args.add(module + '=' + dependencies)
+}
+
+/*
+ * Discover and execute JUnit-based tests.
+ */
+tasks.test {
+    val args = mutableListOf("-enableassertions")
+    setAllJvmArgs(args)
+    testLogging {
+        events("FAILED", "STANDARD_OUT", "STANDARD_ERROR")
+        setExceptionFormat("FULL")
+    }
+}
+
+/*
+ * Configuration of the modules to deploy as Maven artifacts.
+ */
+publishing {
+    publications {
+        create<MavenPublication>("cql") {
+            groupId    = "org.apache.sis.core"
+            artifactId = "sis-cql"
+            artifact(file("${buildDir}/libs/org.apache.sis.cql.jar"))
+            pom {
+                name        = "Apache SIS CQL"
+                description = "CQL parser."
+            }
+        }
+        create<MavenPublication>("storage.shapefile") {
+            groupId    = "org.apache.sis.storage"
+            artifactId = "sis-shapefile"
+            artifact(file("${buildDir}/libs/org.apache.sis.storage.shapefile.jar"))
+            pom {
+                name        = "Apache SIS Shapefile storage"
+                description = "Read and write files in the Shapefile format."
+            }
+        }
+        create<MavenPublication>("webapp") {
+            groupId    = "org.apache.sis.application"
+            artifactId = "sis-webapp"
+            artifact(file("${buildDir}/libs/org.apache.sis.webapp.jar"))
+            pom {
+                name        = "Apache SIS web services layer"
+                description = "Placeholder for future developments."
+            }
+        }
+    }
+}
diff --git a/incubator/src/main/antlr/org/apache/sis/internal/cql/CQL.g4 b/incubator/src/main/antlr/org/apache/sis/internal/cql/CQL.g4
index c2e3fcf3b5..6860141030 100644
--- a/incubator/src/main/antlr/org/apache/sis/internal/cql/CQL.g4
+++ b/incubator/src/main/antlr/org/apache/sis/internal/cql/CQL.g4
@@ -19,6 +19,9 @@ grammar CQL;
 options {
     language = Java;
 }
+@header {
+    package org.apache.sis.internal.cql;
+}
 
 //-----------------------------------------------------------------//
 // LEXER
diff --git a/incubator/src/org.apache.sis.cql/main/org/apache/sis/internal/cql/.gitignore b/incubator/src/org.apache.sis.cql/main/org/apache/sis/internal/cql/.gitignore
new file mode 100644
index 0000000000..4948df6ac7
--- /dev/null
+++ b/incubator/src/org.apache.sis.cql/main/org/apache/sis/internal/cql/.gitignore
@@ -0,0 +1,7 @@
+# Generated by ANTLR
+*.interp
+*.tokens
+CQLLexer.java
+CQLParser.java
+CQLListener.java
+CQLBaseListener.java
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java b/incubator/src/org.apache.sis.test/test/module-info.java
similarity index 79%
copy from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java
copy to incubator/src/org.apache.sis.test/test/module-info.java
index 5817ad614a..a4dea60674 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java
+++ b/incubator/src/org.apache.sis.test/test/module-info.java
@@ -15,13 +15,13 @@
  * limitations under the License.
  */
 
-
 /**
- * Javadoc taglets for processing custom tags in SIS source code comments,
- * together with doclet performing post-processing after Javadoc generation.
+ * Additional dependencies needed for Apache SIS tests.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
- * @since   0.5
+ * @version 1.4
+ * @since   1.4
  */
-package org.apache.sis.internal.doclet;
+module org.apache.sis.test {
+    requires transitive org.opengis.geoapi.conformance;
+}
diff --git a/optional/build.gradle.kts b/optional/build.gradle.kts
new file mode 100644
index 0000000000..997b286b37
--- /dev/null
+++ b/optional/build.gradle.kts
@@ -0,0 +1,168 @@
+/*
+ * 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.
+ */
+group = "org.apache.sis"
+version = "2.0-SNAPSHOT"
+
+val pathToFX = System.getenv("PATH_TO_FX")
+if (pathToFX == null) {
+    throw GradleException("For compiling the Apache SIS optional sub-project, the PATH_TO_FX environment variable must be set.")
+}
+if (!File(pathToFX, "javafx.base.jar").isFile()) {
+    throw GradleException("The directory specified by the PATH_TO_FX environment variable shall contain \"javafx.*.jar\" files.")
+}
+
+/*
+ * "org.apache.sis.buildtools" is a custom Gradle plugin for building a project with Module Source Hierarchy.
+ * See the Gradle build script in the `endorsed` directory for more information.
+ */
+plugins {
+    `java-library`
+    `maven-publish`
+    id("org.apache.sis.buildtools")
+}
+
+/*
+ * All JAR files that may potentially be used by a module.
+ * For each Apache SIS module, the dependencies actually
+ * used are declared in the `module-info.java` file.
+ */
+var mainDepPath = file("../endorsed/build/classes/java/main")
+var testDepPath = file("../endorsed/build/classes/java/test")
+dependencies {
+    // Mandatory dependencies
+    api           (libs.units)
+    api           (libs.geoapi)
+    implementation(libs.jaxb.api)                  // Transitive dependency.
+    runtimeOnly   (libs.jaxb.impl)
+    api           (files(File(pathToFX, "javafx.base.jar")))
+    api           (files(File(pathToFX, "javafx.graphics.jar")))
+    api           (files(File(pathToFX, "javafx.controls.jar")))
+    api           (files(File(pathToFX, "javafx.web.jar")))
+    runtimeOnly   (files(File(pathToFX, "javafx.media.jar")))
+    api           (files("${mainDepPath}/org.apache.sis.util"))
+    api           (files("${mainDepPath}/org.apache.sis.metadata"))
+    api           (files("${mainDepPath}/org.apache.sis.referencing"))
+    implementation(files("${mainDepPath}/org.apache.sis.referencing.gazetteer"))
+    api           (files("${mainDepPath}/org.apache.sis.feature"))
+    api           (files("${mainDepPath}/org.apache.sis.storage"))
+    implementation(files("${mainDepPath}/org.apache.sis.storage.xml"))
+    api           (files("${mainDepPath}/org.apache.sis.portrayal"))
+
+    // Test dependencies
+    testImplementation(tests.geoapi)
+    testImplementation(tests.junit4)
+    testRuntimeOnly   (tests.junit)
+    testRuntimeOnly   (tests.junitLauncher)
+}
+
+/*
+ * Compile main and tests classes from Module Source Hierarchy.
+ * The test classes require some additional dependencies declared in the `org.apache.sis.test` module.
+ */
+var srcDir = file("src")            // Must be the same as the hard-coded value in `BuildHelper.java`.
+tasks.compileJava {
+    dependsOn(":endorsed:compileJava")
+    options.release.set(16)         // The version of both Java source code and compiled byte code.
+}
+tasks.compileTestJava {
+    patchForTests(options.compilerArgs);
+    srcDir.list().forEach {
+        addRead(options.compilerArgs, it, "org.apache.sis.test,junit")
+    }
+}
+
+/*
+ * Adds a JVM argument for adding dependencies to a module.
+ * This is for dependencies not declared in `module-info`
+ * but needed for test compilation or test execution.
+ */
+fun addRead(args : MutableList<String>, module : String, dependencies : String) {
+    args.add("--add-reads")
+    args.add(module + '=' + dependencies)
+}
+
+/*
+ * Adds a JVM argument for making an internal package accessible to another module.
+ * This is for making internal packages accessible to JUnit or to some test classes.
+ */
+fun addExport(args : MutableList<String>, module : String, pkg : String, consumers : String) {
+    args.add("--add-exports")
+    args.add(module + '/' + pkg + '=' + consumers)
+}
+
+/*
+ * Adds a JVM argument for patching a module with test classes.
+ */
+fun patchModuleWithTests(args : MutableList<String>, module : String) {
+    args.add("--patch-module")
+    args.add("${module}=${testDepPath}/${module}")
+}
+
+/*
+ * Add compiler and runtime options for patching the Apache SIS main modules with the test classes.
+ * The same options are required for both compiling and executing the tests.
+ */
+fun patchForTests(args : MutableList<String>) {
+    patchModuleWithTests(args, "org.apache.sis.util")
+    patchModuleWithTests(args, "org.apache.sis.feature")
+    addExport(args, "org.apache.sis.util", "org.apache.sis.test", "org.apache.sis.gui")
+}
+
+/*
+ * Discover and execute JUnit-based tests.
+ */
+tasks.test {
+    val args = mutableListOf("-enableassertions")
+    patchForTests(args);
+    addRead  (args, "org.apache.sis.util",                                "ALL-UNNAMED")
+    addExport(args, "org.apache.sis.util", "org.apache.sis.test",         "ALL-UNNAMED")
+    addExport(args, "org.apache.sis.gui",  "org.apache.sis.internal.gui", "ALL-UNNAMED")
+    setAllJvmArgs(args)
+    testLogging {
+        events("FAILED", "STANDARD_OUT", "STANDARD_ERROR")
+        setExceptionFormat("FULL")
+    }
+}
+
+/*
+ * Configuration of some META-INF/MANIFEST.MF attributes.
+ * Other attributes are hard-coded in `../buildSrc`.
+ */
+tasks.jar {
+    manifest {
+        attributes["Main-Class"] = "org.apache.sis.gui.DataViewer"
+    }
+}
+
+/*
+ * Configuration of the modules to deploy as Maven artifacts.
+ */
+publishing {
+    publications {
+        create<MavenPublication>("gui") {
+            groupId    = "org.apache.sis.application"
+            artifactId = "sis-javafx"
+            artifact(file("${buildDir}/libs/org.apache.sis.gui.jar"))
+            pom {
+                name        = "Apache SIS application for JavaFX (optional)"
+                description = "Client application for JavaFX. " +
+                              "This module requires the JavaFX environment to be pre-installed. " +
+                              "See https://openjfx.io/openjfx-docs/#install-javafx for details."
+            }
+        }
+    }
+}
diff --git a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java b/optional/src/org.apache.sis.test/test/module-info.java
similarity index 79%
rename from buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java
rename to optional/src/org.apache.sis.test/test/module-info.java
index 5817ad614a..a4dea60674 100644
--- a/buildSrc/src/org.apache.sis.buildtools/main/org/apache/sis/internal/doclet/package-info.java
+++ b/optional/src/org.apache.sis.test/test/module-info.java
@@ -15,13 +15,13 @@
  * limitations under the License.
  */
 
-
 /**
- * Javadoc taglets for processing custom tags in SIS source code comments,
- * together with doclet performing post-processing after Javadoc generation.
+ * Additional dependencies needed for Apache SIS tests.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
- * @since   0.5
+ * @version 1.4
+ * @since   1.4
  */
-package org.apache.sis.internal.doclet;
+module org.apache.sis.test {
+    requires transitive org.opengis.geoapi.conformance;
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000000..120ea09901
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,92 @@
+/*
+ * 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.
+ */
+rootProject.name  = "Apache SIS on GeoAPI 4.0"
+val geoapiVersion = "4.0-SNAPSHOT"
+
+/*
+ * The sub-projects to include in the build.
+ * They are directory names relative to this file.
+ */
+include("endorsed")
+include("incubator")
+if (System.getenv("PATH_TO_FX") != null) {
+    include("optional")
+}
+
+/*
+ * Central place where to declare the repositories inherited by all sub-projects.
+ * Sub-projects can override by declaring their own `repositories` block.
+ */
+dependencyResolutionManagement {
+    /*
+     * The repositories from where to download the JAR files declared in the `dependencies` section.
+     * The Maven local repository should be avoided for security reasons (Gradle has its own cache).
+     */
+    repositories {
+        mavenCentral()
+        mavenLocal()        // For GeoAPI SNAPSHOT only, which are built locally.
+        maven {
+            url = uri("https://artifacts.unidata.ucar.edu/repository/unidata-releases")
+            content {
+                includeGroup("edu.ucar")        // Restrict usage to those dependencies.
+            }
+            mavenContent {
+                releasesOnly()
+            }
+        }
+    }
+    /*
+     * All dependencies used by sub-projects, together with their versions.
+     * For most dependencies (except tests), we assume semantic versioning.
+     * The preferred versions are the ones that we tested.
+     */
+    versionCatalogs {
+        create("libs") {
+            library("geoapi",        "org.opengis",            "geoapi-pending")      .version {strictly(geoapiVersion)}
+            library("units",         "javax.measure",          "unit-api")            .version {strictly("[2.1, 3.0[");  prefer("2.1.3")}
+            library("jaxb.api",      "jakarta.xml.bind",       "jakarta.xml.bind-api").version {strictly("[4.0, 5.0[");  prefer("4.0.0")}
+            library("jaxb.impl",     "org.glassfish.jaxb",     "jaxb-runtime")        .version {strictly("[4.0, 5.0[");  prefer("4.0.2")}
+            library("jts.core",      "org.locationtech.jts",   "jts-core")            .version {strictly("[1.15, 2.0["); prefer("1.19.0")}
+            library("esri.geometry", "com.esri.geometry",      "esri-geometry-api")   .version {strictly("[2.0, 3.0[");  prefer("2.2.4")}
+            library("libreoffice",   "org.libreoffice",        "libreoffice")         .version {strictly("[7.0, 8.0[");  prefer("7.3.6")}
+            library("ucar",          "edu.ucar",               "cdm-core")            .version {strictly("[5.0, 6.0[");  prefer("5.5.3")}
+            library("aws.s3",        "software.amazon.awssdk", "s3")                  .version {strictly("[2.0, 3.0[");  prefer("2.18.40")}
+        }
+        create("tests") {
+            library("geoapi",        "org.opengis",            "geoapi-conformance")     .version {strictly(geoapiVersion)}
+            library("junit4",        "junit",                  "junit")                  .version {strictly("4.13.2")}
+            library("junit",         "org.junit.vintage",      "junit-vintage-engine")   .version {strictly("5.9.3")}
+            library("junitLauncher", "org.junit.platform",     "junit-platform-launcher").version {strictly("1.9.3")}
+            library("jama",          "gov.nist.math",          "jama")                   .version {strictly("1.0.3")}
+            library("geographiclib", "net.sf.geographiclib",   "GeographicLib-Java")     .version {strictly("2.0")}
+            library("slf4j",         "org.slf4j",              "slf4j-jdk14").version {
+                prefer("1.7.28")            // Should match the version used by UCAR.
+            }
+        }
+        create("drivers") {
+            version("derby") {
+                strictly("[10.0, 11.0[")
+                prefer("10.15.2.0")         // 10.15 is the last series compatible with JDK 11.
+            }
+            library("derby.core",    "org.apache.derby",       "derby")      .versionRef("derby")
+            library("derby.tools",   "org.apache.derby",       "derbytools") .versionRef("derby")
+            library("postgres",      "org.postgresql",         "postgresql") .version {prefer("42.6.0")}
+            library("hsql",          "org.hsqldb",             "hsqldb")     .version {strictly("[2.0, 3.0["); prefer("2.7.2")}
+            library("h2",            "com.h2database",         "h2")         .version {strictly("[2.0, 3.0["); prefer("2.2.220")}
+        }
+    }
+}