Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
80.00% covered (warning)
80.00%
4 / 5
CRAP
98.44% covered (success)
98.44%
63 / 64
FontResolver
0.00% covered (danger)
0.00%
0 / 1
80.00% covered (warning)
80.00%
4 / 5
36
98.44% covered (success)
98.44%
63 / 64
 setDirectories
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 getDirectories
100.00% covered (success)
100.00%
1 / 1
9
100.00% covered (success)
100.00%
14 / 14
 resolve
0.00% covered (danger)
0.00%
0 / 1
17
96.30% covered (success)
96.30%
26 / 27
 getIndex
100.00% covered (success)
100.00%
1 / 1
8
100.00% covered (success)
100.00%
20 / 20
 normalize
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
<?php
declare(strict_types=1);
namespace PhpOffice\WMF\Renderer;
use Exception;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
/**
 * Finds the TrueType/OpenType font file matching a logical font
 */
class FontResolver
{
    /**
     * Font families used when a font is not available
     */
    protected const FONT_FAMILIES = [
        'sans' => ['arial', 'liberationsans', 'arimo', 'helvetica', 'dejavusans', 'freesans', 'notosans', 'verdana'],
        'serif' => ['timesnewroman', 'liberationserif', 'tinos', 'times', 'dejavuserif', 'freeserif', 'notoserif'],
        'mono' => ['couriernew', 'liberationmono', 'cousine', 'courier', 'dejavusansmono', 'freemono', 'notosansmono'],
    ];
    /**
     * Families of common fonts
     */
    protected const FONT_ALIASES = [
        'arial' => 'sans',
        'arialnarrow' => 'sans',
        'calibri' => 'sans',
        'helvetica' => 'sans',
        'microsoftsansserif' => 'sans',
        'mssansserif' => 'sans',
        'segoeui' => 'sans',
        'tahoma' => 'sans',
        'verdana' => 'sans',
        'bookantiqua' => 'serif',
        'cambria' => 'serif',
        'garamond' => 'serif',
        'georgia' => 'serif',
        'msserif' => 'serif',
        // The texts of the Symbol font are converted to Unicode (see Encoding::decodeSymbol())
        'symbol' => 'serif',
        'times' => 'serif',
        'timesnewroman' => 'serif',
        'consolas' => 'mono',
        'courier' => 'mono',
        'couriernew' => 'mono',
        'lucidaconsole' => 'mono',
    ];
    /**
     * Suffixes of font files for each style
     */
    protected const FONT_STYLES = [
        'regular' => ['', 'regular', 'r', 'book', 'roman', 'normal', 'medium'],
        'bold' => ['bold', 'bd', 'b'],
        'italic' => ['italic', 'i', 'oblique', 'it'],
        'bolditalic' => ['bolditalic', 'bi', 'z', 'boldoblique'],
    ];
    /**
     * Directories where fonts are searched (null for the system directories)
     *
     * @var array<string>|null
     */
    protected $directories;
    /**
     * Font files indexed by normalized name, for each set of directories
     *
     * @var array<string, array<string, string>>
     */
    protected static $indexes = [];
    /**
     * Defines the directories where TrueType/OpenType fonts are searched (recursively)
     *
     * By default, the system directories are used
     *
     * @param array<string> $directories
     */
    public function setDirectories(array $directories): self
    {
        $this->directories = array_values($directories);
        return $this;
    }
    /**
     * @return array<string>
     */
    public function getDirectories(): array
    {
        if ($this->directories !== null) {
            return $this->directories;
        }
        $home = getenv('HOME') ?: getenv('USERPROFILE');
        $directories = [
            // Windows
            (getenv('WINDIR') ?: 'C:\\Windows') . '\\Fonts',
            getenv('LOCALAPPDATA') ? getenv('LOCALAPPDATA') . '\\Microsoft\\Windows\\Fonts' : '',
            // MacOS
            '/Library/Fonts',
            '/System/Library/Fonts',
            $home ? $home . '/Library/Fonts' : '',
            // Linux
            '/usr/share/fonts',
            '/usr/local/share/fonts',
            $home ? $home . '/.fonts' : '',
            $home ? $home . '/.local/share/fonts' : '',
        ];
        return array_values(array_filter($directories, function (string $directory): bool {
            return $directory !== '' && @is_dir($directory);
        }));
    }
    /**
     * Returns the font file matching a logical font
     *
     * @param array<string, mixed> $font Logical font (keys `face`, `weight`, `italic` & `pitchAndFamily`)
     */
    public function resolve(array $font): ?string
    {
        $index = $this->getIndex();
        if (empty($index)) {
            return null;
        }
        $face = $this->normalize($font['face']);
        $family = self::FONT_ALIASES[$face] ?? null;
        if (!$family) {
            // FF_ROMAN, FF_MODERN or FIXED_PITCH
            $pitchAndFamily = $font['pitchAndFamily'];
            $family = 'sans';
            if (($pitchAndFamily & 0xF0) == 0x10) {
                $family = 'serif';
            } elseif (($pitchAndFamily & 0xF0) == 0x30 || ($pitchAndFamily & 0x03) == 1) {
                $family = 'mono';
            }
        }
        // The Symbol font itself is not used : its characters are not Unicode characters
        $names = array_unique(array_merge($face == 'symbol' ? [] : [$face], self::FONT_FAMILIES[$family]));
        $isBold = $font['weight'] >= 600;
        $styles = ['regular'];
        if ($isBold && $font['italic']) {
            $styles = ['bolditalic', 'bold', 'italic', 'regular'];
        } elseif ($isBold) {
            $styles = ['bold', 'regular'];
        } elseif ($font['italic']) {
            $styles = ['italic', 'regular'];
        }
        foreach ($styles as $style) {
            foreach ($names as $name) {
                foreach (self::FONT_STYLES[$style] as $suffix) {
                    if ($name !== '' && isset($index[$name . $suffix])) {
                        return $index[$name . $suffix];
                    }
                }
            }
        }
        return null;
    }
    /**
     * @return array<string, string>
     */
    protected function getIndex(): array
    {
        $directories = $this->getDirectories();
        $key = implode('|', $directories);
        if (isset(self::$indexes[$key])) {
            return self::$indexes[$key];
        }
        $index = [];
        foreach ($directories as $directory) {
            try {
                $iterator = new RecursiveIteratorIterator(
                    new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
                    RecursiveIteratorIterator::LEAVES_ONLY,
                    RecursiveIteratorIterator::CATCH_GET_CHILD
                );
                foreach ($iterator as $file) {
                    $extension = strtolower($file->getExtension());
                    if ($extension != 'ttf' && $extension != 'otf') {
                        continue;
                    }
                    $name = $this->normalize($file->getBasename('.' . $file->getExtension()));
                    if (!isset($index[$name])) {
                        $index[$name] = $file->getPathname();
                    }
                }
            } catch (Exception $e) {
                // Unreadable directory
            }
        }
        self::$indexes[$key] = $index;
        return $index;
    }
    protected function normalize(string $name): string
    {
        return (string) preg_replace('/[^a-z0-9]/', '', strtolower($name));
    }
}