Author: tilman
Date: Sat Jun 13 10:32:57 2026
New Revision: 1935224

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

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

Modified: 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2Embedder.java
==============================================================================
--- 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2Embedder.java
        Sat Jun 13 10:32:53 2026        (r1935223)
+++ 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2Embedder.java
        Sat Jun 13 10:32:57 2026        (r1935224)
@@ -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;
@@ -128,6 +129,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<>();
+        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++)
@@ -152,10 +167,18 @@ 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 (LOG.isDebugEnabled() && codes != null && codes.size() > 1)
+                {
+                    LOG.debug("codes: {}, code chosen: {}", codes, codePoint);
+                }
                 if (codePoint > 0xFFFF)
                 {
                     hasSurrogates = true;

Modified: 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/TrueTypeEmbedder.java
==============================================================================
--- 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/TrueTypeEmbedder.java
      Sat Jun 13 10:32:53 2026        (r1935223)
+++ 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/TrueTypeEmbedder.java
      Sat Jun 13 10:32:57 2026        (r1935224)
@@ -25,6 +25,7 @@ import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -65,7 +66,7 @@ abstract class TrueTypeEmbedder implemen
     protected PDFontDescriptor fontDescriptor;
 
     protected final CmapLookup cmapLookup;
-    private final Set<Integer> subsetCodePoints = new HashSet<>();
+    private final Set<Integer> subsetCodePoints = new LinkedHashSet<>();
     private final boolean embedSubset;
 
     private final Set<Integer> allGlyphIds = new HashSet<>();
@@ -299,7 +300,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;
+    }
+
     public void addGlyphIds(Set<Integer> glyphIds)
     {
         allGlyphIds.addAll(glyphIds);

Modified: 
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/TestFontEmbedding.java
==============================================================================
--- 
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/TestFontEmbedding.java
     Sat Jun 13 10:32:53 2026        (r1935223)
+++ 
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/TestFontEmbedding.java
     Sat Jun 13 10:32:57 2026        (r1935224)
@@ -19,11 +19,14 @@ 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 org.apache.fontbox.ttf.CmapLookup;
 
 import org.apache.fontbox.ttf.OS2WindowsMetricsTable;
 import org.apache.fontbox.ttf.TTFParser;
@@ -33,6 +36,7 @@ import org.apache.pdfbox.Loader;
 import org.apache.pdfbox.cos.COSArray;
 import org.apache.pdfbox.cos.COSDictionary;
 import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.io.RandomAccessReadBuffer;
 import org.apache.pdfbox.pdmodel.PDDocument;
 import org.apache.pdfbox.pdmodel.PDPage;
 import org.apache.pdfbox.pdmodel.PDResources;
@@ -441,6 +445,122 @@ class TestFontEmbedding
         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
+     */
+    @Test
+    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;
+        try (TrueTypeFont ttf = new TTFParser().parse(
+                new RandomAccessReadBuffer(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;
+                }
+            }
+        }
+        assertTrue(highCp != -1, "test font has no glyph shared between two 
printable code points");
+
+        // 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
+     */
+    @Test
+    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
+        try (TrueTypeFont ttf = new TTFParser().parse(new 
RandomAccessReadBuffer(getNotoCjk())))
+        {
+            CmapLookup cmap = ttf.getUnicodeCmapLookup();
+            int gid = cmap.getGlyphId(ideograph);
+            assertTrue(gid > 0 && gid == cmap.getGlyphId(radical),
+                    "test font must map both code points to the same glyph");
+            assertEquals(radical, (int) cmap.getCharCodes(gid).get(0));
+        }
+
+        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");
+        try (PDDocument document = new PDDocument())
+        {
+            PDPage page = new PDPage(PDRectangle.A4);
+            document.addPage(page);
+            PDType0Font font = PDType0Font.load(document, getNotoCjk());
+            try (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();
+            }
+            document.save(file);
+        }
+        return getUnicodeText(file);
+    }
+
     private void validateCIDFontType2(boolean useSubset) throws IOException
     {
         String text;

Reply via email to