Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
73.33% |
77 / 105 |
|
50.00% |
5 / 10 |
CRAP | |
0.00% |
0 / 1 |
| FontResolver | |
73.33% |
77 / 105 |
|
50.00% |
5 / 10 |
109.30 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| resolve | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| resolveMatch | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
6 | |||
| pickFace | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
6.13 | |||
| filterByClosestStretch | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
4.01 | |||
| 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\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 | */ |
| 32 | final 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 | } |