Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.33% covered (warning)
73.33%
77 / 105
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
FontResolver
73.33% covered (warning)
73.33%
77 / 105
50.00% covered (danger)
50.00%
5 / 10
109.30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolve
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 resolveMatch
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 pickFace
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 filterByClosestStretch
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 pickWeight
51.72% covered (warning)
51.72%
15 / 29
0.00% covered (danger)
0.00%
0 / 1
70.62
 weightSatisfies
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 styleSatisfies
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 iterateFamilies
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 familyToString
28.57% covered (danger)
28.57%
4 / 14
0.00% covered (danger)
0.00%
0 / 1
24.86
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\HtmlToPdf\Layout;
6
7use Phpdftk\Css\Value\CssFunction;
8use Phpdftk\Css\Value\Keyword;
9use Phpdftk\Css\Value\StringValue;
10use Phpdftk\Css\Value\Value;
11use Phpdftk\Css\Value\ValueList;
12use Phpdftk\FontParser\FontFaceData;
13
14/**
15 * Resolves a cascaded `font-family` (+ optional `font-weight` / `font-style`)
16 * to a concrete `FontFaceData` by walking the family list left-to-right and
17 * picking the closest matching face per CSS Fonts 4 §6 font-matching.
18 *
19 * Two layers:
20 *  - `$faceMap` — multi-face per family, used when callers want real
21 *    bold / italic alternates. Per CSS Fonts 4 §6.4 weight matching and
22 *    §6.3 style matching, the resolver picks the face whose weight is
23 *    nearest the requested value (with the spec's directional tie-break)
24 *    and whose style matches (italic > oblique > normal preference depends
25 *    on the requested style).
26 *  - `$fontMap` — single-face per family, used as a fallback when `faceMap`
27 *    has no entry. Treated as a 400-normal face.
28 *
29 * Both maps are keyed by lower-case family name; lookups are
30 * case-insensitive. Returns `defaultFont` when no family matches.
31 */
32final readonly class FontResolver
33{
34    /**
35     * @param array<string, FontFaceData> $fontMap legacy single-face map
36     * @param array<string, list<FontFace>> $faceMap weight/style-tagged faces
37     */
38    public function __construct(
39        private array $fontMap,
40        private ?FontFaceData $defaultFont,
41        private array $faceMap = [],
42    ) {}
43
44    /**
45     * Pick a font for the given cascaded `font-family`. When `$weight` /
46     * `$style` are supplied, the resolver prefers a real face from
47     * `$faceMap` matching them per CSS Fonts 4 §6; otherwise it falls back
48     * to the legacy single-face `$fontMap`, or to `$defaultFont`.
49     */
50    public function resolve(
51        ?Value $fontFamily,
52        int $weight = 400,
53        string $style = 'normal',
54        float $stretch = 100.0,
55    ): ?FontFaceData {
56        $match = $this->resolveMatch($fontFamily, $weight, $style, $stretch);
57        return $match?->face->data ?? $this->defaultFont;
58    }
59
60    /**
61     * Like {@see resolve()} but returns the matched {@see FontMatch}
62     * carrying the chosen face *and* whether it actually satisfies the
63     * requested weight/style. Layout uses this so the painter can suppress
64     * synthetic fake-bold / fake-italic when a real face matched.
65     */
66    public function resolveMatch(
67        ?Value $fontFamily,
68        int $weight = 400,
69        string $style = 'normal',
70        float $stretch = 100.0,
71    ): ?FontMatch {
72        if ($fontFamily === null) {
73            return null;
74        }
75        $lcStyle = strtolower($style);
76        foreach ($this->iterateFamilies($fontFamily) as $name) {
77            $key = strtolower($name);
78            if (isset($this->faceMap[$key]) && $this->faceMap[$key] !== []) {
79                $best = $this->pickFace($this->faceMap[$key], $weight, $lcStyle, $stretch);
80                return new FontMatch(
81                    face: $best,
82                    matchesWeight: $this->weightSatisfies($best->weight, $weight),
83                    matchesStyle: $this->styleSatisfies($best->style, $lcStyle),
84                );
85            }
86            if (isset($this->fontMap[$key])) {
87                // Single-face fallback — treat as 400-normal-100%.
88                $synthetic = new FontFace($this->fontMap[$key], 400, 'normal');
89                return new FontMatch(
90                    face: $synthetic,
91                    matchesWeight: $this->weightSatisfies(400, $weight),
92                    matchesStyle: $this->styleSatisfies('normal', $lcStyle),
93                );
94            }
95        }
96        return null;
97    }
98
99    /**
100     * CSS Fonts 4 §6 font-matching over the in-family face list. Picks
101     * first by style preference (an exact-style match always beats a
102     * style-mismatched alternative), then within the same-style bucket
103     * picks the closest weight using the spec's directional tie-break
104     * algorithm.
105     *
106     * @param list<FontFace> $faces
107     */
108    private function pickFace(array $faces, int $weight, string $style, float $stretch = 100.0): FontFace
109    {
110        // CSS Fonts 4 §6 — match order: stretch → style → weight.
111        // Bucket by closest stretch first; if multiple faces share
112        // the same minimum delta keep them all in the bucket.
113        $faces = $this->filterByClosestStretch($faces, $stretch);
114        // Style buckets: prefer exact match. For 'italic' request, fall
115        // back order is italic > oblique > normal; for 'oblique', oblique
116        // > italic > normal; for 'normal', normal > oblique > italic.
117        $preference = match ($style) {
118            'italic' => ['italic', 'oblique', 'normal'],
119            'oblique' => ['oblique', 'italic', 'normal'],
120            default => ['normal', 'oblique', 'italic'],
121        };
122        foreach ($preference as $candidateStyle) {
123            $bucket = array_values(array_filter(
124                $faces,
125                static fn(FontFace $f): bool => $f->style === $candidateStyle,
126            ));
127            if ($bucket !== []) {
128                return $this->pickWeight($bucket, $weight);
129            }
130        }
131        // Defensive: faces is non-empty (checked by caller) but no style
132        // bucket matched (impossible since FontFace::style is normalised
133        // to one of three values). Return the first.
134        return $faces[0];
135    }
136
137    /**
138     * CSS Fonts 4 §6.5 — pick the face(s) whose stretch is closest to
139     * the requested value. Ties (multiple faces equidistant) keep
140     * everything; the style/weight matching cascade narrows from
141     * there. Empty input returns empty.
142     *
143     * @param list<FontFace> $faces
144     * @return list<FontFace>
145     */
146    private function filterByClosestStretch(array $faces, float $target): array
147    {
148        if ($faces === []) {
149            return [];
150        }
151        $best = INF;
152        foreach ($faces as $f) {
153            $d = abs($f->stretch - $target);
154            if ($d < $best) {
155                $best = $d;
156            }
157        }
158        return array_values(array_filter(
159            $faces,
160            static fn(FontFace $f): bool => abs($f->stretch - $target) === $best,
161        ));
162    }
163
164    /**
165     * Per CSS Fonts 4 §6.4: weight selection inside a style bucket. The
166     * directional rule (treat 400-500 differently from <400 and >500)
167     * captures the practical "if you want normal-ish, prefer 500 over
168     * 600" behaviour browsers ship.
169     *
170     * @param list<FontFace> $faces
171     */
172    private function pickWeight(array $faces, int $weight): FontFace
173    {
174        // Group by weight; the spec picks closest, with directional
175        // tie-break: if 400 <= weight <= 500, scan 400..500 first; below
176        // 400, scan downward then upward; above 500, scan upward then
177        // downward.
178        usort($faces, static fn(FontFace $a, FontFace $b): int => $a->weight <=> $b->weight);
179        $exact = null;
180        foreach ($faces as $f) {
181            if ($f->weight === $weight) {
182                return $f;
183            }
184        }
185        if ($weight >= 400 && $weight <= 500) {
186            // Look in [weight..500] then [<weight] then [>500].
187            foreach ($faces as $f) {
188                if ($f->weight > $weight && $f->weight <= 500) {
189                    return $f;
190                }
191            }
192            for ($i = count($faces) - 1; $i >= 0; $i--) {
193                if ($faces[$i]->weight < $weight) {
194                    return $faces[$i];
195                }
196            }
197            // Else: only weights >500 remain — return the lightest.
198            foreach ($faces as $f) {
199                if ($f->weight > 500) {
200                    return $f;
201                }
202            }
203        } elseif ($weight < 400) {
204            // Scan downward (closer to 0) first.
205            for ($i = count($faces) - 1; $i >= 0; $i--) {
206                if ($faces[$i]->weight < $weight) {
207                    return $faces[$i];
208                }
209            }
210            foreach ($faces as $f) {
211                if ($f->weight > $weight) {
212                    return $f;
213                }
214            }
215        } else {
216            // weight > 500: scan upward first.
217            foreach ($faces as $f) {
218                if ($f->weight > $weight) {
219                    return $f;
220                }
221            }
222            for ($i = count($faces) - 1; $i >= 0; $i--) {
223                if ($faces[$i]->weight < $weight) {
224                    return $faces[$i];
225                }
226            }
227        }
228        // Single-element bucket: just return it.
229        return $faces[0];
230    }
231
232    /**
233     * A face's weight "satisfies" the request when the face is at least
234     * as heavy as the requested 600+ (bold-ish) cutoff, or the face is at
235     * most 500 (normal-ish) when the request is normal-ish. Matches the
236     * coarse-grained "do we still need fake-bold?" decision the painter
237     * cares about, not the fine-grained weight-difference signal.
238     */
239    private function weightSatisfies(int $faceWeight, int $requestedWeight): bool
240    {
241        $faceIsBold = $faceWeight >= 600;
242        $requestIsBold = $requestedWeight >= 600;
243        return $faceIsBold === $requestIsBold;
244    }
245
246    private function styleSatisfies(string $faceStyle, string $requestedStyle): bool
247    {
248        if ($requestedStyle === 'normal') {
249            return $faceStyle === 'normal';
250        }
251        // italic or oblique request — either italic or oblique face
252        // satisfies (browsers treat them interchangeably for fallback).
253        return in_array($faceStyle, ['italic', 'oblique'], true);
254    }
255
256    /**
257     * Yields each family name in the comma-separated `font-family` list,
258     * unquoted and trimmed. Generic keywords (`serif` / `sans-serif` /
259     * `monospace` / `cursive` / `fantasy` / `system-ui`) come through
260     * verbatim so callers can register fonts under those names.
261     *
262     * @return iterable<string>
263     */
264    private function iterateFamilies(Value $value): iterable
265    {
266        if ($value instanceof ValueList) {
267            foreach ($value->values as $item) {
268                $name = $this->familyToString($item);
269                if ($name !== '') {
270                    yield $name;
271                }
272            }
273            return;
274        }
275        $name = $this->familyToString($value);
276        if ($name !== '') {
277            yield $name;
278        }
279    }
280
281    private function familyToString(Value $value): string
282    {
283        if ($value instanceof StringValue) {
284            return $value->value;
285        }
286        if ($value instanceof Keyword) {
287            return $value->name;
288        }
289        if ($value instanceof ValueList) {
290            $parts = [];
291            foreach ($value->values as $v) {
292                $piece = $this->familyToString($v);
293                if ($piece !== '') {
294                    $parts[] = $piece;
295                }
296            }
297            return implode(' ', $parts);
298        }
299        if ($value instanceof CssFunction) {
300            return '';
301        }
302        return '';
303    }
304}