Author: msahyoun
Date: Wed Aug 26 07:38:25 2026
New Revision: 1937470

Log:
PDFBOX-6242: properly escape characters for XML 1.0; parts by Claude Sonnet

Added:
   
pdfbox/branches/2.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/fdf/FDFUtilsTest.java
Modified:
   
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/fdf/FDFUtils.java

Modified: 
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/fdf/FDFUtils.java
==============================================================================
--- 
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/fdf/FDFUtils.java
        Wed Aug 26 07:31:20 2026        (r1937469)
+++ 
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/fdf/FDFUtils.java
        Wed Aug 26 07:38:25 2026        (r1937470)
@@ -16,8 +16,13 @@
  */
 package org.apache.pdfbox.pdmodel.fdf;
 
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
 public class FDFUtils {
 
+    private static final Log LOG = LogFactory.getLog(FDFUtils.class);
+
     /**
      * Escape special characters.
      *
@@ -28,10 +33,22 @@ public class FDFUtils {
     static String escapeXML10(String input)
     {
         StringBuilder escapedXML = new StringBuilder();
-        for (int i = 0; i < input.length(); i++)
+        int invalidCount = 0;
+        int i = 0;
+        while (i < input.length())
         {
-            char c = input.charAt(i);
-            switch (c)
+            int cp = input.codePointAt(i);
+            int charCount = Character.charCount(cp);
+
+            if (!isValidXML10Char(cp))
+            {
+                invalidCount++;
+                escapedXML.append('\uFFFD');
+                i += charCount;
+                continue;
+            }
+
+            switch (cp)
             {
             case '<':
                 escapedXML.append("&lt;");
@@ -49,16 +66,32 @@ public class FDFUtils {
                 escapedXML.append("&apos;");
                 break;
             default:
-                if (c > 0x7e)
+                if (cp > 0x7e)
                 {
-                    escapedXML.append("&#").append((int) c).append(';');
+                    escapedXML.append("&#").append(cp).append(';');
                 }
                 else
                 {
-                    escapedXML.append(c);
+                    escapedXML.appendCodePoint(cp);
                 }
             }
+            i += charCount;
+        }
+
+        if (invalidCount > 0 && LOG.isInfoEnabled())
+        {
+            LOG.info("Replaced " + invalidCount + " character(s) invalid in 
XML 1.0 with U+FFFD");
         }
+
+
         return escapedXML.toString();
     }
+
+    private static boolean isValidXML10Char(int cp)
+    {
+        return cp == 0x9 || cp == 0xA || cp == 0xD
+            || (cp >= 0x20 && cp <= 0xD7FF)
+            || (cp >= 0xE000 && cp <= 0xFFFD)
+            || (cp >= 0x10000 && cp <= 0x10FFFF);
+    }
 }

Added: 
pdfbox/branches/2.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/fdf/FDFUtilsTest.java
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
pdfbox/branches/2.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/fdf/FDFUtilsTest.java
    Wed Aug 26 07:38:25 2026        (r1937470)
@@ -0,0 +1,123 @@
+/*
+ * 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.pdfbox.pdmodel.fdf;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+import org.junit.Test;
+/*
+ * Test the XML 1.0 escaping performed by FDFUtils.escapeXML10().
+ */
+public class FDFUtilsTest
+{
+    @Test
+    public void testPlainAsciiIsUnchanged()
+    {
+        String input = "Hello World 123";
+        assertEquals(input, FDFUtils.escapeXML10(input));
+    }
+
+    @Test
+    public void testEmptyStringReturnsEmptyString()
+    {
+        assertEquals("", FDFUtils.escapeXML10(""));
+    }
+
+    @Test
+    public void testEscapesLessThanSign()
+    {
+        assertEquals("&lt;", FDFUtils.escapeXML10("<"));
+    }
+
+    @Test
+    public void testEscapesGreaterThanSign()
+    {
+        assertEquals("&gt;", FDFUtils.escapeXML10(">"));
+    }
+
+    @Test
+    public void testEscapesAmpersand()
+    {
+        assertEquals("&amp;", FDFUtils.escapeXML10("&"));
+    }
+
+    @Test
+    public void testEscapesDoubleQuote()
+    {
+        assertEquals("&quot;", FDFUtils.escapeXML10("\""));
+    }
+
+    @Test
+    public void testEscapesSingleQuote()
+    {
+        assertEquals("&apos;", FDFUtils.escapeXML10("'"));
+    }
+
+    @Test
+    public void testEscapesAllSpecialCharactersInOneString()
+    {
+        String input = "<tag attr=\"value\" other='x'>&</tag>";
+        String expected = "&lt;tag attr=&quot;value&quot; 
other=&apos;x&apos;&gt;&amp;&lt;/tag&gt;";
+        assertEquals(expected, FDFUtils.escapeXML10(input));
+    }
+
+    @Test
+    public void testLegalWhitespaceControlCharactersPassThroughUnescaped()
+    {
+        // Tab (0x9), line feed (0xA) and carriage return (0xD) are explicitly
+        // legal XML 1.0 characters and are not part of the escaped set.
+        String input = "line1\tline2\nline3\rline4";
+        assertEquals(input, FDFUtils.escapeXML10(input));
+    }
+
+    @Test
+    public void testNonAsciiBmpCharacterIsEscapedAsNumericReference()
+    {
+        // 'é' is U+00E9 (233 decimal)
+        assertEquals("caf&#233;", FDFUtils.escapeXML10("caf\u00e9"));
+    }
+
+    @Test
+    public void testMultipleNonAsciiCharactersAreEachEscaped()
+    {
+        // '\u00e9' = 233, '\u00e8' = 232
+        assertEquals("&#233;&#232;", FDFUtils.escapeXML10("\u00e9\u00e8"));
+    }
+
+    @Test
+    public void testIllegalControlCharacterIsNotPassedThroughRaw()
+    {
+        // 0x0B (vertical tab) is not a legal XML 1.0 character and must not
+        // appear unescaped in the output.
+        String result = FDFUtils.escapeXML10("a\u000bb");
+        assertFalse("Illegal control character must not appear raw in escaped 
output",
+                result.indexOf('\u000b') >= 0);
+        assertEquals("a\ufffdb", result);
+    }
+
+    @Test
+    public void testSupplementaryCharacterProducesSingleValidReference()
+    {
+        // U+1F600 (GRINNING FACE) is represented in Java as a surrogate pair.
+        // It must be escaped as a single reference to its code point (128512),
+        // not as two references to the individual (illegal) surrogate values.
+        String input = new String(Character.toChars(0x1F600));
+        assertEquals("&#128512;", FDFUtils.escapeXML10(input));
+    }
+}
\ No newline at end of file

Reply via email to