Author: tilman
Date: Tue Sep  1 14:36:14 2026
New Revision: 1937725

Log:
PDFBOX-6250: substitute Adobe-Identity-0 CID-keyed fonts for non-embedded 
legacy-ROS fonts + prefer a regular weight when the descriptor has no weight 
information, by Tim Allison and 🤖

Added:
   
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/CIDCharSetMatchTest.java
   (contents, props changed)
   
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType0SubstituteTest.java
   (contents, props changed)
Modified:
   
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/FontMapperImpl.java
   
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType0.java

Modified: 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/FontMapperImpl.java
==============================================================================
--- 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/FontMapperImpl.java
        Tue Sep  1 12:45:05 2026        (r1937724)
+++ 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/FontMapperImpl.java
        Tue Sep  1 14:36:14 2026        (r1937725)
@@ -636,6 +636,11 @@ final class FontMapperImpl implements Fo
                 float dist = Math.abs(fontDescriptor.getFontWeight() - 
info.getWeightClass());
                 match.score += 1 - (dist / 100) * 0.5;
             }
+            else if (info.getWeightClass() > 0)
+            {
+                // no weight information in the descriptor: prefer a regular 
weight
+                match.score += 1 - (Math.abs(info.getWeightClass() - 400) / 
100) * 0.5;
+            }
             // todo: italic
             // ...
 
@@ -664,7 +669,7 @@ final class FontMapperImpl implements Fo
      * Returns true if the character set described by CIDSystemInfo is present 
in the given font.
      * Only applies to Adobe-GB1, Adobe-CNS1, Adobe-Japan1, Adobe-Korea1, as 
per the PDF spec.
      */
-    private boolean isCharSetMatch(PDCIDSystemInfo cidSystemInfo, FontInfo 
info)
+    boolean isCharSetMatch(PDCIDSystemInfo cidSystemInfo, FontInfo info)
     {
         String ordering = cidSystemInfo.getOrdering();
         if (ordering == null)
@@ -673,10 +678,18 @@ final class FontMapperImpl implements Fo
         }
         if (info.getCIDSystemInfo() != null)
         {
-            return 
info.getCIDSystemInfo().getRegistry().equals(cidSystemInfo.getRegistry()) &&
-                   info.getCIDSystemInfo().getOrdering().equals(ordering);
+            if 
(info.getCIDSystemInfo().getRegistry().equals(cidSystemInfo.getRegistry()) &&
+                info.getCIDSystemInfo().getOrdering().equals(ordering))
+            {
+                return true;
+            }
+            if (!"Identity".equals(info.getCIDSystemInfo().getOrdering()))
+            {
+                return false;
+            }
+            // PDFBOX-6249: Adobe-Identity-0 fonts (Noto CJK, Source Han) 
never match a ROS by
+            // name; fall through to the code page bits
         }
-        else
         {
             long codePageRange = info.getCodePageRange();
             

Modified: 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType0.java
==============================================================================
--- 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType0.java
        Tue Sep  1 12:45:05 2026        (r1937724)
+++ 
pdfbox/trunk/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType0.java
        Tue Sep  1 14:36:14 2026        (r1937725)
@@ -31,6 +31,7 @@ import org.apache.fontbox.cff.CFFFont;
 import org.apache.fontbox.cff.CFFParser;
 import org.apache.fontbox.cff.CFFType1Font;
 import org.apache.fontbox.cff.Type2CharString;
+import org.apache.fontbox.ttf.CmapLookup;
 import org.apache.fontbox.util.BoundingBox;
 import org.apache.pdfbox.cos.COSDictionary;
 import org.apache.pdfbox.io.RandomAccessRead;
@@ -54,6 +55,10 @@ public class PDCIDFontType0 extends PDCI
 
     private final CFFCIDFont cidFont;  // Top DICT that uses CIDFont operators
     private final FontBoxFont t1Font; // Top DICT that does not use CIDFont 
operators
+
+    // substitute is CID-keyed with a different ROS: this font's CIDs are 
meaningless in it,
+    // resolve glyphs via Unicode instead (PDFBOX-6249)
+    private final CmapLookup substituteUnicodeCmap;
     
     private final Map<Integer, Float> glyphHeights = new HashMap<>();
     private final AffineTransform fontMatrixTransform;
@@ -76,6 +81,7 @@ public class PDCIDFontType0 extends PDCI
         super(fontDictionary, resourceCache);
 
         boolean fontIsDamaged = false;
+        CmapLookup substituteCmap = null;
         CFFFont cffFont = null;
         PDFontDescriptor fd = getFontDescriptor();
         if (fd != null)
@@ -157,9 +163,22 @@ public class PDCIDFontType0 extends PDCI
             {
                 LOG.warn("Using fallback {} for CID-keyed font {}", 
font.getName(), getBaseFont());
             }
+            if (cidFont != null && mapping.isCIDFont() && 
!isCharacterCollectionMatch(cidFont) &&
+                "Identity".equals(cidFont.getOrdering()))
+            {
+                try
+                {
+                    substituteCmap = mapping.getFont().getUnicodeCmapLookup();
+                }
+                catch (IOException e)
+                {
+                    LOG.warn("Could not read cmap of the substitute for font 
{}", getBaseFont(), e);
+                }
+            }
             isEmbedded = false;
             isDamaged = fontIsDamaged;
         }
+        substituteUnicodeCmap = substituteCmap;
         fontMatrixTransform = getFontMatrix().createAffineTransform();
         fontMatrixTransform.scale(1000, 1000);
     }
@@ -299,6 +318,27 @@ public class PDCIDFontType0 extends PDCI
         }
     }
 
+    private boolean isCharacterCollectionMatch(CFFCIDFont substitute) throws 
IOException
+    {
+        PDCIDSystemInfo ros = getCIDSystemInfo();
+        return ros != null && 
ros.getRegistry().equals(substitute.getRegistry()) &&
+                ros.getOrdering().equals(substitute.getOrdering());
+    }
+
+    /**
+     * GID in the substitute for the given code, via Unicode; -1 if unmapped. 
The substitute's
+     * Identity charset makes GIDs address its charstrings directly.
+     */
+    private int codeToSubstituteGID(int code, PDType0Font parent)
+    {
+        String unicodes = parent.toUnicode(code);
+        if (unicodes == null)
+        {
+            return -1;
+        }
+        return substituteUnicodeCmap.getGlyphId(unicodes.codePointAt(0));
+    }
+
     /**
      * Returns the name of the glyph with the given character code. This is 
done by looking up the
      * code in the parent font's ToUnicode map and generating a glyph name 
from that.
@@ -317,6 +357,11 @@ public class PDCIDFontType0 extends PDCI
     protected GeneralPath getPath(int code, PDType0Font parent) throws 
IOException
     {
         int cid = codeToCID(code, parent);
+        if (substituteUnicodeCmap != null)
+        {
+            int gid = codeToSubstituteGID(code, parent);
+            return getType2CharString(Math.max(gid, 0)).getPath();
+        }
         if (cid2gid != null && isEmbedded)
         {
             // PDFBOX-4093: despite being a type 0 font, there is a CIDToGIDMap
@@ -347,6 +392,10 @@ public class PDCIDFontType0 extends PDCI
     protected boolean hasGlyph(int code, PDType0Font parent) throws IOException
     {
         int cid = codeToCID(code, parent);
+        if (substituteUnicodeCmap != null)
+        {
+            return codeToSubstituteGID(code, parent) > 0;
+        }
         Type2CharString charstring = getType2CharString(cid);
         if (charstring != null)
         {
@@ -378,6 +427,10 @@ public class PDCIDFontType0 extends PDCI
     protected int codeToGID(int code, PDType0Font parent)
     {
         int cid = codeToCID(code, parent);
+        if (substituteUnicodeCmap != null)
+        {
+            return Math.max(codeToSubstituteGID(code, parent), 0);
+        }
         if (cidFont != null)
         {
             // The CIDs shall be used to determine the GID value for the glyph 
procedure using the
@@ -402,7 +455,11 @@ public class PDCIDFontType0 extends PDCI
     {
         int cid = codeToCID(code, parent);
         float width;
-        if (cidFont != null)
+        if (substituteUnicodeCmap != null)
+        {
+            width = getType2CharString(Math.max(codeToSubstituteGID(code, 
parent), 0)).getWidth();
+        }
+        else if (cidFont != null)
         {
             width = getType2CharString(cid).getWidth();
         }

Added: 
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/CIDCharSetMatchTest.java
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/CIDCharSetMatchTest.java
   Tue Sep  1 14:36:14 2026        (r1937725)
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.pdfbox.pdmodel.font;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.fontbox.FontBoxFont;
+import org.junit.jupiter.api.Test;
+
+/**
+ * CIDSystemInfo-based candidate filtering for CID font substitution.
+ */
+class CIDCharSetMatchTest
+{
+    private static final long CHINESE_TRADITIONAL = 1 << 20;
+    private static final long CHINESE_SIMPLIFIED = 1 << 18;
+
+    private static FontInfo info(final CIDSystemInfo ros, final int 
codePageRange1)
+    {
+        return new FontInfo()
+        {
+            @Override
+            public String getPostScriptName()
+            {
+                return "TestFont";
+            }
+
+            @Override
+            public FontFormat getFormat()
+            {
+                return FontFormat.OTF;
+            }
+
+            @Override
+            public CIDSystemInfo getCIDSystemInfo()
+            {
+                return ros;
+            }
+
+            @Override
+            public FontBoxFont getFont()
+            {
+                return null;
+            }
+
+            @Override
+            public int getFamilyClass()
+            {
+                return 0;
+            }
+
+            @Override
+            public int getWeightClass()
+            {
+                return 0;
+            }
+
+            @Override
+            public int getCodePageRange1()
+            {
+                return codePageRange1;
+            }
+
+            @Override
+            public int getCodePageRange2()
+            {
+                return 0;
+            }
+
+            @Override
+            public int getMacStyle()
+            {
+                return 0;
+            }
+
+            @Override
+            public PDPanoseClassification getPanose()
+            {
+                return null;
+            }
+        };
+    }
+
+    @Test
+    void testCharSetMatch()
+    {
+        FontMapperImpl mapper = new FontMapperImpl();
+        PDCIDSystemInfo cns1 = new PDCIDSystemInfo("Adobe", "CNS1", 0);
+
+        // exact ROS match
+        assertTrue(mapper.isCharSetMatch(cns1,
+                info(new CIDSystemInfo("Adobe", "CNS1", 0), 0)));
+
+        // a different legacy ROS never matches
+        assertFalse(mapper.isCharSetMatch(cns1,
+                info(new CIDSystemInfo("Adobe", "Japan1", 0), (int) 
CHINESE_TRADITIONAL)));
+
+        // Adobe-Identity-0 (Noto CJK, Source Han) matches via its OS/2 code 
page bits
+        assertTrue(mapper.isCharSetMatch(cns1,
+                info(new CIDSystemInfo("Adobe", "Identity", 0), (int) 
CHINESE_TRADITIONAL)));
+        assertFalse(mapper.isCharSetMatch(cns1,
+                info(new CIDSystemInfo("Adobe", "Identity", 0), (int) 
CHINESE_SIMPLIFIED)));
+
+        // ROS-less TrueType fonts keep matching via code page bits
+        assertTrue(mapper.isCharSetMatch(cns1, info(null, (int) 
CHINESE_TRADITIONAL)));
+        assertFalse(mapper.isCharSetMatch(cns1, info(null, 0)));
+    }
+}

Added: 
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType0SubstituteTest.java
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
pdfbox/trunk/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType0SubstituteTest.java
  Tue Sep  1 14:36:14 2026        (r1937725)
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.pdfbox.pdmodel.font;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.awt.geom.GeneralPath;
+import java.io.File;
+import java.io.IOException;
+
+import org.apache.fontbox.cff.CFFCIDFont;
+import org.apache.fontbox.cff.CFFFont;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.junit.jupiter.api.Test;
+
+/**
+ * A non-embedded Adobe-CNS1 CIDFontType0 must render real glyphs when a 
CID-keyed substitute is
+ * available, even when the substitute uses a different character collection 
(modern CJK fonts
+ * such as Noto CJK are Adobe-Identity-0). PDFBOX-6249.
+ */
+class PDCIDFontType0SubstituteTest
+{
+    @Test
+    void testLatinViaCns1NonEmbedded() throws IOException
+    {
+        File file = new 
File("src/test/resources/org/apache/pdfbox/pdmodel/font",
+                "PDFBOX-6249-cns1-nonembedded-latin.pdf");
+        try (PDDocument doc = Loader.loadPDF(file))
+        {
+            PDPage page = doc.getPage(0);
+            PDType0Font font = (PDType0Font) 
page.getResources().getFont(COSName.getPDFName("F1"));
+            PDCIDFontType0 cidFont = (PDCIDFontType0) font.getDescendantFont();
+
+            CIDFontMapping mapping = 
FontMappers.instance().getCIDFont(cidFont.getBaseFont(),
+                    cidFont.getFontDescriptor(), cidFont.getCIDSystemInfo());
+            assumeTrue(mapping.isCIDFont(),
+                    "no CID-keyed substitute for Adobe-CNS1 installed, can't 
test");
+
+            // 0x48 is "H": the CMap maps it to CID 41, which is not a valid 
glyph index in an
+            // Adobe-Identity-0 substitute; it must be resolved via Unicode
+            assertTrue(cidFont.hasGlyph(0x48, font), "glyph for 'H' not found 
in substitute");
+            GeneralPath path = cidFont.getPath(0x48, font);
+            assertFalse(path.getPathIterator(null).isDone(), "glyph for 'H' 
has an empty path");
+
+            // the repro descriptor carries no Panose or FontWeight: a regular 
weight must
+            // outrank Bold among otherwise-tied candidates
+            assertFalse(mapping.getFont().getName().endsWith("-Bold"),
+                    "regular weight should be preferred over Bold on a 
weightless descriptor");
+
+            CFFFont cff = mapping.getFont().getCFF().getFont();
+            if (cff instanceof CFFCIDFont && "Identity".equals(((CFFCIDFont) 
cff).getOrdering()))
+            {
+                // ASCII GIDs happen to line up with Adobe CIDs in Noto/Source 
Han, so assert on
+                // an ideograph, where using the CID as a GID yields the wrong 
glyph
+                int expected = 
mapping.getFont().getUnicodeCmapLookup().getGlyphId(0x4E2D);
+                assertTrue(expected > 0);
+                assertEquals(expected, cidFont.codeToGID(0x4E2D, font),
+                        "GID must be resolved via Unicode, not used as a CID");
+            }
+        }
+    }
+}

Reply via email to