You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/09/25 15:11:47 UTC

(maven-filtering) 03/04: Add separate input/output encoding support for resource filtering (MRESOURCES-232)

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

gnodet pushed a commit to branch feat/resource-encoding
in repository https://gitbox.apache.org/repos/asf/maven-filtering.git

commit 5c3f2223792a809564d78ab2446bf1704cc7fe20
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Fri Sep 25 11:21:14 2026 +0000

    Add separate input/output encoding support for resource filtering (MRESOURCES-232)
    
    Add the ability to configure a separate output encoding when filtering resources,
    enabling encoding conversion during the build (e.g. reading ISO-8859-1 / EBCDIC
    sources and writing UTF-8 output).
    
    Changes:
    - FilteringUtils.copyFile(): add overload with separate inputEncoding/outputEncoding;
      the single-encoding overload delegates to it for backward compatibility
    - MavenFileFilter: add copyFile() overload with inputEncoding + outputEncoding;
      the gracefulBinaryHandling overload delegates to the new one
    - DefaultMavenFileFilter: override new overload, use separate charsets for reader/writer
    - MavenResourcesExecution: add outputEncoding and outputPropertiesEncoding fields
      with getters/setters and copyOf() support
    - Resource: add per-resource encoding and outputEncoding fields so users can configure
      encoding conversion inside a single <resource> block
    - DefaultMavenResourcesFiltering: compute separate input/output encodings in the
      per-file loop; per-resource values override global ones; add getOutputEncoding()
      static helper (mirrors existing getEncoding())
    
    New tests:
    - getOutputEncoding(): unit-tests all fallback combinations
    - filterResourcesWithOutputEncoding(): end-to-end conversion ISO-8859-1 → UTF-8
    - filterResourcesWithPerResourceOutputEncoding(): per-resource override
---
 .../shared/filtering/DefaultMavenFileFilter.java   | 34 ++++++++-
 .../filtering/DefaultMavenResourcesFiltering.java  | 61 ++++++++++++---
 .../maven/shared/filtering/FilteringUtils.java     | 53 ++++++++++---
 .../maven/shared/filtering/MavenFileFilter.java    | 25 ++++++
 .../shared/filtering/MavenResourcesExecution.java  | 65 +++++++++++++++-
 .../DefaultMavenResourcesFilteringTest.java        | 89 ++++++++++++++++++++++
 src/test/units-files/MRESOURCES-232/message.txt    |  1 +
 7 files changed, 300 insertions(+), 28 deletions(-)

diff --git a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenFileFilter.java b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenFileFilter.java
index 0758e37..72c8f25 100644
--- a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenFileFilter.java
+++ b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenFileFilter.java
@@ -127,6 +127,19 @@ public class DefaultMavenFileFilter extends BaseFilter implements MavenFileFilte
         doCopyFile(from, to, filtering, filterWrappers, encoding, ChangeDetection.CONTENT, gracefulBinaryHandling);
     }
 
+    @Override
+    public boolean copyFileWithResult(
+            Path from,
+            Path to,
+            boolean filtering,
+            List<FilterWrapper> filterWrappers,
+            String inputEncoding,
+            String outputEncoding,
+            ChangeDetection changeDetection)
+            throws MavenFilteringException {
+        return doCopyFile(from, to, filtering, filterWrappers, inputEncoding, outputEncoding, changeDetection, false);
+    }
+
     private boolean doCopyFile(
             Path from,
             Path to,
@@ -136,12 +149,25 @@ public class DefaultMavenFileFilter extends BaseFilter implements MavenFileFilte
             ChangeDetection changeDetection,
             boolean gracefulBinaryHandling)
             throws MavenFilteringException {
+        return doCopyFile(from, to, filtering, filterWrappers, encoding, encoding, changeDetection, gracefulBinaryHandling);
+    }
+
+    private boolean doCopyFile(
+            Path from,
+            Path to,
+            boolean filtering,
+            List<FilterWrapper> filterWrappers,
+            String inputEncoding,
+            String outputEncoding,
+            ChangeDetection changeDetection,
+            boolean gracefulBinaryHandling)
+            throws MavenFilteringException {
         try {
             boolean copied;
             if (filtering) {
                 FilterWrapper[] array = filterWrappers.toArray(new FilterWrapper[0]);
                 try {
-                    copied = FilteringUtils.copyFile(from, to, encoding, array, changeDetection);
+                    copied = FilteringUtils.copyFile(from, to, inputEncoding, outputEncoding, array, changeDetection);
                 } catch (MalformedInputException e) {
                     if (!gracefulBinaryHandling) {
                         throw e;
@@ -156,7 +182,7 @@ public class DefaultMavenFileFilter extends BaseFilter implements MavenFileFilte
                     copied = true;
                 }
             } else {
-                copied = FilteringUtils.copyFile(from, to, encoding, new FilterWrapper[0], changeDetection);
+                copied = FilteringUtils.copyFile(from, to, inputEncoding, outputEncoding, new FilterWrapper[0], changeDetection);
             }
 
             buildContext.refresh(to.toFile());
@@ -164,9 +190,9 @@ public class DefaultMavenFileFilter extends BaseFilter implements MavenFileFilte
         } catch (IOException e) {
             String reason = e.getClass().getSimpleName() + ": " + e.getMessage();
             if (e instanceof MalformedInputException) {
-                String charsetName = encoding == null || encoding.isEmpty()
+                String charsetName = inputEncoding == null || inputEncoding.isEmpty()
                         ? Charset.defaultCharset().name()
-                        : encoding;
+                        : inputEncoding;
                 reason += " while reading with " + charsetName + " encoding";
             }
             throw new MavenFilteringException(
diff --git a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java
index c46eeb3..8762a2c 100644
--- a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java
+++ b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java
@@ -277,17 +277,27 @@ public class DefaultMavenResourcesFiltering implements MavenResourcesFiltering {
                     propertiesFiles.add(source);
                 }
 
-                // Determine which encoding to use when filtering this file.
-                // A per-resource encoding overrides the global encoding and propertiesEncoding.
-                String resourceEncoding = resource.getEncoding();
-                String encoding = (resourceEncoding != null)
-                        ? resourceEncoding
-                        : getEncoding(
-                                source,
-                                mavenResourcesExecution.getEncoding(),
-                                mavenResourcesExecution.getPropertiesEncoding());
+                // Determine which encoding to use when filtering this file
+                String inputEncoding = getEncoding(
+                        source, mavenResourcesExecution.getEncoding(), mavenResourcesExecution.getPropertiesEncoding());
+                // Per-resource input encoding overrides global (MRESOURCES-232 pattern)
+                if (resource.getEncoding() != null) {
+                    inputEncoding = resource.getEncoding();
+                }
+                String outputEncoding = getOutputEncoding(
+                        source,
+                        mavenResourcesExecution.getOutputEncoding(),
+                        mavenResourcesExecution.getOutputPropertiesEncoding(),
+                        inputEncoding);
+                // Per-resource output encoding overrides global
+                if (resource.getOutputEncoding() != null) {
+                    outputEncoding = resource.getOutputEncoding();
+                }
                 LOGGER.debug(
-                        "Using '" + encoding + "' encoding to copy filtered resource '" + source.getFileName() + "'.");
+                        "Using '{}' input / '{}' output encoding to copy filtered resource '{}'.",
+                        inputEncoding,
+                        outputEncoding,
+                        source.getFileName());
                 boolean doFiltering = resource.isFiltering() && filteredExt && filteredGlob;
                 boolean copied;
                 try {
@@ -296,7 +306,8 @@ public class DefaultMavenResourcesFiltering implements MavenResourcesFiltering {
                             destinationFile,
                             doFiltering,
                             mavenResourcesExecution.getFilterWrappers(),
-                            encoding,
+                            inputEncoding,
+                            outputEncoding,
                             changeDetection);
                 } catch (MavenFilteringException e) {
                     if (!mavenResourcesExecution.isGracefulBinaryHandling()
@@ -313,7 +324,8 @@ public class DefaultMavenResourcesFiltering implements MavenResourcesFiltering {
                             destinationFile,
                             false,
                             mavenResourcesExecution.getFilterWrappers(),
-                            encoding,
+                            inputEncoding,
+                            outputEncoding,
                             changeDetection);
                 }
                 if (LOGGER.isDebugEnabled()) {
@@ -399,6 +411,31 @@ public class DefaultMavenResourcesFiltering implements MavenResourcesFiltering {
         }
     }
 
+    /**
+     * Get the output encoding to use when writing the specified file. When a dedicated
+     * {@code outputEncoding} is configured, it is returned (using {@code outputPropertiesEncoding}
+     * for properties files). When no output encoding is configured, the resolved input encoding
+     * is used (backward-compatible: same encoding for reading and writing).
+     *
+     * @param file The file to check
+     * @param outputEncoding The global output encoding (may be {@code null})
+     * @param outputPropertiesEncoding The global output encoding for properties files (may be {@code null})
+     * @param inputEncoding The already-resolved input encoding to fall back to
+     * @return The output encoding to use when writing the specified file
+     * @since 4.0.0-beta-3
+     */
+    static String getOutputEncoding(
+            Path file, String outputEncoding, String outputPropertiesEncoding, String inputEncoding) {
+        if (outputEncoding == null) {
+            // no output encoding configured — fall back to input encoding (no conversion)
+            return inputEncoding;
+        }
+        if (isPropertiesFile(file)) {
+            return outputPropertiesEncoding != null ? outputPropertiesEncoding : outputEncoding;
+        }
+        return outputEncoding;
+    }
+
     /**
      * Determine whether a file is a properties file or not.
      *
diff --git a/src/main/java/org/apache/maven/shared/filtering/FilteringUtils.java b/src/main/java/org/apache/maven/shared/filtering/FilteringUtils.java
index 11571b5..ce41e9d 100644
--- a/src/main/java/org/apache/maven/shared/filtering/FilteringUtils.java
+++ b/src/main/java/org/apache/maven/shared/filtering/FilteringUtils.java
@@ -292,7 +292,7 @@ public final class FilteringUtils {
      *
      * @param from the file to copy
      * @param to the destination file
-     * @param encoding the file output encoding (only if wrappers is not empty)
+     * @param encoding the encoding used for both reading and writing (only if wrappers is not empty)
      * @param wrappers array of {@link FilterWrapper}
      * @param overwrite if {@code true}, uses {@link ChangeDetection#ALWAYS}; if {@code false}, uses {@link ChangeDetection#CONTENT}
      * @throws IOException if an IO error occurs during copying or filtering
@@ -319,6 +319,33 @@ public final class FilteringUtils {
     public static boolean copyFile(
             Path from, Path to, String encoding, FilterWrapper[] wrappers, ChangeDetection changeDetection)
             throws IOException {
+        return copyFile(from, to, encoding, encoding, wrappers, changeDetection);
+    }
+
+    /**
+     * Copy and optionally filter a file using separate input and output encodings. This allows
+     * encoding conversion during resource filtering (e.g. reading ISO-8859-1 and writing UTF-8).
+     * <b>If wrappers is null or empty, the file is copied as raw bytes — encoding parameters are
+     * ignored in that case.</b>
+     *
+     * @param from the file to copy
+     * @param to the destination file
+     * @param inputEncoding the charset used to read {@code from} (only when wrappers is not empty)
+     * @param outputEncoding the charset used to write {@code to} (only when wrappers is not empty)
+     * @param wrappers array of {@link FilterWrapper}
+     * @param changeDetection the strategy to apply if to is existing file
+     * @return {@code true} if the file was copied.
+     * @throws IOException if an IO error occurs during copying or filtering
+     * @since 4.0.0-beta-3
+     */
+    public static boolean copyFile(
+            Path from,
+            Path to,
+            String inputEncoding,
+            String outputEncoding,
+            FilterWrapper[] wrappers,
+            ChangeDetection changeDetection)
+            throws IOException {
         boolean needsCopy = false;
         boolean unconditionally = false;
         switch (changeDetection) {
@@ -350,28 +377,30 @@ public final class FilteringUtils {
         boolean copied = false;
         if (needsCopy) {
             if (unconditionally) {
-                copied = copyUnconditionally(from, to, encoding, wrappers);
+                copied = copyUnconditionally(from, to, inputEncoding, outputEncoding, wrappers);
             } else {
-                copied = copyIfContentsChanged(from, to, encoding, wrappers);
+                copied = copyIfContentsChanged(from, to, inputEncoding, outputEncoding, wrappers);
             }
         }
         return copied;
     }
 
-    private static boolean copyUnconditionally(Path from, Path to, String encoding, FilterWrapper[] wrappers)
+    private static boolean copyUnconditionally(
+            Path from, Path to, String inputEncoding, String outputEncoding, FilterWrapper[] wrappers)
             throws IOException {
         if (wrappers == null || wrappers.length == 0) {
             try (OutputStream os = Files.newOutputStream(to)) {
                 Files.copy(from, os);
             }
         } else {
-            Charset charset = charset(encoding);
-            try (Reader fileReader = Files.newBufferedReader(from, charset)) {
+            Charset inputCharset = charset(inputEncoding);
+            Charset outputCharset = charset(outputEncoding);
+            try (Reader fileReader = Files.newBufferedReader(from, inputCharset)) {
                 Reader wrapped = fileReader;
                 for (FilterWrapper wrapper : wrappers) {
                     wrapped = wrapper.getReader(wrapped);
                 }
-                try (Writer writer = Files.newBufferedWriter(to, charset)) {
+                try (Writer writer = Files.newBufferedWriter(to, outputCharset)) {
                     char[] buffer = new char[COPY_BUFFER_LENGTH];
                     int nRead;
                     while ((nRead = wrapped.read(buffer, 0, COPY_BUFFER_LENGTH)) >= 0) {
@@ -384,7 +413,8 @@ public final class FilteringUtils {
         return true;
     }
 
-    private static boolean copyIfContentsChanged(Path from, Path to, String encoding, FilterWrapper[] wrappers)
+    private static boolean copyIfContentsChanged(
+            Path from, Path to, String inputEncoding, String outputEncoding, FilterWrapper[] wrappers)
             throws IOException {
         boolean copied = false;
         if (wrappers == null || wrappers.length == 0) {
@@ -393,13 +423,14 @@ public final class FilteringUtils {
                 copied = os.isModified();
             }
         } else {
-            Charset charset = charset(encoding);
-            try (Reader fileReader = Files.newBufferedReader(from, charset)) {
+            Charset inputCharset = charset(inputEncoding);
+            Charset outputCharset = charset(outputEncoding);
+            try (Reader fileReader = Files.newBufferedReader(from, inputCharset)) {
                 Reader wrapped = fileReader;
                 for (FilterWrapper wrapper : wrappers) {
                     wrapped = wrapper.getReader(wrapped);
                 }
-                try (CachingWriter writer = new CachingWriter(to, charset)) {
+                try (CachingWriter writer = new CachingWriter(to, outputCharset)) {
                     char[] buffer = new char[COPY_BUFFER_LENGTH];
                     int nRead;
                     while ((nRead = wrapped.read(buffer, 0, COPY_BUFFER_LENGTH)) >= 0) {
diff --git a/src/main/java/org/apache/maven/shared/filtering/MavenFileFilter.java b/src/main/java/org/apache/maven/shared/filtering/MavenFileFilter.java
index b66f830..6cd88de 100644
--- a/src/main/java/org/apache/maven/shared/filtering/MavenFileFilter.java
+++ b/src/main/java/org/apache/maven/shared/filtering/MavenFileFilter.java
@@ -143,4 +143,29 @@ public interface MavenFileFilter extends DefaultFilterInfo {
             throws MavenFilteringException {
         copyFile(from, to, filtering, filterWrappers, encoding, ChangeDetection.CONTENT);
     }
+
+    /**
+     * Copy and optionally filter a file using separate input and output encodings. This enables
+     * encoding conversion during resource filtering (e.g. reading ISO-8859-1 and writing UTF-8).
+     *
+     * @param from The source file
+     * @param to The target file
+     * @param filtering true to apply filtering
+     * @param filterWrappers {@link List} of FileUtils.FilterWrapper
+     * @param inputEncoding The charset used to read {@code from}
+     * @param outputEncoding The charset used to write {@code to}
+     * @param changeDetection The change detection mode to use
+     * @return {@code true} if the destination was written; {@code false} if it was skipped.
+     * @throws MavenFilteringException In case of an error.
+     * @since 4.0.0-beta-3
+     */
+    boolean copyFileWithResult(
+            Path from,
+            Path to,
+            boolean filtering,
+            List<FilterWrapper> filterWrappers,
+            String inputEncoding,
+            String outputEncoding,
+            ChangeDetection changeDetection)
+            throws MavenFilteringException;
 }
diff --git a/src/main/java/org/apache/maven/shared/filtering/MavenResourcesExecution.java b/src/main/java/org/apache/maven/shared/filtering/MavenResourcesExecution.java
index b9cadd6..aebc27f 100644
--- a/src/main/java/org/apache/maven/shared/filtering/MavenResourcesExecution.java
+++ b/src/main/java/org/apache/maven/shared/filtering/MavenResourcesExecution.java
@@ -52,11 +52,27 @@ public class MavenResourcesExecution extends AbstractMavenFilteringRequest {
 
     private String encoding;
 
+    /**
+     * The character encoding used to write filtered resources. When {@code null}, defaults to
+     * {@link #encoding} (i.e. same encoding for reading and writing, preserving backward compatibility).
+     *
+     * @since 4.0.0-beta-3
+     */
+    private String outputEncoding;
+
     /**
      * @since 3.2.0
      */
     private String propertiesEncoding;
 
+    /**
+     * The character encoding used to write filtered properties files. When {@code null}, defaults to
+     * {@link #outputEncoding} if set, otherwise to {@link #propertiesEncoding}.
+     *
+     * @since 4.0.0-beta-3
+     */
+    private String outputPropertiesEncoding;
+
     /**
      * By default files like {@code .gitignore}, {@code .cvsignore} etc. are excluded which means they will not being
      * copied. If you need them for a particular reason you can do that by settings this to {@code false}. This means
@@ -196,6 +212,29 @@ public class MavenResourcesExecution extends AbstractMavenFilteringRequest {
         this.encoding = encoding;
     }
 
+    /**
+     * Return the output encoding used when writing filtered resources.
+     * When {@code null}, {@link #getEncoding()} is used for writing as well.
+     *
+     * @return the output encoding, or {@code null} if not set
+     * @since 4.0.0-beta-3
+     */
+    public String getOutputEncoding() {
+        return outputEncoding;
+    }
+
+    /**
+     * Set the output encoding used when writing filtered resources.
+     * When set, enables encoding conversion during filtering (read with {@link #getEncoding()},
+     * write with this encoding). Pass {@code null} to restore symmetric behaviour.
+     *
+     * @param outputEncoding the output encoding, or {@code null} to use {@link #getEncoding()}
+     * @since 4.0.0-beta-3
+     */
+    public void setOutputEncoding(String outputEncoding) {
+        this.outputEncoding = outputEncoding;
+    }
+
     /**
      * Return the encoding of properties files.
      *
@@ -216,6 +255,28 @@ public class MavenResourcesExecution extends AbstractMavenFilteringRequest {
         this.propertiesEncoding = propertiesEncoding;
     }
 
+    /**
+     * Return the output encoding used when writing filtered properties files.
+     * When {@code null}, falls back to {@link #getOutputEncoding()} if set, then to
+     * {@link #getPropertiesEncoding()}.
+     *
+     * @return the output properties encoding, or {@code null} if not explicitly set
+     * @since 4.0.0-beta-3
+     */
+    public String getOutputPropertiesEncoding() {
+        return outputPropertiesEncoding;
+    }
+
+    /**
+     * Set the output encoding used when writing filtered properties files.
+     *
+     * @param outputPropertiesEncoding the output encoding for properties files, or {@code null}
+     * @since 4.0.0-beta-3
+     */
+    public void setOutputPropertiesEncoding(String outputPropertiesEncoding) {
+        this.outputPropertiesEncoding = outputPropertiesEncoding;
+    }
+
     /**
      * @return List of {@link Resource}
      */
@@ -437,6 +498,9 @@ public class MavenResourcesExecution extends AbstractMavenFilteringRequest {
         MavenResourcesExecution mre = new MavenResourcesExecution();
         mre.setAdditionalProperties(this.getAdditionalProperties());
         mre.setEncoding(this.getEncoding());
+        mre.setOutputEncoding(this.getOutputEncoding());
+        mre.setPropertiesEncoding(this.getPropertiesEncoding());
+        mre.setOutputPropertiesEncoding(this.getOutputPropertiesEncoding());
         mre.setEscapedBackslashesInFilePath(this.isEscapedBackslashesInFilePath());
         mre.setEscapeString(this.getEscapeString());
         mre.setFileFilters(copyList(this.getFileFilters()));
@@ -455,7 +519,6 @@ public class MavenResourcesExecution extends AbstractMavenFilteringRequest {
         mre.setAddDefaultExcludes(this.isAddDefaultExcludes());
         mre.setSupportMultiLineFiltering(this.isSupportMultiLineFiltering());
         mre.setFlatten(this.isFlatten());
-        mre.setPropertiesEncoding(this.getPropertiesEncoding());
         mre.setDelimiters(new LinkedHashSet<>(this.getDelimiters()));
         mre.setInterpolatorCustomizer(this.getInterpolatorCustomizer());
         mre.setGracefulBinaryHandling(this.isGracefulBinaryHandling());
diff --git a/src/test/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFilteringTest.java b/src/test/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFilteringTest.java
index 32e99b9..d4da8ff 100644
--- a/src/test/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFilteringTest.java
+++ b/src/test/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFilteringTest.java
@@ -1211,6 +1211,95 @@ class DefaultMavenResourcesFilteringTest {
         assertTrue(contentEquals(Paths.get(unitFilesDir, "binary-file"), outputDirectory.resolve("binary-file")));
     }
 
+    /**
+     * MRESOURCES-232: getOutputEncoding returns correct encoding based on parameters.
+     */
+    @Test
+    void getOutputEncoding() {
+        Path propertiesFile = Paths.get("file.properties");
+        Path regularFile = Paths.get("file.xml");
+
+        // When outputEncoding is null -> fall back to inputEncoding (no conversion)
+        assertNull(DefaultMavenResourcesFiltering.getOutputEncoding(regularFile, null, null, null));
+        assertEquals("UTF-8", DefaultMavenResourcesFiltering.getOutputEncoding(regularFile, null, null, "UTF-8"));
+
+        // Regular file with outputEncoding set -> use outputEncoding
+        assertEquals(
+                "UTF-16", DefaultMavenResourcesFiltering.getOutputEncoding(regularFile, "UTF-16", null, "ISO-8859-1"));
+        // Regular file: outputPropertiesEncoding is ignored for non-properties files
+        assertEquals(
+                "UTF-16",
+                DefaultMavenResourcesFiltering.getOutputEncoding(regularFile, "UTF-16", "ISO-8859-1", "UTF-8"));
+
+        // Properties file with outputEncoding only -> use outputEncoding
+        assertEquals(
+                "UTF-16",
+                DefaultMavenResourcesFiltering.getOutputEncoding(propertiesFile, "UTF-16", null, "ISO-8859-1"));
+        // Properties file with outputPropertiesEncoding -> use outputPropertiesEncoding
+        assertEquals(
+                "ISO-8859-1",
+                DefaultMavenResourcesFiltering.getOutputEncoding(propertiesFile, "UTF-16", "ISO-8859-1", "UTF-8"));
+    }
+
+    /**
+     * MRESOURCES-232: Filtering with a different output encoding should produce a file
+     * readable with that output encoding (encoding conversion test).
+     * Input: ISO-8859-1 source containing a token and a Latin-1 character (é = 0xE9).
+     * Output: UTF-8 (é is encoded as 0xC3 0xA9 in UTF-8).
+     */
+    @Test
+    void filterResourcesWithOutputEncoding() throws Exception {
+        mavenProject.addProperty("greeting", "World");
+        String unitFilesDir = getBasedir() + "/src/test/units-files/MRESOURCES-232";
+
+        Resource resource = new Resource();
+        resource.setDirectory(unitFilesDir);
+        resource.setFiltering(true);
+
+        MavenResourcesExecution mre = new MavenResourcesExecution(
+                Collections.singletonList(resource),
+                outputDirectory,
+                mavenProject,
+                "ISO-8859-1",
+                Collections.emptyList(),
+                Collections.emptyList(),
+                new StubSession());
+        mre.setOutputEncoding("UTF-8");
+        mavenResourcesFiltering.filterResources(mre);
+
+        // Read the output file as UTF-8 — the token must be expanded and é must survive
+        String result = Files.readString(outputDirectory.resolve("message.txt"), StandardCharsets.UTF_8);
+        assertEquals("Bonjour World! café", result);
+    }
+
+    /**
+     * MRESOURCES-232: Per-resource outputEncoding overrides the global setting.
+     */
+    @Test
+    void filterResourcesWithPerResourceOutputEncoding() throws Exception {
+        mavenProject.addProperty("greeting", "World");
+        String unitFilesDir = getBasedir() + "/src/test/units-files/MRESOURCES-232";
+
+        Resource resource = new Resource();
+        resource.setDirectory(unitFilesDir);
+        resource.setFiltering(true);
+        resource.setEncoding("ISO-8859-1");
+        resource.setOutputEncoding("UTF-8");
+
+        MavenResourcesExecution mre = new MavenResourcesExecution();
+        mre.setResources(Collections.singletonList(resource));
+        mre.setOutputDirectory(outputDirectory);
+        mre.setEncoding("UTF-8"); // global encoding — overridden per-resource
+        mre.setMavenProject(mavenProject);
+        mre.setMavenSession(new StubSession());
+        mre.setNonFilteredFileExtensions(Collections.emptyList());
+        mre.setUseDefaultFilterWrappers(true);
+        mavenResourcesFiltering.filterResources(mre);
+
+        String result = Files.readString(outputDirectory.resolve("message.txt"), StandardCharsets.UTF_8);
+        assertEquals("Bonjour World! café", result);
+    }
+
     private String filename(Path file) {
         return file.getFileName().toString();
     }
diff --git a/src/test/units-files/MRESOURCES-232/message.txt b/src/test/units-files/MRESOURCES-232/message.txt
new file mode 100644
index 0000000..d7521b9
--- /dev/null
+++ b/src/test/units-files/MRESOURCES-232/message.txt
@@ -0,0 +1 @@
+Bonjour ${greeting}! caf�
\ No newline at end of file