'ttf', 'true' => 'ttf', 'OTTO' => 'otf', 'wOFF' => 'woff', 'wOF2' => 'woff2', ]; public function __construct(private readonly string $dir) { if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { throw new RuntimeException("Cannot create font directory {$this->dir}"); } } /** A stored file name, as it appears in a project document. */ public static function isValidName(string $name): bool { return (bool) preg_match('/^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/', $name); } /** * Stores the raw bytes of a typeface and returns its record. * * The format is read from the file's own signature, never from anything the client says. * `finfo` is not consulted the way it is for images: it reports fonts inconsistently across * libmagic versions (`font/sfnt`, `application/vnd.ms-opentype`, plain `application/octet-stream`), * so a second opinion from it would reject valid files rather than catch bad ones. * * `$originalName` is kept beside the file as a caption (see OriginalName): the hash says * nothing, and the name inside the font is deliberately never read (see `gm-font-` families). * * @return array{file:string,name:string,bytes:int} * @throws InvalidFontException when the bytes are not a supported font */ public function store(string $bytes, string $originalName = ''): array { $extension = self::SIGNATURES[substr($bytes, 0, 4)] ?? ''; if ($extension === '') { throw new InvalidFontException('Not a font file – use TTF, OTF, WOFF or WOFF2'); } // The shortest thing that can still be a font: a table directory and one entry. if (strlen($bytes) < 28) { throw new InvalidFontException('Font file is truncated'); } $file = hash('sha256', $bytes) . '.' . $extension; $path = $this->dir . '/' . $file; if (!is_file($path)) { $this->writeFile($path, $bytes); } OriginalName::write($this->dir, $file, $originalName); return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), 'bytes' => strlen($bytes)]; } /** * Every stored file, newest first – what the font picker offers. Unlike an image there is * nothing to read out of the header here: the picker shows each face by rendering with it. * * @return list */ public function list(): array { $items = []; foreach (glob($this->dir . '/*.*') ?: [] as $path) { $file = basename($path); // Sidecars share the directory and are skipped: `{hash}.ttf.json` is not a valid name. if (!self::isValidName($file)) { continue; } $items[] = [ 'file' => $file, 'name' => OriginalName::read($this->dir, $file), 'bytes' => (int) filesize($path), 'updatedAt' => gmdate('c', (int) filemtime($path)), ]; } usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); return $items; } public function delete(string $file): bool { if (!self::isValidName($file)) { return false; } $path = $this->dir . '/' . $file; if (!is_file($path) || !unlink($path)) { return false; } OriginalName::delete($this->dir, $file); return true; } /** Temp file plus rename, so a reader never sees a half-written font (see TextureStore). */ private function writeFile(string $path, string $bytes): void { $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) { @unlink($tmp); throw new RuntimeException('Cannot write font file'); } @chmod($path, 0664); } } /** Thrown by FontStore::store when the uploaded bytes are not a usable font. */ final class InvalidFontException extends RuntimeException { }