Fonts: a new asset type, picked from the server or dropped in, worn by text objects (#74)
game-maker / suite (push) Successful in 9m10s
game-maker / suite (push) Successful in 9m10s
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.
The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
28c0789b19
commit
86b5363a01
35 files changed
+1451
-85
No files matched your search
+44
-2
@@ -17,10 +17,15 @@ declare(strict_types=1);
|
||||
* POST ?r=textures raw image bytes → {file,width,height,bytes}
|
||||
* DELETE ?r=textures/{file} → 204
|
||||
*
|
||||
* Stored images are served straight from `data/textures/` as static files; the API only
|
||||
* writes them.
|
||||
* GET ?r=fonts → [{file,bytes,updatedAt}]
|
||||
* POST ?r=fonts raw font bytes → {file,bytes}
|
||||
* DELETE ?r=fonts/{file} → 204
|
||||
*
|
||||
* Stored images and fonts are served straight from `data/textures/` and `data/fonts/` as
|
||||
* static files; the API only writes them.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/lib/FontStore.php';
|
||||
require __DIR__ . '/lib/Response.php';
|
||||
require __DIR__ . '/lib/Store.php';
|
||||
require __DIR__ . '/lib/TextureStore.php';
|
||||
@@ -30,6 +35,9 @@ const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
/** Upload ceiling for a single image. The editor downscales larger pictures before sending. */
|
||||
const MAX_TEXTURE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/** Upload ceiling for a single font. A font cannot be shrunk on the way, so this is a plain limit. */
|
||||
const MAX_FONT_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
@@ -126,6 +134,39 @@ function routeTextures(array $parts, string $method): never
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeFonts(array $parts, string $method): never
|
||||
{
|
||||
$store = new FontStore(dirname(__DIR__) . '/data/fonts');
|
||||
|
||||
if (count($parts) === 1) {
|
||||
if ($method === 'GET') {
|
||||
Response::json($store->list());
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
$bytes = Response::rawBody();
|
||||
if (strlen($bytes) > MAX_FONT_BYTES) {
|
||||
Response::error('Font must be at most ' . (MAX_FONT_BYTES / 1024 / 1024) . ' MB', 413);
|
||||
}
|
||||
try {
|
||||
Response::json($store->store($bytes), 201);
|
||||
} catch (InvalidFontException $e) {
|
||||
Response::error($e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ($method === 'DELETE') {
|
||||
if (!$store->delete($parts[1])) {
|
||||
Response::error('Font not found', 404);
|
||||
}
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
try {
|
||||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) {
|
||||
Response::error('Request body too large', 413);
|
||||
@@ -142,6 +183,7 @@ try {
|
||||
match ($parts[0]) {
|
||||
'projects' => routeProjects($parts, $method),
|
||||
'textures' => routeTextures($parts, $method),
|
||||
'fonts' => routeFonts($parts, $method),
|
||||
default => Response::error('Not found', 404),
|
||||
};
|
||||
} catch (Throwable $e) {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Content-addressed font store: `data/fonts/{sha256}.{ext}` – the image store's twin, for the
|
||||
* same reasons (see TextureStore): one file per typeface however often it is uploaded, a URL
|
||||
* that can be cached forever, and deleting a font asset stays undoable because nothing here
|
||||
* is removed when the last project stops referencing it.
|
||||
*
|
||||
* A font is stored byte for byte. There is no re-encoding step as there is for images, so what
|
||||
* the browser loads is exactly what was picked.
|
||||
*/
|
||||
final class FontStore
|
||||
{
|
||||
/**
|
||||
* Accepted formats, keyed by the four bytes every one of them starts with. Bitmap and
|
||||
* collection formats are absent: a `.ttc` holds several faces and `@font-face` cannot say
|
||||
* which, and SVG fonts can carry script.
|
||||
*/
|
||||
private const SIGNATURES = [
|
||||
"\x00\x01\x00\x00" => '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.
|
||||
*
|
||||
* @return array{file:string,bytes:int}
|
||||
* @throws InvalidFontException when the bytes are not a supported font
|
||||
*/
|
||||
public function store(string $bytes): 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);
|
||||
}
|
||||
|
||||
return ['file' => $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<array{file:string,bytes:int,updatedAt:string}>
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$items = [];
|
||||
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
|
||||
$file = basename($path);
|
||||
if (!self::isValidName($file)) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'file' => $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;
|
||||
return is_file($path) && unlink($path);
|
||||
}
|
||||
|
||||
/** 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
|
||||
{
|
||||
}
|
||||
Reference in new issue
Block a user