Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.82% covered (warning)
68.82%
64 / 93
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
FontResolver
68.82% covered (warning)
68.82%
64 / 93
44.44% covered (danger)
44.44%
4 / 9
125.80
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
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 resolveMatch
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 pickFace
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
6.17
 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\OpenTypeData;
13
14/**
15 * Resolves a cascaded `font-family` (+ optional `font-weight` / `font-style`)
16 * to a concrete `OpenTypeData` 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, OpenTypeData> $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 ?OpenTypeData $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    ): ?OpenTypeData {
55        $match = $this->resolveMatch($fontFamily, $weight, $style);
56        return $match?->face->data ?? $this->defaultFont;
57    }
58
59    /**
60     * Like {@see resolve()} but returns the matched {@see FontMatch}
61     * carrying the chosen face *and* whether it actually satisfies the
62     * requested weight/style. Layout uses this so the painter can suppress
63     * synthetic fake-bold / fake-italic when a real face matched.
64     */
65    public function resolveMatch(
66        ?Value $fontFamily,
67        int $weight = 400,
68        string $style = 'normal',
69    ): ?FontMatch {
70        if ($fontFamily === null) {
71            return null;
72        }
73        $lcStyle = strtolower($style);
74        foreach ($this->iterateFamilies($fontFamily) as $name) {
75            $key = strtolower($name);
76            if (isset($this->faceMap[$key]) && $this->faceMap[$key] !== []) {
77                $best = $this->pickFace($this->faceMap[$key], $weight, $lcStyle);
78                return new FontMatch(
79                    face: $best,
80                    matchesWeight: $this->weightSatisfies($best->weight, $weight),
81                    matchesStyle: $this->styleSatisfies($best->style, $lcStyle),
82                );
83            }
84            if (isset($this->fontMap[$key])) {
85                // Single-face fallback — treat as 400-normal.
86                $synthetic = new FontFace($this->fontMap[$key], 400, 'normal');
87                return new FontMatch(
88                    face: $synthetic,
89                    matchesWeight: $this->weightSatisfies(400, $weight),
90                    matchesStyle: $this->styleSatisfies('normal', $lcStyle),
91                );
92            }
93        }
94        return null;
95    }
96
97    /**
98     * CSS Fonts 4 §6 font-matching over the in-family face list. Picks
99     * first by style preference (an exact-style match always beats a
100     * style-mismatched alternative), then within the same-style bucket
101     * picks the closest weight using the spec's directional tie-break
102     * algorithm.
103     *
104     * @param list<FontFace> $faces
105     */
106    private function pickFace(array $faces, int $weight, string $style): FontFace
107    {
108        // Style buckets: prefer exact match. For 'italic' request, fall
109        // back order is italic > oblique > normal; for 'oblique', oblique
110        // > italic > normal; for 'normal', normal > oblique > italic.
111        $preference = match ($style) {
112            'italic' => ['italic', 'oblique', 'normal'],
113            'oblique' => ['oblique', 'italic', 'normal'],
114            default => ['normal', 'oblique', 'italic'],
115        };
116        foreach ($preference as $candidateStyle) {
117            $bucket = array_values(array_filter(
118                $faces,
119                static fn(FontFace $f): bool => $f->style === $candidateStyle,
120            ));
121            if ($bucket !== []) {
122                return $this->pickWeight($bucket, $weight);
123            }
124        }
125        // Defensive: faces is non-empty (checked by caller) but no style
126        // bucket matched (impossible since FontFace::style is normalised
127        // to one of three values). Return the first.
128        return $faces[0];
129    }
130
131    /**
132     * Per CSS Fonts 4 §6.4: weight selection inside a style bucket. The
133     * directional rule (treat 400-500 differently from <400 and >500)
134     * captures the practical "if you want normal-ish, prefer 500 over
135     * 600" behaviour browsers ship.
136     *
137     * @param list<FontFace> $faces
138     */
139    private function pickWeight(array $faces, int $weight): FontFace
140    {
141        // Group by weight; the spec picks closest, with directional
142        // tie-break: if 400 <= weight <= 500, scan 400..500 first; below
143        // 400, scan downward then upward; above 500, scan upward then
144        // downward.
145        usort($faces, static fn(FontFace $a, FontFace $b): int => $a->weight <=> $b->weight);
146        $exact = null;
147        foreach ($faces as $f) {
148            if ($f->weight === $weight) {
149                return $f;
150            }
151        }
152        if ($weight >= 400 && $weight <= 500) {
153            // Look in [weight..500] then [<weight] then [>500].
154            foreach ($faces as $f) {
155                if ($f->weight > $weight && $f->weight <= 500) {
156                    return $f;
157                }
158            }
159            for ($i = count($faces) - 1; $i >= 0; $i--) {
160                if ($faces[$i]->weight < $weight) {
161                    return $faces[$i];
162                }
163            }
164            // Else: only weights >500 remain — return the lightest.
165            foreach ($faces as $f) {
166                if ($f->weight > 500) {
167                    return $f;
168                }
169            }
170        } elseif ($weight < 400) {
171            // Scan downward (closer to 0) first.
172            for ($i = count($faces) - 1; $i >= 0; $i--) {
173                if ($faces[$i]->weight < $weight) {
174                    return $faces[$i];
175                }
176            }
177            foreach ($faces as $f) {
178                if ($f->weight > $weight) {
179                    return $f;
180                }
181            }
182        } else {
183            // weight > 500: scan upward first.
184            foreach ($faces as $f) {
185                if ($f->weight > $weight) {
186                    return $f;
187                }
188            }
189            for ($i = count($faces) - 1; $i >= 0; $i--) {
190                if ($faces[$i]->weight < $weight) {
191                    return $faces[$i];
192                }
193            }
194        }
195        // Single-element bucket: just return it.
196        return $faces[0];
197    }
198
199    /**
200     * A face's weight "satisfies" the request when the face is at least
201     * as heavy as the requested 600+ (bold-ish) cutoff, or the face is at
202     * most 500 (normal-ish) when the request is normal-ish. Matches the
203     * coarse-grained "do we still need fake-bold?" decision the painter
204     * cares about, not the fine-grained weight-difference signal.
205     */
206    private function weightSatisfies(int $faceWeight, int $requestedWeight): bool
207    {
208        $faceIsBold = $faceWeight >= 600;
209        $requestIsBold = $requestedWeight >= 600;
210        return $faceIsBold === $requestIsBold;
211    }
212
213    private function styleSatisfies(string $faceStyle, string $requestedStyle): bool
214    {
215        if ($requestedStyle === 'normal') {
216            return $faceStyle === 'normal';
217        }
218        // italic or oblique request — either italic or oblique face
219        // satisfies (browsers treat them interchangeably for fallback).
220        return in_array($faceStyle, ['italic', 'oblique'], true);
221    }
222
223    /**
224     * Yields each family name in the comma-separated `font-family` list,
225     * unquoted and trimmed. Generic keywords (`serif` / `sans-serif` /
226     * `monospace` / `cursive` / `fantasy` / `system-ui`) come through
227     * verbatim so callers can register fonts under those names.
228     *
229     * @return iterable<string>
230     */
231    private function iterateFamilies(Value $value): iterable
232    {
233        if ($value instanceof ValueList) {
234            foreach ($value->values as $item) {
235                $name = $this->familyToString($item);
236                if ($name !== '') {
237                    yield $name;
238                }
239            }
240            return;
241        }
242        $name = $this->familyToString($value);
243        if ($name !== '') {
244            yield $name;
245        }
246    }
247
248    private function familyToString(Value $value): string
249    {
250        if ($value instanceof StringValue) {
251            return $value->value;
252        }
253        if ($value instanceof Keyword) {
254            return $value->name;
255        }
256        if ($value instanceof ValueList) {
257            $parts = [];
258            foreach ($value->values as $v) {
259                $piece = $this->familyToString($v);
260                if ($piece !== '') {
261                    $parts[] = $piece;
262                }
263            }
264            return implode(' ', $parts);
265        }
266        if ($value instanceof CssFunction) {
267            return '';
268        }
269        return '';
270    }
271}