Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.75% covered (success)
92.75%
128 / 138
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Shaper
92.75% covered (success)
92.75%
128 / 138
60.00% covered (warning)
60.00%
3 / 5
59.28
0.00% covered (danger)
0.00%
0 / 1
 shapeRun
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
13
 lookupGid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDefaultIgnorable
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
27
 applyLigaturesWithMap
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
11.02
 decodeUtf8
71.43% covered (warning)
71.43%
20 / 28
0.00% covered (danger)
0.00%
0 / 1
6.84
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Text;
6
7use 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 */
26final 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}