Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.15% covered (success)
98.15%
53 / 54
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
FontResolver
98.15% covered (success)
98.15%
53 / 54
85.71% covered (warning)
85.71%
6 / 7
40
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
 selectVariant
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
21
 pickGeneric
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
8
 isBoldWeight
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 isItalicStyle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 ensureRegistered
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\SvgToPdf\Text;
6
7use Phpdftk\Pdf\Core\Font\StandardFont;
8use Phpdftk\Pdf\Core\Font\Type1Font;
9use Phpdftk\Pdf\Writer\Font;
10use Phpdftk\Pdf\Writer\Page;
11use Phpdftk\Pdf\Writer\PdfWriter;
12
13/**
14 * Map CSS Fonts 4 `font-family` / `font-weight` / `font-style` triples
15 * onto one of the 14 standard PDF fonts (Helvetica, Times, Courier,
16 * Symbol, ZapfDingbats). The resolver registers each picked
17 * `StandardFont` lazily on first use, caches the resulting writer
18 * `Font` handle, and reuses it for every following text element on the
19 * same page.
20 *
21 * Scope at 3P:
22 *
23 *  - Family lookup is keyword-only (CSS Fonts 4 §3.2 generic families
24 *    plus a handful of synonyms). `font-family: "Open Sans", "Helvetica
25 *    Neue", sans-serif` resolves to Helvetica because the first
26 *    keyword the resolver recognises wins.
27 *  - Weight ≥ 600 promotes the variant to bold. Numeric weights are
28 *    treated like the keyword equivalents (`bold`, `bolder`, …).
29 *  - `font-style: italic | oblique` picks the oblique / italic
30 *    variant; `font-style: normal` (or absent) stays upright.
31 *  - Symbol and ZapfDingbats are intentionally out of scope — the
32 *    resolver never picks them because mapping `font-family` to those
33 *    isn't standardised and SVG content rarely targets them.
34 *
35 * Deferred (documented in plan + README):
36 *
37 *  - `@font-face` / embedded TrueType + OpenType fonts (would need
38 *    the renderer adapter to plumb fonts into the resolver).
39 *  - OpenType shaping via `phpdftk/text`.
40 */
41final class FontResolver
42{
43    /** @var array<string, Font> */
44    private array $cache = [];
45
46    public function __construct(
47        private readonly PdfWriter $writer,
48        private readonly Page $page,
49    ) {}
50
51    /**
52     * @param list<string> $families  Ordered list from `font-family`.
53     */
54    public function resolve(array $families, ?string $weight, ?string $style): Font
55    {
56        $variant = $this->selectVariant($families, $weight, $style);
57        return $this->ensureRegistered($variant);
58    }
59
60    /**
61     * @param list<string> $families
62     */
63    private function selectVariant(array $families, ?string $weight, ?string $style): StandardFont
64    {
65        $generic = $this->pickGeneric($families);
66        $bold = $weight !== null && self::isBoldWeight($weight);
67        $italic = $style !== null && self::isItalicStyle($style);
68
69        return match ($generic) {
70            'serif' => match (true) {
71                $bold && $italic => StandardFont::TimesBoldItalic,
72                $bold => StandardFont::TimesBold,
73                $italic => StandardFont::TimesItalic,
74                default => StandardFont::TimesRoman,
75            },
76            'monospace' => match (true) {
77                $bold && $italic => StandardFont::CourierBoldOblique,
78                $bold => StandardFont::CourierBold,
79                $italic => StandardFont::CourierOblique,
80                default => StandardFont::Courier,
81            },
82            default => match (true) {
83                $bold && $italic => StandardFont::HelveticaBoldOblique,
84                $bold => StandardFont::HelveticaBold,
85                $italic => StandardFont::HelveticaOblique,
86                default => StandardFont::Helvetica,
87            },
88        };
89    }
90
91    /**
92     * Walk the `font-family` list left-to-right, returning the first
93     * generic family it recognises. Falls back to `sans-serif` so any
94     * unknown stack ends up on Helvetica.
95     *
96     * @param list<string> $families
97     * @return 'serif'|'sans-serif'|'monospace'
98     */
99    private function pickGeneric(array $families): string
100    {
101        foreach ($families as $family) {
102            $key = strtolower(trim($family));
103            $match = match (true) {
104                $key === 'serif',
105                str_contains($key, 'times'),
106                str_contains($key, 'georgia'),
107                str_contains($key, 'cambria'),
108                str_contains($key, 'serif') && !str_contains($key, 'sans') => 'serif',
109                $key === 'monospace',
110                str_contains($key, 'courier'),
111                str_contains($key, 'mono'),
112                str_contains($key, 'consolas'),
113                str_contains($key, 'menlo') => 'monospace',
114                $key === 'sans-serif',
115                str_contains($key, 'helvetica'),
116                str_contains($key, 'arial'),
117                str_contains($key, 'verdana'),
118                str_contains($key, 'sans') => 'sans-serif',
119                default => null,
120            };
121            if ($match !== null) {
122                return $match;
123            }
124        }
125        return 'sans-serif';
126    }
127
128    private static function isBoldWeight(string $weight): bool
129    {
130        $value = strtolower(trim($weight));
131        if (is_numeric($value)) {
132            return (float) $value >= 600.0;
133        }
134        return match ($value) {
135            'bold', 'bolder' => true,
136            default => false,
137        };
138    }
139
140    private static function isItalicStyle(string $style): bool
141    {
142        return match (strtolower(trim($style))) {
143            'italic', 'oblique' => true,
144            default => false,
145        };
146    }
147
148    private function ensureRegistered(StandardFont $variant): Font
149    {
150        $key = $variant->value;
151        if (isset($this->cache[$key])) {
152            return $this->cache[$key];
153        }
154        $font = $this->writer->addFont(new Type1Font($variant), $this->page);
155        return $this->cache[$key] = $font;
156    }
157}