Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.26% covered (warning)
74.26%
75 / 101
22.22% covered (danger)
22.22%
2 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
GsubParser
74.26% covered (warning)
74.26%
75 / 101
22.22% covered (danger)
22.22%
2 / 9
60.35
0.00% covered (danger)
0.00%
0 / 1
 parse
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 parseGsub
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 findLigatureFeatureIndices
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getLookupIndicesFromFeatures
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 parseLigatureSubstLookups
65.22% covered (warning)
65.22%
15 / 23
0.00% covered (danger)
0.00%
0 / 1
12.41
 parseLigatureSubst
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
7
 parseCoverage
38.89% covered (danger)
38.89%
7 / 18
0.00% covered (danger)
0.00%
0 / 1
14.22
 readUint16
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 readUint32
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\FontParser;
6
7/**
8 * Parses GSUB (Glyph Substitution) table for ligature features.
9 *
10 * Extracts ligature substitution rules from the "liga" (standard ligatures)
11 * and "clig" (contextual ligatures) features. Supports:
12 * - GSUB LigatureSubst (LookupType 4)
13 * - Extension lookups (LookupType 7)
14 *
15 * Returns a map of: firstGid => [[[componentGid2, ...], ligatureGid], ...]
16 * sorted by component count descending (longest match first).
17 */
18final class GsubParser
19{
20    private string $data;
21
22    /**
23     * @param string $fontBytes Raw font file bytes
24     * @param array<string, array{offset:int, length:int}> $tables Table directory
25     * @return array<int, list<array{components: int[], ligature: int}>> firstGid => ligature rules
26     */
27    public function parse(string $fontBytes, array $tables): array
28    {
29        if (!isset($tables['GSUB'])) {
30            return [];
31        }
32
33        $this->data = $fontBytes;
34        return $this->parseGsub($tables['GSUB']['offset']);
35    }
36
37    /**
38     * @return array<int, list<array{components: int[], ligature: int}>>
39     */
40    private function parseGsub(int $offset): array
41    {
42        $scriptListOffset = $offset + $this->readUint16($offset + 4);
43        $featureListOffset = $offset + $this->readUint16($offset + 6);
44        $lookupListOffset = $offset + $this->readUint16($offset + 8);
45
46        // Find "liga" and "clig" feature indices
47        $featureIndices = $this->findLigatureFeatureIndices($scriptListOffset, $featureListOffset);
48        if ($featureIndices === []) {
49            return [];
50        }
51
52        $lookupIndices = $this->getLookupIndicesFromFeatures($featureListOffset, $featureIndices);
53        if ($lookupIndices === []) {
54            return [];
55        }
56
57        return $this->parseLigatureSubstLookups($lookupListOffset, $lookupIndices);
58    }
59
60    /**
61     * Find feature indices for "liga" and "clig" features.
62     *
63     * @return int[]
64     */
65    private function findLigatureFeatureIndices(int $scriptListOffset, int $featureListOffset): array
66    {
67        $featureCount = $this->readUint16($featureListOffset);
68        $ligaTags = ['liga', 'clig'];
69
70        $indices = [];
71        for ($i = 0; $i < $featureCount; $i++) {
72            $recOffset = $featureListOffset + 2 + $i * 6;
73            $tag = substr($this->data, $recOffset, 4);
74            if (in_array($tag, $ligaTags, true)) {
75                $indices[] = $i;
76            }
77        }
78
79        return $indices;
80    }
81
82    /**
83     * @param int[] $featureIndices
84     * @return int[]
85     */
86    private function getLookupIndicesFromFeatures(int $featureListOffset, array $featureIndices): array
87    {
88        $lookupIndices = [];
89
90        foreach ($featureIndices as $fi) {
91            $recOffset = $featureListOffset + 2 + $fi * 6;
92            $featureTableOffset = $featureListOffset + $this->readUint16($recOffset + 4);
93
94            $lookupCount = $this->readUint16($featureTableOffset + 2);
95            for ($j = 0; $j < $lookupCount; $j++) {
96                $lookupIndices[] = $this->readUint16($featureTableOffset + 4 + $j * 2);
97            }
98        }
99
100        return array_unique($lookupIndices);
101    }
102
103    /**
104     * @param int[] $lookupIndices
105     * @return array<int, list<array{components: int[], ligature: int}>>
106     */
107    private function parseLigatureSubstLookups(int $lookupListOffset, array $lookupIndices): array
108    {
109        $result = [];
110        $lookupCount = $this->readUint16($lookupListOffset);
111
112        foreach ($lookupIndices as $li) {
113            if ($li >= $lookupCount) {
114                continue;
115            }
116
117            $lookupOffset = $lookupListOffset + $this->readUint16($lookupListOffset + 2 + $li * 2);
118            $lookupType = $this->readUint16($lookupOffset);
119            $subtableCount = $this->readUint16($lookupOffset + 4);
120
121            for ($s = 0; $s < $subtableCount; $s++) {
122                $subtableOffset = $lookupOffset + $this->readUint16($lookupOffset + 6 + $s * 2);
123
124                // Handle extension lookups (type 7)
125                if ($lookupType === 7) {
126                    $extFormat = $this->readUint16($subtableOffset);
127                    if ($extFormat === 1) {
128                        $extType = $this->readUint16($subtableOffset + 2);
129                        $extOffset = $this->readUint32($subtableOffset + 4);
130                        if ($extType === 4) {
131                            $this->parseLigatureSubst($subtableOffset + $extOffset, $result);
132                        }
133                    }
134                    continue;
135                }
136
137                // LigatureSubst (type 4)
138                if ($lookupType === 4) {
139                    $this->parseLigatureSubst($subtableOffset, $result);
140                }
141            }
142        }
143
144        // Sort each GID's ligatures by component count descending (longest match first)
145        foreach ($result as $gid => &$rules) {
146            usort($rules, fn($a, $b) => count($b['components']) <=> count($a['components']));
147        }
148
149        return $result;
150    }
151
152    /**
153     * Parse a LigatureSubst subtable (format 1).
154     *
155     * @param array<int, list<array{components: int[], ligature: int}>> &$result
156     */
157    private function parseLigatureSubst(int $offset, array &$result): void
158    {
159        $format = $this->readUint16($offset);
160        if ($format !== 1) {
161            return;
162        }
163
164        $coverageOffset = $offset + $this->readUint16($offset + 2);
165        $ligatureSetCount = $this->readUint16($offset + 4);
166        $coveredGlyphs = $this->parseCoverage($coverageOffset);
167
168        for ($i = 0; $i < $ligatureSetCount && $i < count($coveredGlyphs); $i++) {
169            $firstGid = $coveredGlyphs[$i];
170            $ligatureSetOffset = $offset + $this->readUint16($offset + 6 + $i * 2);
171            $ligatureCount = $this->readUint16($ligatureSetOffset);
172
173            for ($j = 0; $j < $ligatureCount; $j++) {
174                $ligatureTableOffset = $ligatureSetOffset + $this->readUint16($ligatureSetOffset + 2 + $j * 2);
175                $ligatureGlyph = $this->readUint16($ligatureTableOffset);
176                $componentCount = $this->readUint16($ligatureTableOffset + 2);
177
178                $components = [];
179                for ($k = 0; $k < $componentCount - 1; $k++) {
180                    $components[] = $this->readUint16($ligatureTableOffset + 4 + $k * 2);
181                }
182
183                if (!isset($result[$firstGid])) {
184                    $result[$firstGid] = [];
185                }
186                $result[$firstGid][] = [
187                    'components' => $components,
188                    'ligature' => $ligatureGlyph,
189                ];
190            }
191        }
192    }
193
194    /**
195     * @return int[] Covered glyph IDs
196     */
197    private function parseCoverage(int $offset): array
198    {
199        $format = $this->readUint16($offset);
200
201        if ($format === 1) {
202            $count = $this->readUint16($offset + 2);
203            $glyphs = [];
204            for ($i = 0; $i < $count; $i++) {
205                $glyphs[] = $this->readUint16($offset + 4 + $i * 2);
206            }
207            return $glyphs;
208        }
209
210        if ($format === 2) {
211            $rangeCount = $this->readUint16($offset + 2);
212            $glyphs = [];
213            for ($i = 0; $i < $rangeCount; $i++) {
214                $rangeBase = $offset + 4 + $i * 6;
215                $startGid = $this->readUint16($rangeBase);
216                $endGid = $this->readUint16($rangeBase + 2);
217                for ($g = $startGid; $g <= $endGid; $g++) {
218                    $glyphs[] = $g;
219                }
220            }
221            return $glyphs;
222        }
223
224        return [];
225    }
226
227    private function readUint16(int $offset): int
228    {
229        if ($offset + 1 >= strlen($this->data)) {
230            return 0;
231        }
232        return unpack('n', $this->data, $offset)[1];
233    }
234
235    private function readUint32(int $offset): int
236    {
237        if ($offset + 3 >= strlen($this->data)) {
238            return 0;
239        }
240        return unpack('N', $this->data, $offset)[1];
241    }
242}