Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
74.26% |
75 / 101 |
|
22.22% |
2 / 9 |
CRAP | |
0.00% |
0 / 1 |
| GsubParser | |
74.26% |
75 / 101 |
|
22.22% |
2 / 9 |
60.35 | |
0.00% |
0 / 1 |
| parse | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| parseGsub | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
3.01 | |||
| findLigatureFeatureIndices | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| getLookupIndicesFromFeatures | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| parseLigatureSubstLookups | |
65.22% |
15 / 23 |
|
0.00% |
0 / 1 |
12.41 | |||
| parseLigatureSubst | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
7 | |||
| parseCoverage | |
38.89% |
7 / 18 |
|
0.00% |
0 / 1 |
14.22 | |||
| readUint16 | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| readUint32 | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace 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 | */ |
| 18 | final 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 | } |