Author: tilman
Date: Sat Jun 13 10:33:01 2026
New Revision: 1935225
Log:
PDFBOX-6210: Fix Incorrect CJK Character Extraction for Shared Glyphs, by
Chanhyuk Lee; closes #470
Modified:
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2Embedder.java
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/TrueTypeEmbedder.java
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/TestFontEmbedding.java
Modified:
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2Embedder.java
==============================================================================
---
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2Embedder.java
Sat Jun 13 10:32:57 2026 (r1935224)
+++
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2Embedder.java
Sat Jun 13 10:33:01 2026 (r1935225)
@@ -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,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/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/TrueTypeEmbedder.java
==============================================================================
---
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/TrueTypeEmbedder.java
Sat Jun 13 10:32:57 2026 (r1935224)
+++
pdfbox/branches/3.0/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/TrueTypeEmbedder.java
Sat Jun 13 10:33:01 2026 (r1935225)
@@ -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/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/TestFontEmbedding.java
==============================================================================
---
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/TestFontEmbedding.java
Sat Jun 13 10:32:57 2026 (r1935224)
+++
pdfbox/branches/3.0/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/TestFontEmbedding.java
Sat Jun 13 10:33:01 2026 (r1935225)
@@ -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;
@@ -440,6 +444,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;