feat: FontSignature (Magic Bytes) und FontNameRules (Namensregeln eigener Schriften)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
172a703903
commit
775d611cfa
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name rules of an uploaded font (ruling W9), stricter than FontsUtility::font_add(): Divi silently strips every
|
||||||
|
* character but A–Z, a–z, 0–9, whitespace, _ and -; the plugin refuses them and names what Divi would store.
|
||||||
|
* Divi compares names with Google's and uploaded fonts case-sensitively and skips the web-safe ones; the plugin
|
||||||
|
* refuses any of them whatever the case, so a font never shadows another one in Divi's font lists. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class FontNameRules
|
||||||
|
{
|
||||||
|
/** = FontsUtility::FONT_NAME_MAX_LENGTH in Divi 5.15.1 (FontInstaller::nameMaxLength() reads Divi's). */
|
||||||
|
public const MAX_LENGTH = 100;
|
||||||
|
|
||||||
|
private const ALLOWED = '/^[A-Za-z0-9 _-]+$/D';
|
||||||
|
|
||||||
|
/** What FontsUtility::font_add() removes from a name. */
|
||||||
|
private const DIVI_STRIPS = '/[^A-Za-z0-9\s\_-]/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $reserved Names of Google, web-safe and uploaded fonts (FontCatalog::reserved()).
|
||||||
|
* @return string|null null = valid, else the problem (German).
|
||||||
|
*/
|
||||||
|
public static function check(string $name, array $reserved, int $maxLength = self::MAX_LENGTH): ?string
|
||||||
|
{
|
||||||
|
if (trim($name) === '') {
|
||||||
|
return sprintf('Name fehlt (1–%d Zeichen)', $maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match(self::ALLOWED, $name) !== 1) {
|
||||||
|
$stored = trim((string) preg_replace(self::DIVI_STRIPS, '', $name));
|
||||||
|
|
||||||
|
return 'nur Buchstaben A–Z/a–z, Ziffern, Leerzeichen, _ und - erlaubt ('
|
||||||
|
. ($stored === '' ? 'Divi ließe nichts übrig)' : sprintf('Divi würde „%s“ speichern)', $stored));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name !== trim($name)) {
|
||||||
|
return 'ohne Leerzeichen am Anfang oder Ende';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($name) > $maxLength) {
|
||||||
|
return sprintf('höchstens %d Zeichen (%d angegeben)', $maxLength, strlen($name));
|
||||||
|
}
|
||||||
|
|
||||||
|
$taken = self::taken($name, $reserved);
|
||||||
|
|
||||||
|
return $taken === null ? null : sprintf(
|
||||||
|
'„%s“ gibt es schon als Google-, Web-Safe- oder hochgeladene Schrift (ohne Beachtung der '
|
||||||
|
. 'Groß-/Kleinschreibung)',
|
||||||
|
$taken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $reserved
|
||||||
|
* @return string|null The existing name $name collides with (case-insensitive), null when it is free.
|
||||||
|
*/
|
||||||
|
public static function taken(string $name, array $reserved): ?string
|
||||||
|
{
|
||||||
|
$lower = strtolower($name);
|
||||||
|
foreach ($reserved as $existing) {
|
||||||
|
if (strtolower($existing) === $lower) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The font format of a file by its magic bytes (ruling W9): wOF2 = woff2, wOFF = woff, 00 01 00 00 or "true" =
|
||||||
|
* ttf, OTTO = otf. Divi's own check (finfo) may be skipped for users with unfiltered_upload, so the font upload
|
||||||
|
* checks the content itself. Font collections (ttcf) and eot are no font here. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class FontSignature
|
||||||
|
{
|
||||||
|
/** Bytes detect() needs from the start of the file. */
|
||||||
|
public const HEADER_BYTES = 4;
|
||||||
|
|
||||||
|
private const MAGIC = [
|
||||||
|
'wOF2' => 'woff2',
|
||||||
|
'wOFF' => 'woff',
|
||||||
|
"\x00\x01\x00\x00" => 'ttf',
|
||||||
|
'true' => 'ttf',
|
||||||
|
'OTTO' => 'otf',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @return 'woff2'|'woff'|'ttf'|'otf'|null */
|
||||||
|
public static function detect(string $header): ?string
|
||||||
|
{
|
||||||
|
return self::MAGIC[substr($header, 0, self::HEADER_BYTES)] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\FontNameRules;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class FontNameRulesTest extends TestCase
|
||||||
|
{
|
||||||
|
private const RESERVED = ['Open Sans', 'Roboto', 'Arial', 'Times New Roman', 'Hausschrift'];
|
||||||
|
|
||||||
|
public function testLettersDigitsSpacesUnderscoresAndHyphensAreAllowed(): void
|
||||||
|
{
|
||||||
|
self::assertNull(FontNameRules::check('Inter Display_2-Bold', self::RESERVED));
|
||||||
|
self::assertNull(FontNameRules::check('X', self::RESERVED));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOtherCharactersAreRefusedWithTheNameDiviWouldStore(): void
|
||||||
|
{
|
||||||
|
$problem = (string) FontNameRules::check('Grotesk (Pro)!', self::RESERVED);
|
||||||
|
self::assertStringContainsString('nur Buchstaben', $problem);
|
||||||
|
self::assertStringContainsString('„Grotesk Pro“', $problem);
|
||||||
|
|
||||||
|
self::assertStringContainsString('„Mller“', (string) FontNameRules::check('Müller', self::RESERVED));
|
||||||
|
self::assertStringContainsString('nur Buchstaben', (string) FontNameRules::check('a.b', self::RESERVED));
|
||||||
|
self::assertStringContainsString('nur Buchstaben', (string) FontNameRules::check("Tab\tName", self::RESERVED));
|
||||||
|
self::assertStringContainsString('nichts übrig', (string) FontNameRules::check('«»', self::RESERVED));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyAndPaddedNamesAreRefused(): void
|
||||||
|
{
|
||||||
|
self::assertStringContainsString('1–100 Zeichen', (string) FontNameRules::check('', self::RESERVED));
|
||||||
|
self::assertStringContainsString('1–100 Zeichen', (string) FontNameRules::check(' ', self::RESERVED));
|
||||||
|
self::assertStringContainsString('Anfang oder Ende', (string) FontNameRules::check(' Inter', self::RESERVED));
|
||||||
|
self::assertStringContainsString('Anfang oder Ende', (string) FontNameRules::check('Inter ', self::RESERVED));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheLengthLimitIsOneHundredCharactersOrDivisBound(): void
|
||||||
|
{
|
||||||
|
self::assertNull(FontNameRules::check(str_repeat('a', 100), self::RESERVED));
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'höchstens 100 Zeichen (101 angegeben)',
|
||||||
|
(string) FontNameRules::check(str_repeat('a', 101), self::RESERVED)
|
||||||
|
);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'höchstens 50 Zeichen',
|
||||||
|
(string) FontNameRules::check(str_repeat('a', 51), self::RESERVED, 50)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNamesOfGoogleWebSafeAndUploadedFontsAreTakenWhateverTheCase(): void
|
||||||
|
{
|
||||||
|
self::assertSame('Open Sans', FontNameRules::taken('open sans', self::RESERVED));
|
||||||
|
self::assertSame('Arial', FontNameRules::taken('ARIAL', self::RESERVED), 'web-safe fonts too (Divi skips them)');
|
||||||
|
self::assertSame('Hausschrift', FontNameRules::taken('Hausschrift', self::RESERVED));
|
||||||
|
self::assertNull(FontNameRules::taken('Open Sans Pro', self::RESERVED));
|
||||||
|
self::assertNull(FontNameRules::taken('Roboto', []));
|
||||||
|
|
||||||
|
$problem = (string) FontNameRules::check('times new roman', self::RESERVED);
|
||||||
|
self::assertStringContainsString('„Times New Roman“ gibt es schon', $problem);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\FontSignature;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class FontSignatureTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testTheFourFormatsAreRecognizedByTheirFirstFourBytes(): void
|
||||||
|
{
|
||||||
|
self::assertSame('woff2', FontSignature::detect("wOF2\x00\x01\x00\x00\x00\x00\x12\x34"));
|
||||||
|
self::assertSame('woff', FontSignature::detect("wOFF\x00\x01\x00\x00"));
|
||||||
|
self::assertSame('ttf', FontSignature::detect("\x00\x01\x00\x00\x00\x0c\x00\x80"));
|
||||||
|
self::assertSame('ttf', FontSignature::detect('true' . "\x00\x0c"), 'Apple TrueType');
|
||||||
|
self::assertSame('otf', FontSignature::detect("OTTO\x00\x0a\x00\x80"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEverythingElseIsNoFont(): void
|
||||||
|
{
|
||||||
|
self::assertNull(FontSignature::detect(''));
|
||||||
|
self::assertNull(FontSignature::detect('wOF'), 'too short');
|
||||||
|
self::assertNull(FontSignature::detect('wof2'), 'case matters');
|
||||||
|
self::assertNull(FontSignature::detect('ttcf'), 'font collections are not accepted');
|
||||||
|
self::assertNull(FontSignature::detect("\x89PNG\r\n\x1a\n"));
|
||||||
|
self::assertNull(FontSignature::detect('<?php evil();'));
|
||||||
|
self::assertNull(FontSignature::detect("\x00wOF2"), 'the magic must start at byte 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user