Author: msahyoun
Date: Tue Mar 24 16:07:10 2026
New Revision: 1932507

Log:
PDFBOX-6178: set AS and V entry resolving matching COSName

Modified:
   
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/interactive/annotation/PDAnnotation.java
   
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/interactive/form/PDButton.java
   
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/interactive/form/TestRadioButtons.java

Modified: 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/interactive/annotation/PDAnnotation.java
==============================================================================
--- 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/interactive/annotation/PDAnnotation.java
        Tue Mar 24 13:34:45 2026        (r1932506)
+++ 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/interactive/annotation/PDAnnotation.java
        Tue Mar 24 16:07:10 2026        (r1932507)
@@ -337,6 +337,20 @@ public abstract class PDAnnotation imple
     }
 
     /**
+     * This will set the annotations appearance state name.
+     * 
+     * <p>Note that the PDF specification defines the AS entry as a name, but 
some PDFs use a string.
+     * This method will write a name, which is correct and should be preferred 
but may cause issues
+     * with some viewers if the PDF being edited already uses a string.</p>
+     * 
+     * @param as The COSName of the appearance stream.
+     */
+    public void setAppearanceState(COSName as)
+    {
+        getCOSObject().setItem(COSName.AS, as);
+    }
+
+    /**
      * This will get the appearance dictionary associated with this 
annotation. This may return null.
      * 
      * @return This annotations appearance.
@@ -384,7 +398,7 @@ public abstract class PDAnnotation imple
         }
         else
         {
-// PDAppearanceStream extends PDFormXObject, but does not reference the 
resource cache
+            // PDAppearanceStream extends PDFormXObject, but does not 
reference the resource cache
             return normalAppearance.getAppearanceStream();
         }
     }

Modified: 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/interactive/form/PDButton.java
==============================================================================
--- 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/interactive/form/PDButton.java
  Tue Mar 24 13:34:45 2026        (r1932506)
+++ 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/interactive/form/PDButton.java
  Tue Mar 24 16:07:10 2026        (r1932507)
@@ -296,11 +296,11 @@ public abstract class PDButton extends P
             COSName value = getCOSObject().getCOSName(COSName.V);
             if (appearanceEntry.getCOSObject().containsKey(value))
             {
-                widget.setAppearanceState(value.getName());
+                widget.setAppearanceState(value);
             }
             else
             {
-                widget.setAppearanceState(COSName.Off.getName());
+                widget.setAppearanceState(COSName.Off);
             }
         }
     } 
@@ -390,8 +390,10 @@ public abstract class PDButton extends P
 
     private void updateByValue(String value)
     {
-        getCOSObject().setName(COSName.V, value);
-        // update the appearance state (AS)
+        // Find the matching appearance key from the first widget that has it
+        COSName matchingKey = null;
+        
+        // update the appearance state (AS) for each widget
         for (PDAnnotationWidget widget : getWidgets())
         {
             PDAppearanceDictionary appearance = widget.getAppearance();
@@ -400,15 +402,65 @@ public abstract class PDButton extends P
                 continue;
             }
             PDAppearanceEntry appearanceEntry = 
appearance.getNormalAppearance();
-            if (appearanceEntry.getCOSObject().containsKey(value))
+            COSDictionary appearanceDict = appearanceEntry.getCOSObject();
+            
+            // Find the matching appearance key by searching through the 
actual keys
+            // and comparing their decoded names. This handles encoding 
differences:
+            // the appearance key might be ISO-8859-1 encoded (e.g. 
/m#e4nnlich for "männlich")
+            // while the value String is UTF-8.
+            COSName widgetMatchingKey = 
findMatchingAppearanceKey(appearanceDict, value);
+            
+            // Save the first matching key to use for the V entry
+            if (widgetMatchingKey != null && matchingKey == null)
             {
-                widget.setAppearanceState(value);
+                matchingKey = widgetMatchingKey;
+            }
+            
+            if (widgetMatchingKey != null)
+            {
+                // Use the exact COSName from the appearance dictionary to 
preserve encoding
+                widget.setAppearanceState(widgetMatchingKey);
             }
             else
             {
-                widget.setAppearanceState(COSName.Off.getName());
+                // Fall back to Off if no match found for this widget
+                widget.setAppearanceState(COSName.Off);
+            }
+        }
+        
+        // Set the V entry once using the first matching key found
+        if (matchingKey != null)
+        {
+            getCOSObject().setItem(COSName.V, matchingKey);
+        }
+        else
+        {
+            // Fall back to UTF-8 encoding if no match found in any widget
+            getCOSObject().setName(COSName.V, value);
+        }
+    }
+
+    /**
+     * Find the appearance dictionary key that matches the given value String.
+     * This method handles encoding differences - the value might be UTF-8 
while
+     * appearance keys in the PDF could be ISO-8859-1 or other encodings.
+     *
+     * @param appearanceDict the appearance dictionary with keys to search
+     * @param value the value String to match against (typically UTF-8)
+     * @return the matching COSName key, or null if no match found
+     */
+    private COSName findMatchingAppearanceKey(COSDictionary appearanceDict, 
String value)
+    {
+        // Search all keys in the appearance dictionary and compare their 
decoded names
+        // COSName.getName() uses UTF-8 decoding with ISO-8859-1 fallback for 
non-UTF-8 bytes
+        for (COSName key : appearanceDict.keySet())
+        {
+            if (value.equals(key.getName()))
+            {
+                return key;
             }
         }
+        return null;
     }
 
     private void updateByOption(String value)

Modified: 
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/interactive/form/TestRadioButtons.java
==============================================================================
--- 
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/interactive/form/TestRadioButtons.java
  Tue Mar 24 13:34:45 2026        (r1932506)
+++ 
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/interactive/form/TestRadioButtons.java
  Tue Mar 24 16:07:10 2026        (r1932507)
@@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Asse
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
@@ -366,4 +367,124 @@ class TestRadioButtons
             assertEquals(1, field.getSelectedIndex());
         }        
     }
+
+    /**
+     * PDFBOX-6178: Ensure that RadioButton values with non-ASCII characters 
preserve encoding.
+     * When setting a RadioButton value to "männlich", both V and AS entries 
should preserve
+     * the original byte encoding from the appearance dictionary (0xE4 for ä 
in ISO-8859-1,
+     * not 0xC3 0xA4 from UTF-8).
+     * 
+     * @throws IOException
+     */
+    @Test
+    void testPDFBox6178NonAsciiRadioButtonValue() throws IOException
+    {
+        File pdfFile = new File("target/pdfs/PDFBOX-6178.pdf");
+        if (!pdfFile.exists())
+        {
+            return;  // Skip test if PDF not available
+        }
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+
+        // Load document, set value, and save to memory
+        try (PDDocument document = Loader.loadPDF(pdfFile))
+        {
+            PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
+            PDField field = acroForm.getField("Geschlecht");
+            
+            field.setValue("männlich");
+            
+            // Verify V entry preserves encoding - should have 0xE4 byte for ä 
(ISO-8859-1)
+            COSName vEntry = (COSName) 
field.getCOSObject().getDictionaryObject(COSName.V);
+            assertNotNull(vEntry, "V entry should not be null after setValue");
+            
+            // Check that the bytes contain 0xE4 (ISO-8859-1 ä) not 0xC3 0xA4 
(UTF-8)
+            byte[] vBytes = vEntry.getBytes();
+            
+            assertFalse(containsSequence(vBytes, new byte[]{(byte) 0xC3, 
(byte) 0xA4}),
+                    "V entry should not contain UTF-8 encoded ä (0xC3 0xA4)");
+            assertTrue(containsSequence(vBytes, new byte[]{(byte) 0xE4}),
+                    "V entry should contain ISO-8859-1 encoded ä (0xE4)");
+            
+            // Verify AS entry preserves encoding
+            COSName asEntry = (COSName) 
field.getWidgets().get(0).getCOSObject()
+                    .getDictionaryObject(COSName.AS);
+            assertNotNull(asEntry, "AS entry should not be null after 
setValue");
+            
+            // Check that the AS bytes also preserve ISO-8859-1 encoding
+            byte[] asBytes = asEntry.getBytes();
+            
+            assertFalse(containsSequence(asBytes, new byte[]{(byte) 0xC3, 
(byte) 0xA4}),
+                    "AS entry should not contain UTF-8 encoded ä (0xC3 0xA4)");
+            assertTrue(containsSequence(asBytes, new byte[]{(byte) 0xE4}),
+                    "AS entry should contain ISO-8859-1 encoded ä (0xE4)");
+            
+            document.save(baos);
+        }
+
+        // Reload and verify entries are still correct
+        try (PDDocument document = Loader.loadPDF(baos.toByteArray()))
+        {
+            PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
+            PDField field = acroForm.getField("Geschlecht");
+            
+            // Verify V entry after reload
+            COSName vEntry = (COSName) 
field.getCOSObject().getDictionaryObject(COSName.V);
+            assertNotNull(vEntry, "V entry should not be null after reload");
+            
+            byte[] vBytes = vEntry.getBytes();
+            assertFalse(containsSequence(vBytes, new byte[]{(byte) 0xC3, 
(byte) 0xA4}),
+                    "V entry should still not contain UTF-8 ä after reload");
+            assertTrue(containsSequence(vBytes, new byte[]{(byte) 0xE4}),
+                    "V entry should still contain ISO-8859-1 ä after reload");
+            
+            // Verify AS entry after reload
+            COSName asEntry = (COSName) 
field.getWidgets().get(0).getCOSObject()
+                    .getDictionaryObject(COSName.AS);
+            assertNotNull(asEntry, "AS entry should not be null after reload");
+            
+            byte[] asBytes = asEntry.getBytes();
+            assertFalse(containsSequence(asBytes, new byte[]{(byte) 0xC3, 
(byte) 0xA4}),
+                    "AS entry should still not contain UTF-8 ä after reload");
+            assertTrue(containsSequence(asBytes, new byte[]{(byte) 0xE4}),
+                    "AS entry should still contain ISO-8859-1 ä after reload");
+        }
+    }
+
+    /**
+     * Helper method to check if a byte sequence contains a particular 
sub-sequence.
+     *
+     * @param haystack the bytes to search in
+     * @param needle the bytes to search for
+     * @return true if needle is found in haystack, false otherwise
+     */
+    private boolean containsSequence(byte[] haystack, byte[] needle)
+    {
+        if (needle.length == 0)
+        {
+            return true;
+        }
+        if (needle.length > haystack.length)
+        {
+            return false;
+        }
+        for (int i = 0; i <= haystack.length - needle.length; i++)
+        {
+            boolean match = true;
+            for (int j = 0; j < needle.length; j++)
+            {
+                if (haystack[i + j] != needle[j])
+                {
+                    match = false;
+                    break;
+                }
+            }
+            if (match)
+            {
+                return true;
+            }
+        }
+        return false;
+    }
 }

Reply via email to