Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.72% covered (success)
96.72%
59 / 61
85.71% covered (success)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
CidDecoder
96.72% covered (success)
96.72%
59 / 61
85.71% covered (success)
85.71%
6 / 7
32
0.00% covered (danger)
0.00%
0 / 1
 decode
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 splitCodes
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 codeToCid
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 cidToGid
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getReverseCmap
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 buildReverseCmap
88.24% covered (success)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
10.16
 codepointToUtf8
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Pdf\Extract\Font;
16
17use Pop\Pdf\Build\Font\TrueType;
18
19/**
20 * Pdf extract CID font decoder class
21 *
22 * @category   Pop
23 * @package    Pop\Pdf
24 * @author     Nick Sagona, III <nick@popphp.org>
25 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    6.0.0
28 */
29class CidDecoder
30{
31
32    /**
33     * Per-FontInfo cache of built glyph-ID-to-Unicode reverse cmaps
34     * @var \WeakMap
35     */
36    protected static \WeakMap $reverseCmapCache;
37
38    /**
39     * Decode a Type0/CID composite font's raw byte string to Unicode text
40     *
41     * @param  string   $rawBytes
42     * @param  FontInfo $info
43     * @return string
44     */
45    public static function decode(string $rawBytes, FontInfo $info): string
46    {
47        $codes = self::splitCodes($rawBytes, $info->encoding);
48
49        $reverseCmap      = null;
50        $reverseCmapBuilt = false;
51
52        $out = '';
53
54        foreach ($codes as $code) {
55            if (($info->toUnicodeCMap !== null) && isset($info->toUnicodeCMap['bfMappings'][$code])) {
56                $out .= $info->toUnicodeCMap['bfMappings'][$code];
57                continue;
58            }
59
60            if (!$reverseCmapBuilt) {
61                $reverseCmap      = self::getReverseCmap($info);
62                $reverseCmapBuilt = true;
63            }
64
65            $cid = self::codeToCid($code, $info->encoding);
66            $gid = self::cidToGid($cid, $info->cidToGidMap);
67
68            $unicode = $reverseCmap[$gid] ?? null;
69            if ($unicode !== null) {
70                $out .= self::codepointToUtf8($unicode);
71            }
72        }
73
74        return $out;
75    }
76
77    /**
78     * Split raw bytes into fixed-width character codes per the encoding's codespace range
79     *
80     * @param  string $rawBytes
81     * @param  mixed  $encoding
82     * @return array
83     */
84    protected static function splitCodes(string $rawBytes, mixed $encoding): array
85    {
86        $length = 2;
87
88        if (is_array($encoding) && !empty($encoding['codespaceRanges'])) {
89            $length = $encoding['codespaceRanges'][0]['length'] ?? 2;
90        }
91
92        if ($length < 1) {
93            $length = 2;
94        }
95
96        $codes = [];
97        for ($i = 0; ($i + $length) <= strlen($rawBytes); $i += $length) {
98            $chunk = substr($rawBytes, $i, $length);
99            $value = 0;
100            for ($j = 0; $j < $length; $j++) {
101                $value = ($value << 8) | ord($chunk[$j]);
102            }
103            $codes[] = $value;
104        }
105
106        return $codes;
107    }
108
109    /**
110     * Map a character code to a CID via the encoding's cidMappings, or identity
111     *
112     * @param  int   $code
113     * @param  mixed $encoding
114     * @return int
115     */
116    protected static function codeToCid(int $code, mixed $encoding): int
117    {
118        if (is_array($encoding) && isset($encoding['cidMappings'][$code])) {
119            return $encoding['cidMappings'][$code];
120        }
121
122        // Identity-H/V (or any predefined encoding this decoder doesn't
123        // specifically recognize): CID == code, directly.
124        return $code;
125    }
126
127    /**
128     * Map a CID to a glyph ID via the CIDToGIDMap stream bytes, or identity
129     *
130     * @param  int   $cid
131     * @param  mixed $cidToGidMap
132     * @return int
133     */
134    protected static function cidToGid(int $cid, mixed $cidToGidMap): int
135    {
136        if (!is_string($cidToGidMap) || ($cidToGidMap === 'Identity')) {
137            return $cid;
138        }
139
140        $offset = $cid * 2;
141        if (($offset + 2) > strlen($cidToGidMap)) {
142            return $cid;
143        }
144
145        return (ord($cidToGidMap[$offset]) << 8) | ord($cidToGidMap[$offset + 1]);
146    }
147
148    /**
149     * Get a cached reverse cmap for a FontInfo, building and caching it if needed
150     *
151     * @param  FontInfo $info
152     * @return array
153     */
154    protected static function getReverseCmap(FontInfo $info): array
155    {
156        self::$reverseCmapCache ??= new \WeakMap();
157
158        if (!isset(self::$reverseCmapCache[$info])) {
159            self::$reverseCmapCache[$info] = self::buildReverseCmap($info->embeddedFontBytes);
160        }
161
162        return self::$reverseCmapCache[$info];
163    }
164
165    /**
166     * Build a glyph-ID-to-Unicode reverse cmap from an embedded TrueType font's own cmap table
167     *
168     * @param  ?string $embeddedFontBytes
169     * @return array
170     */
171    protected static function buildReverseCmap(?string $embeddedFontBytes): array
172    {
173        if ($embeddedFontBytes === null) {
174            return [];
175        }
176
177        try {
178            $font = new TrueType(null, $embeddedFontBytes);
179        } catch (\Throwable $e) {
180            return [];
181        }
182
183        $cmapTable = $font->tables['cmap'] ?? null;
184        if ($cmapTable === null) {
185            return [];
186        }
187
188        $reverse = [];
189        foreach ($cmapTable->subTables as $subTable) {
190            if (($subTable->encoding === 'Microsoft Unicode') || ($subTable->encoding === 'Unicode')) {
191                foreach (($subTable->parsed['glyphNumbers'] ?? []) as $unicode => $glyphId) {
192                    if (!isset($reverse[$glyphId])) {
193                        $reverse[$glyphId] = $unicode;
194                    }
195                }
196                // Only stop at the first Unicode-ish subtable if it actually
197                // produced usable glyph numbers - some subtable formats
198                // (anything other than 0/4/6) never populate `parsed`,
199                // which would otherwise discard a perfectly good later
200                // subtable and silently lose all text for this font.
201                if (!empty($reverse)) {
202                    break;
203                }
204            }
205        }
206
207        return $reverse;
208    }
209
210    /**
211     * Convert a Unicode codepoint to a UTF-8 string
212     *
213     * @param  int $codepoint
214     * @return string
215     */
216    protected static function codepointToUtf8(int $codepoint): string
217    {
218        return @mb_convert_encoding(pack('N', $codepoint), 'UTF-8', 'UTF-32BE');
219    }
220
221}