Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
68.82% |
64 / 93 |
|
44.44% |
4 / 9 |
CRAP | |
0.00% |
0 / 1 |
| FontResolver | |
68.82% |
64 / 93 |
|
44.44% |
4 / 9 |
125.80 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| resolve | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| resolveMatch | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
6 | |||
| pickFace | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
6.17 | |||
| pickWeight | |
51.72% |
15 / 29 |
|
0.00% |
0 / 1 |
70.62 | |||
| weightSatisfies | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| styleSatisfies | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| iterateFamilies | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| familyToString | |
28.57% |
4 / 14 |
|
0.00% |
0 / 1 |
24.86 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\HtmlToPdf\Layout; |
| 6 | |
| 7 | use Phpdftk\Css\Value\CssFunction; |
| 8 | use Phpdftk\Css\Value\Keyword; |
| 9 | use Phpdftk\Css\Value\StringValue; |
| 10 | use Phpdftk\Css\Value\Value; |
| 11 | use Phpdftk\Css\Value\ValueList; |
| 12 | use 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 | */ |
| 32 | final 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 | } |