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();