Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
92.75% |
128 / 138 |
|
60.00% |
3 / 5 |
CRAP | |
0.00% |
0 / 1 |
| Shaper | |
92.75% |
128 / 138 |
|
60.00% |
3 / 5 |
59.28 | |
0.00% |
0 / 1 |
| shapeRun | |
100.00% |
57 / 57 |
|
100.00% |
1 / 1 |
13 | |||
| lookupGid | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isDefaultIgnorable | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
27 | |||
| applyLigaturesWithMap | |
94.44% |
34 / 36 |
|
0.00% |
0 / 1 |
11.02 | |||
| decodeUtf8 | |
71.43% |
20 / 28 |
|
0.00% |
0 / 1 |
6.84 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Text; |
| 6 | |
| 7 | use Phpdftk\FontParser\FontFaceData; |
| 8 | |
| 9 | /** |
| 10 | * OpenType text shaper. |
| 11 | * |
| 12 | * Phase-1 implementation: cmap-based codepoint → glyph mapping, GSUB |
| 13 | * ligature substitution via the font-parser's `TextShaper::applyLigatures`, |
| 14 | * and GPOS kerning via the font's `kernPairs` table (legacy kern; modern |
| 15 | * `GPOS` lookups will land in Phase 2 alongside Arabic/Indic shaping). |
| 16 | * |
| 17 | * For glyphs whose codepoint isn't in the font's `fullUnicodeToGid` map, |
| 18 | * the shaper emits glyph 0 (`.notdef`). Higher-level paragraph shaping — |
| 19 | * which would attempt font fallback before falling back to `.notdef` — |
| 20 | * lives at the layout layer once font stacks are wired in. |
| 21 | * |
| 22 | * Advances are reported in PDF user-space units (1pt = 1/72in), already |
| 23 | * scaled by `fontSizePt / unitsPerEm`. Layout consumers do not need to |
| 24 | * know about font design units. |
| 25 | */ |
| 26 | final class Shaper |
| 27 | { |
| 28 | public function shapeRun(string $text, ShapingContext $context): ShapedRun |
| 29 | { |
| 30 | $font = $context->font; |
| 31 | if ($text === '') { |
| 32 | return new ShapedRun( |
| 33 | $font, |
| 34 | $context->fontSizePt, |
| 35 | $context->direction, |
| 36 | [], |
| 37 | 0.0, |
| 38 | ); |
| 39 | } |
| 40 | |
| 41 | $codepoints = self::decodeUtf8($text); |
| 42 | // Unicode Default_Ignorable_Code_Point — zero-width formatting / |
| 43 | // control characters (ZWSP, ZWNJ/ZWJ, bidi controls, word joiner, |
| 44 | // BOM, variation selectors, …) render as no visible glyph and add |
| 45 | // no advance (so they also get no letter-spacing). Drop them before |
| 46 | // GID lookup so they don't fall through to a `.notdef` box. |
| 47 | $codepoints = array_values(array_filter( |
| 48 | $codepoints, |
| 49 | static fn(array $cp): bool => !self::isDefaultIgnorable($cp['codepoint']), |
| 50 | )); |
| 51 | if ($codepoints === []) { |
| 52 | return new ShapedRun($font, $context->fontSizePt, $context->direction, [], 0.0); |
| 53 | } |
| 54 | $gids = []; |
| 55 | foreach ($codepoints as $cp) { |
| 56 | $gids[] = self::lookupGid($cp['codepoint'], $font); |
| 57 | } |
| 58 | |
| 59 | // Track which codepoint each output GID came from, so ligature |
| 60 | // substitution can preserve byte offsets for the consolidated glyph. |
| 61 | // `sourceMap` is per output-glyph index → [startCpIdx, endCpIdxExclusive]. |
| 62 | $sourceMap = []; |
| 63 | for ($i = 0; $i < count($gids); $i++) { |
| 64 | $sourceMap[$i] = [$i, $i + 1]; |
| 65 | } |
| 66 | |
| 67 | if (in_array('liga', $context->features, true) && $font->ligatures !== null) { |
| 68 | [$gids, $sourceMap] = self::applyLigaturesWithMap($gids, $sourceMap, $font->ligatures); |
| 69 | } |
| 70 | |
| 71 | $scale = $context->fontSizePt / ($font->unitsPerEm > 0 ? $font->unitsPerEm : 1000); |
| 72 | $applyKern = in_array('kern', $context->features, true) && $font->kernPairs !== null; |
| 73 | $kernPairs = $font->kernPairs ?? []; |
| 74 | |
| 75 | $glyphs = []; |
| 76 | $totalAdvance = 0.0; |
| 77 | $count = count($gids); |
| 78 | for ($i = 0; $i < $count; $i++) { |
| 79 | $gid = $gids[$i]; |
| 80 | $advanceUnits = $font->glyphWidths[$gid] ?? 0; |
| 81 | if ($applyKern && $i + 1 < $count) { |
| 82 | $next = $gids[$i + 1]; |
| 83 | $adjust = $kernPairs[$gid][$next] ?? 0; |
| 84 | $advanceUnits += $adjust; |
| 85 | } |
| 86 | $advanceX = $advanceUnits * $scale; |
| 87 | [$startIdx, $endIdx] = $sourceMap[$i]; |
| 88 | $startByte = $codepoints[$startIdx]['byteOffset']; |
| 89 | $endByte = $endIdx < count($codepoints) |
| 90 | ? $codepoints[$endIdx]['byteOffset'] |
| 91 | : strlen($text); |
| 92 | |
| 93 | $glyphs[] = new ShapedGlyph( |
| 94 | glyphId: $gid, |
| 95 | sourceOffset: $startByte, |
| 96 | sourceLength: $endByte - $startByte, |
| 97 | advanceX: $advanceX, |
| 98 | ); |
| 99 | $totalAdvance += $advanceX; |
| 100 | } |
| 101 | |
| 102 | return new ShapedRun( |
| 103 | $font, |
| 104 | $context->fontSizePt, |
| 105 | $context->direction, |
| 106 | $glyphs, |
| 107 | $totalAdvance, |
| 108 | ); |
| 109 | } |
| 110 | |
| 111 | private static function lookupGid(int $codepoint, FontFaceData $font): int |
| 112 | { |
| 113 | return $font->fullUnicodeToGid[$codepoint] ?? 0; |
| 114 | } |
| 115 | |
| 116 | /** |
| 117 | * Unicode `Default_Ignorable_Code_Point` (DerivedCoreProperties) — the |
| 118 | * formatting / control characters that must render as no visible glyph |
| 119 | * and zero advance. Excludes U+00AD SOFT HYPHEN (handled by the |
| 120 | * line-break / hyphenation logic, which needs to see it). |
| 121 | */ |
| 122 | private static function isDefaultIgnorable(int $cp): bool |
| 123 | { |
| 124 | return $cp === 0x034F // combining grapheme joiner |
| 125 | || $cp === 0x061C // arabic letter mark |
| 126 | || ($cp >= 0x115F && $cp <= 0x1160) // hangul choseong/jungseong fillers |
| 127 | || ($cp >= 0x17B4 && $cp <= 0x17B5) // khmer inherent vowels |
| 128 | || ($cp >= 0x180B && $cp <= 0x180F) // mongolian free variation selectors + vowel sep |
| 129 | || ($cp >= 0x200B && $cp <= 0x200F) // ZWSP, ZWNJ, ZWJ, LRM, RLM |
| 130 | || ($cp >= 0x202A && $cp <= 0x202E) // bidi embedding / override |
| 131 | || ($cp >= 0x2060 && $cp <= 0x206F) // word joiner, invisible ops, deprecated fmt |
| 132 | || $cp === 0x3164 // hangul filler |
| 133 | || ($cp >= 0xFE00 && $cp <= 0xFE0F) // variation selectors |
| 134 | || $cp === 0xFEFF // zero width no-break space (BOM) |
| 135 | || $cp === 0xFFA0 // halfwidth hangul filler |
| 136 | || ($cp >= 0xFFF0 && $cp <= 0xFFF8) // reserved |
| 137 | || ($cp >= 0x1BCA0 && $cp <= 0x1BCA3) // shorthand format controls |
| 138 | || ($cp >= 0x1D173 && $cp <= 0x1D17A) // musical symbols beam/slur controls |
| 139 | || ($cp >= 0xE0000 && $cp <= 0xE0FFF); // tags + variation selectors supplement |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Run the existing `FontTextShaper::applyLigatures` pass and keep a |
| 144 | * parallel sourceMap so each surviving glyph still points back to the |
| 145 | * byte range in the original input. |
| 146 | * |
| 147 | * @param list<int> $gids |
| 148 | * @param array<int, array{int, int}> $sourceMap |
| 149 | * @param array<int, list<array{components: int[], ligature: int}>> $ligatures |
| 150 | * @return array{list<int>, array<int, array{int, int}>} |
| 151 | */ |
| 152 | private static function applyLigaturesWithMap(array $gids, array $sourceMap, array $ligatures): array |
| 153 | { |
| 154 | if ($gids === [] || $ligatures === []) { |
| 155 | return [$gids, $sourceMap]; |
| 156 | } |
| 157 | $outGids = []; |
| 158 | $outMap = []; |
| 159 | $i = 0; |
| 160 | $len = count($gids); |
| 161 | while ($i < $len) { |
| 162 | $gid = $gids[$i]; |
| 163 | $matched = false; |
| 164 | if (isset($ligatures[$gid])) { |
| 165 | foreach ($ligatures[$gid] as $rule) { |
| 166 | $components = $rule['components']; |
| 167 | $compLen = count($components); |
| 168 | if ($i + $compLen >= $len) { |
| 169 | continue; |
| 170 | } |
| 171 | $allMatch = true; |
| 172 | for ($j = 0; $j < $compLen; $j++) { |
| 173 | if ($gids[$i + 1 + $j] !== $components[$j]) { |
| 174 | $allMatch = false; |
| 175 | break; |
| 176 | } |
| 177 | } |
| 178 | if ($allMatch) { |
| 179 | $outIdx = count($outGids); |
| 180 | $outGids[] = $rule['ligature']; |
| 181 | $outMap[$outIdx] = [ |
| 182 | $sourceMap[$i][0], |
| 183 | $sourceMap[$i + $compLen][1], |
| 184 | ]; |
| 185 | $i += 1 + $compLen; |
| 186 | $matched = true; |
| 187 | break; |
| 188 | } |
| 189 | } |
| 190 | } |
| 191 | if (!$matched) { |
| 192 | $outIdx = count($outGids); |
| 193 | $outGids[] = $gid; |
| 194 | $outMap[$outIdx] = $sourceMap[$i]; |
| 195 | $i++; |
| 196 | } |
| 197 | } |
| 198 | return [$outGids, $outMap]; |
| 199 | } |
| 200 | |
| 201 | /** |
| 202 | * @return list<array{codepoint: int, byteOffset: int}> |
| 203 | */ |
| 204 | private static function decodeUtf8(string $text): array |
| 205 | { |
| 206 | $out = []; |
| 207 | $bytes = strlen($text); |
| 208 | $i = 0; |
| 209 | while ($i < $bytes) { |
| 210 | $byte = ord($text[$i]); |
| 211 | if ($byte < 0x80) { |
| 212 | $out[] = ['codepoint' => $byte, 'byteOffset' => $i]; |
| 213 | $i++; |
| 214 | } elseif ($byte < 0xC0) { |
| 215 | $out[] = ['codepoint' => 0xFFFD, 'byteOffset' => $i]; |
| 216 | $i++; |
| 217 | } elseif ($byte < 0xE0) { |
| 218 | $cp = (($byte & 0x1F) << 6) | (ord($text[$i + 1] ?? "\x00") & 0x3F); |
| 219 | $out[] = ['codepoint' => $cp, 'byteOffset' => $i]; |
| 220 | $i += 2; |
| 221 | } elseif ($byte < 0xF0) { |
| 222 | $cp = (($byte & 0x0F) << 12) |
| 223 | | ((ord($text[$i + 1] ?? "\x00") & 0x3F) << 6) |
| 224 | | (ord($text[$i + 2] ?? "\x00") & 0x3F); |
| 225 | $out[] = ['codepoint' => $cp, 'byteOffset' => $i]; |
| 226 | $i += 3; |
| 227 | } else { |
| 228 | $cp = (($byte & 0x07) << 18) |
| 229 | | ((ord($text[$i + 1] ?? "\x00") & 0x3F) << 12) |
| 230 | | ((ord($text[$i + 2] ?? "\x00") & 0x3F) << 6) |
| 231 | | (ord($text[$i + 3] ?? "\x00") & 0x3F); |
| 232 | $out[] = ['codepoint' => $cp, 'byteOffset' => $i]; |
| 233 | $i += 4; |
| 234 | } |
| 235 | } |
| 236 | return $out; |
| 237 | } |
| 238 | } |