Author: tilman
Date: Sat Jun 13 10:32:53 2026
New Revision: 1935223

Log:
PDFBOX-6210: Fix Incorrect CJK Character Extraction for Shared Glyphs, by 
Chanhyuk Lee; closes #470

Modified:
   
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2Embedder.java
   
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/TrueTypeEmbedder.java
   
pdfbox/branches/2.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/TestFontEmbedding.java

Modified: 
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2Embedder.java
==============================================================================
--- 
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2Embedder.java
 Sat Jun 13 10:14:52 2026        (r1935222)
+++ 
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2Embedder.java
 Sat Jun 13 10:32:53 2026        (r1935223)
@@ -21,6 +21,7 @@ import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -132,6 +133,20 @@ final class PDCIDFontType2Embedder exten
 
     private void buildToUnicodeCMap(Map<Integer, Integer> newGIDToOldCID) 
throws IOException
     {
+        // PDFBOX-6210:
+        // When several code points map to one glyph, prefer the one actually 
used in the
+        // document (first occurrence wins) instead of 
cmapLookup.getCharCodes(gid).get(0),
+        // which is the lowest code point and often an unexpected 
compatibility character.
+        Map<Integer, Integer> inputCodePointByGID = new HashMap<Integer, 
Integer>();
+        for (int codePoint : getSubsetCodePoints())
+        {
+            int inputGid = cmapLookup.getGlyphId(codePoint);
+            if (inputGid > 0)
+            {
+                inputCodePointByGID.putIfAbsent(inputGid, codePoint);
+            }
+        }
+
         ToUnicodeWriter toUniWriter = new ToUnicodeWriter();
         boolean hasSurrogates = false;
         for (int gid = 1, max = ttf.getMaximumProfile().getNumGlyphs(); gid <= 
max; gid++)
@@ -156,10 +171,14 @@ final class PDCIDFontType2Embedder exten
 
             // skip composite glyph components that have no code point
             List<Integer> codes = cmapLookup.getCharCodes(cid); // old GID -> 
Unicode
-            if (codes != null)
+            // PDFBOX-6210: try to get the codepoint that is actually use in 
the document
+            // instead of cmapLookup.getCharCodes(gid).get(0)
+            // set inputCodePoint to null to test pre-PDFBOX-6210 behavior
+            Integer inputCodePoint = inputCodePointByGID.get(cid); 
+            if (inputCodePoint != null || codes != null)
             {
-                // use the first entry even for ambiguous mappings
-                int codePoint = codes.get(0);
+                // fall back to the cmap's first entry for glyphs with no 
recorded input
+                int codePoint = inputCodePoint != null ? inputCodePoint : 
codes.get(0);
                 if (codePoint > 0xFFFF)
                 {
                     hasSurrogates = true;

Modified: 
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/TrueTypeEmbedder.java
==============================================================================
--- 
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/TrueTypeEmbedder.java
       Sat Jun 13 10:14:52 2026        (r1935222)
+++ 
pdfbox/branches/2.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/TrueTypeEmbedder.java
       Sat Jun 13 10:32:53 2026        (r1935223)
@@ -23,7 +23,7 @@ import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.Arrays;
-import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -75,7 +75,7 @@ abstract class TrueTypeEmbedder implemen
     protected final CmapSubtable cmap;
 
     protected final CmapLookup cmapLookup;
-    private final Set<Integer> subsetCodePoints = new HashSet<Integer>();
+    private final Set<Integer> subsetCodePoints = new LinkedHashSet<Integer>();
     private final boolean embedSubset;
 
     /**
@@ -325,7 +325,19 @@ abstract class TrueTypeEmbedder implemen
     {
         subsetCodePoints.add(codePoint);
     }
-    
+
+    /**
+     * Returns the Unicode code points that were passed to {@link 
#addToSubset(int)}, i.e. the code
+     * points actually used in the document, in first-occurrence order. Used 
when building the
+     * ToUnicode CMap to map a glyph back to the code point that was really 
typed.
+     *
+     * @return the code points added to the subset, in insertion order
+     */
+    Set<Integer> getSubsetCodePoints()
+    {
+        return subsetCodePoints;
+    }
+
     @Override
     public void subset() throws IOException
     {

Modified: 
pdfbox/branches/2.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/TestFontEmbedding.java
==============================================================================
--- 
pdfbox/branches/2.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/TestFontEmbedding.java
      Sat Jun 13 10:14:52 2026        (r1935222)
+++ 
pdfbox/branches/2.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/TestFontEmbedding.java
      Sat Jun 13 10:32:53 2026        (r1935223)
@@ -19,13 +19,16 @@ package org.apache.pdfbox.pdmodel.font;
 
 import java.io.ByteArrayOutputStream;
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
 import junit.framework.TestCase;
+import org.apache.fontbox.ttf.CmapLookup;
 
 import org.apache.fontbox.ttf.OS2WindowsMetricsTable;
 import org.apache.fontbox.ttf.TTFParser;
@@ -227,6 +230,114 @@ public class TestFontEmbedding extends T
         assertEquals(text, extracted.trim());
     }
 
+    /**
+     * When several code points map to one glyph, the ToUnicode CMap must 
reflect the code point
+     * actually used, not the lowest one sharing the glyph. Finds such an 
ambiguous glyph in the
+     * test font and verifies that each code point sharing it round-trips as 
itself.
+     *
+     * @throws IOException
+     */
+    public void testToUnicodePrefersUsedCodePoint() throws IOException
+    {
+        // Find a glyph reachable from two distinct printable code points, 
preferring a CJK
+        // radical/ideograph pair (e.g. 食 U+98DF and its radical look-alike ⻝ 
U+2EDD). lowCp is
+        // what the old reverse mapping always picked; highCp used to be 
mis-extracted as lowCp.
+        int lowCp = -1;
+        int highCp = -1;
+        TrueTypeFont ttf = new TTFParser().parse(getNotoCjk());
+        CmapLookup cmap = ttf.getUnicodeCmapLookup();
+        int numGlyphs = ttf.getMaximumProfile().getNumGlyphs();
+        boolean cjkPair = false;
+        for (int gid = 1; gid <= numGlyphs && !cjkPair; gid++)
+        {
+            List<Integer> codes = cmap.getCharCodes(gid); // sorted ascending
+            if (codes == null || codes.size() < 2)
+            {
+                continue;
+            }
+            int lo = -1;
+            int hi = -1;
+            for (int cp : codes)
+            {
+                if (cp <= 0xFFFF && !Character.isWhitespace(cp) && 
!Character.isISOControl(cp))
+                {
+                    if (lo == -1)
+                    {
+                        lo = cp;
+                    }
+                    else
+                    {
+                        hi = cp;
+                        break;
+                    }
+                }
+            }
+            // lo >= 0x2E80 == CJK Radicals Supplement and above: a CJK 
demonstration
+            if (hi != -1 && (lowCp == -1 || lo >= 0x2E80))
+            {
+                lowCp = lo;
+                highCp = hi;
+                cjkPair = lo >= 0x2E80;
+            }
+        }
+        ttf.close();
+        assertTrue("test font has no glyph shared between two printable code 
points", highCp != -1);
+
+        // Each code point must round-trip as itself. Without the fix, highCp 
was extracted as lowCp.
+        assertEquals(new String(Character.toChars(highCp)), 
renderAndExtract(1, highCp).trim());
+        assertEquals(new String(Character.toChars(lowCp)), renderAndExtract(2, 
lowCp).trim());
+    }
+
+    /**
+     * Explicit case: a CJK Unified Ideograph and its radical look-alike that 
share one glyph. The
+     * ideograph must extract as itself (not the radical), and an 
intentionally typed radical must
+     * be preserved.
+     *
+     * @throws IOException
+     */
+    public void testToUnicodeCjkAndRadicalLookAlike() throws IOException
+    {
+        final int ideograph = 0x98DF; // 食 CJK Unified Ideograph
+        final int radical = 0x2EDD;   // ⻝ CJK RADICAL EAT ONE, shares the 
glyph
+
+        // precondition: both code points map to the same glyph and the 
radical is the lower one,
+        // i.e. the entry the old reverse mapping wrongly picked for both
+        TrueTypeFont ttf = new TTFParser().parse(getNotoCjk());
+        CmapLookup cmap = ttf.getUnicodeCmapLookup();
+        int gid = cmap.getGlyphId(ideograph);
+        assertTrue("test font must map both code points to the same glyph", 
gid > 0 && gid == cmap.getGlyphId(radical));
+        assertEquals(radical, (int) cmap.getCharCodes(gid).get(0));
+        ttf.close();
+
+        assertEquals(new String(Character.toChars(ideograph)), 
renderAndExtract(3, ideograph).trim());
+        assertEquals(new String(Character.toChars(radical)), 
renderAndExtract(4, radical).trim());
+    }
+
+    private static InputStream getNotoCjk() throws IOException
+    {
+        return new FileInputStream("target/fonts/NotoSansCJKkr-VF.ttf");
+    }
+
+    // num parameter needed because two tests produce the same files
+    private String renderAndExtract(int num, int codePoint) throws IOException
+    {
+        File file = new File(OUT_DIR, "ToUnicode-" + num + "-U+" + 
Integer.toHexString(codePoint) + ".pdf");
+        PDDocument document = new PDDocument();
+        PDPage page = new PDPage(PDRectangle.A4);
+        document.addPage(page);
+        PDType0Font font = PDType0Font.load(document, getNotoCjk());
+        PDPageContentStream stream = new PDPageContentStream(document, page);
+        stream.beginText();
+        stream.setFont(font, 20);
+        stream.newLineAtOffset(50, 700);
+        stream.showText(new String(Character.toChars(codePoint)));
+        stream.endText();
+        stream.close();
+        document.save(file);
+        document.close();
+        return getUnicodeText(file);
+    }
+
     private void validateCIDFontType2(boolean useSubset) throws Exception
     {
         PDDocument document = new PDDocument();

Reply via email to