-
Notifications
You must be signed in to change notification settings - Fork 93
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat!: Implement a font registry for text rendering
Fixes #82, #168, #171. This patch removes the artificial `SVGFont` class as well as the `SVGText::setFont(SVGFont $font)` method. Instead, it is now possible to register TrueType (`.ttf`) font files by calling `SVG::addFont()` with a file path. A TrueType font file parser is implemented based on Microsoft's OpenType specification. This allows us to choose the best matching font file for a given text element automatically, just like a browser would, based on algorithms from CSS. Currently, TTF files are supported in the "Regular", "Bold", "Italic", and "Bold Italic" variants. The simple algorithm implemented here tries to match the font family exactly, if possible, falling back to any other font. Generic font families such as "serif" or "monospace" aren't supported yet and will trigger the fallback. Also, the relative weights "bolder" and "lighter" aren't computed and fall back to normal weight. BREAKING CHANGE: `SVGFont` class and some methods on `SVGText` removed.
- Loading branch information
Showing
15 changed files
with
467 additions
and
128 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
<?php | ||
|
||
namespace SVG\Fonts; | ||
|
||
/** | ||
* Abstract base class for font files. | ||
*/ | ||
abstract class FontFile { | ||
private $path; | ||
|
||
public function __construct(string $path) { | ||
$this->path = $path; | ||
} | ||
|
||
/** | ||
* @return string The path of the font file. | ||
*/ | ||
public function getPath(): string | ||
{ | ||
return $this->path; | ||
} | ||
|
||
public abstract function getFamily(): string; | ||
|
||
public abstract function getWeight(): float; | ||
|
||
public abstract function isItalic(): bool; | ||
|
||
public abstract function isMonospace(): bool; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
<?php | ||
|
||
namespace SVG\Fonts; | ||
|
||
class FontRegistry | ||
{ | ||
private $fontFiles = []; | ||
|
||
public function addFont(string $filePath) | ||
{ | ||
$ttfFile = TrueTypeFontFile::read($filePath); | ||
if ($ttfFile === null) { | ||
throw new \RuntimeException('Font file "' . $filePath . '" is not a valid TrueType font.'); | ||
} | ||
$this->fontFiles[] = $ttfFile; | ||
} | ||
|
||
public function findMatchingFont(?string $family, bool $italic, float $weight) | ||
{ | ||
if (empty($this->fontFiles)) { | ||
return null; | ||
} | ||
|
||
// TODO implement generic families ('serif', 'sans-serif', 'monospace', etc.) | ||
|
||
$anyFontFamily = true; | ||
foreach ($this->fontFiles as $font) { | ||
if ($family === $font->getFamily()) { | ||
$anyFontFamily = false; | ||
} | ||
} | ||
|
||
// Attempt to find an exact match. | ||
foreach ($this->fontFiles as $font) { | ||
if (($anyFontFamily || $font->getFamily() === $family) && $font->isItalic() === $italic && $font->getWeight() === $weight) { | ||
return $font; | ||
} | ||
} | ||
|
||
// Attempt to find a match with a different weight. | ||
foreach ($this->fontFiles as $font) { | ||
if (($anyFontFamily || $font->getFamily() === $family) && $font->isItalic() === $italic) { | ||
return $font; | ||
} | ||
} | ||
|
||
// Attempt to match just based on the font family. | ||
foreach ($this->fontFiles as $font) { | ||
if (($anyFontFamily || $font->getFamily() === $family)) { | ||
return $font; | ||
} | ||
} | ||
|
||
// Return any font at all, if possible. | ||
return $this->fontFiles[0]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,238 @@ | ||
<?php | ||
|
||
namespace SVG\Fonts; | ||
|
||
/** | ||
* A font file using the TTF format (TrueType). | ||
*/ | ||
class TrueTypeFontFile extends FontFile | ||
{ | ||
// https://learn.microsoft.com/en-us/typography/opentype/spec/otff#table-directory | ||
// OpenType fonts that contain TrueType outlines should use the value of 0x00010000 for the sfntVersion. | ||
// OpenType fonts containing CFF data (version 1 or 2) should use 0x4F54544F ('OTTO', when re-interpreted as a Tag) | ||
// for sfntVersion. | ||
private const SFNT_VERSION_TTF = 0x00010000; | ||
private const SFNT_VERSION_OTF = 0x4F54544F; | ||
|
||
private const TABLE_TAG_NAME = 'name'; | ||
private const NAME_ID_FONT_FAMILY = 1; | ||
private const NAME_ID_FONT_SUBFAMILY = 2; | ||
|
||
private const PLATFORM_ID_UNICODE = 0; | ||
private const PLATFORM_ID_MACINTOSH = 1; | ||
private const PLATFORM_ID_WINDOWS = 3; | ||
|
||
private $family; | ||
private $subfamily; | ||
|
||
public function __construct(string $path, string $family, string $subfamily) | ||
{ | ||
parent::__construct($path); | ||
|
||
$this->family = $family; | ||
$this->subfamily = $subfamily; | ||
} | ||
|
||
public function getFamily(): string | ||
{ | ||
return $this->family; | ||
} | ||
|
||
public function getWeight(): float | ||
{ | ||
// TODO implement this in a more fine-grained way by using the OS/2 table | ||
return $this->subfamily === 'Bold' || $this->subfamily === 'Bold Italic' ? 700 : 400; | ||
} | ||
|
||
public function isItalic(): bool | ||
{ | ||
return $this->subfamily === 'Italic' || $this->subfamily === 'Bold Italic'; | ||
} | ||
|
||
public function isMonospace(): bool | ||
{ | ||
// TODO implement detection for monospace fonts | ||
return false; | ||
} | ||
|
||
// https://learn.microsoft.com/en-us/typography/opentype/spec/otff | ||
public static function read(string $path) | ||
{ | ||
$file = fopen($path, 'rb'); | ||
if ($file === false) { | ||
return null; | ||
} | ||
|
||
$tableDirectory = self::readTableDirectory($file); | ||
if ($tableDirectory['sfntVersion'] !== self::SFNT_VERSION_TTF) { | ||
fclose($file); | ||
return null; | ||
} | ||
|
||
// 'name' should always exist: https://learn.microsoft.com/en-us/typography/opentype/spec/otff#required-tables | ||
if (!self::locateTableRecord($file, $tableDirectory, self::TABLE_TAG_NAME)) { | ||
fclose($file); | ||
return null; | ||
} | ||
|
||
$nameTableRecord = self::readTableRecord($file); | ||
$nameTable = self::readNamingTable($file, $nameTableRecord); | ||
|
||
fclose($file); | ||
|
||
return new self($path, $nameTable[self::NAME_ID_FONT_FAMILY], $nameTable[self::NAME_ID_FONT_SUBFAMILY]); | ||
} | ||
|
||
// // https://learn.microsoft.com/en-us/typography/opentype/spec/otff#table-directory | ||
// TableDirectory | ||
private static function readTableDirectory($file) | ||
{ | ||
return [ | ||
// 0x00010000 or 0x4F54544F ('OTTO') | ||
'sfntVersion' => self::uint32($file), | ||
// Number of tables. | ||
'numTables' => self::uint16($file), | ||
// Maximum power of 2 less than or equal to numTables, times 16. | ||
'searchRange' => self::uint16($file), | ||
// Log2 of the maximum power of 2 less than or equal to numTables. | ||
'entrySelector' => self::uint16($file), | ||
// numTables times 16, minus searchRange | ||
'rangeShift' => self::uint16($file), | ||
]; | ||
} | ||
|
||
/** | ||
* Perform a linear search over the table directory to locate the table record for the given table tag. | ||
* At the end, the file pointer will be positioned at the beginning of the table record, if found; otherwise, | ||
* the position will be unchanged. | ||
* | ||
* @param $file resource The file. | ||
* @param $tableDirectory array The table directory. | ||
* @param $tag string The tag to locate. | ||
* @return bool True if the table record was found and the file pointer changed; otherwise, false. | ||
*/ | ||
private static function locateTableRecord($file, array $tableDirectory, string $tag) | ||
{ | ||
for ($i = 0; $i < $tableDirectory['numTables']; ++$i) { | ||
$pos = 12 + $i * 16; | ||
if (self::stringAt($file, $pos, 4) === $tag) { | ||
fseek($file, $pos); | ||
return true; | ||
} | ||
} | ||
return false; | ||
} | ||
|
||
// https://learn.microsoft.com/en-us/typography/opentype/spec/otff#table-directory | ||
// TableRecord | ||
private static function readTableRecord($file) | ||
{ | ||
return [ | ||
// Table identifier. | ||
'tag' => self::string($file, 4), | ||
// Checksum for this table. | ||
'checkSum' => self::uint32($file), | ||
// Offset from beginning of font file. | ||
'offset' => self::uint32($file), | ||
// Length of this table. | ||
'length' => self::uint32($file), | ||
]; | ||
} | ||
|
||
// https://learn.microsoft.com/en-us/typography/opentype/spec/name | ||
private static function readNamingTable($file, array $record) | ||
{ | ||
fseek($file, $record['offset']); | ||
|
||
// https://learn.microsoft.com/en-us/typography/opentype/spec/name#naming-table-version-0 | ||
// Table version number (0 or 1). | ||
$version = self::uint16($file); | ||
// Number of name records. | ||
$count = self::uint16($file); | ||
// Offset to start of string storage (from start of table). | ||
$storageOffset = self::uint16($file); | ||
|
||
$names = []; | ||
for ($i = 0; $i < $count; ++$i) { | ||
$nameRecord = self::readNameRecord($file); | ||
$string = self::stringAt( | ||
$file, | ||
$record['offset'] + $storageOffset + $nameRecord['stringOffset'], | ||
$nameRecord['stringLength'] | ||
); | ||
$names[$nameRecord['nameID']] = self::decodeString($string, $nameRecord['platformID'], $nameRecord['encodingID']); | ||
} | ||
|
||
// Note: Table version 1 has additional fields after the name records, but we don't need those. | ||
// See: https://learn.microsoft.com/en-us/typography/opentype/spec/name#naming-table-version-1 | ||
|
||
return $names; | ||
} | ||
|
||
// https://learn.microsoft.com/en-us/typography/opentype/spec/name#name-records | ||
// NameRecord | ||
private static function readNameRecord($file) | ||
{ | ||
return [ | ||
// Platform ID. | ||
'platformID' => self::uint16($file), | ||
// Platform-specific encoding ID. | ||
'encodingID' => self::uint16($file), | ||
// Language ID. | ||
'languageID' => self::uint16($file), | ||
// Name ID. | ||
'nameID' => self::uint16($file), | ||
// String length (in bytes). | ||
'stringLength' => self::uint16($file), | ||
// String offset from start of storage area (in bytes). | ||
'stringOffset' => self::uint16($file), | ||
]; | ||
} | ||
|
||
private static function uint16($file) | ||
{ | ||
$bytes = fread($file, 2); | ||
return (ord($bytes[0]) << 8) + ord($bytes[1]); | ||
} | ||
|
||
private static function uint32($file) | ||
{ | ||
$bytes = fread($file, 4); | ||
return (ord($bytes[0]) << 24) + (ord($bytes[1]) << 16) + (ord($bytes[2]) << 8) + ord($bytes[3]); | ||
} | ||
|
||
private static function string($file, int $length) | ||
{ | ||
return fread($file, $length); | ||
} | ||
|
||
private static function stringAt($file, int $offset, int $length) | ||
{ | ||
$pos = ftell($file); | ||
fseek($file, $offset); | ||
$string = self::string($file, $length); | ||
fseek($file, $pos); | ||
return $string; | ||
} | ||
|
||
// https://learn.microsoft.com/en-us/typography/opentype/spec/name#platform-encoding-and-language | ||
private static function decodeString($string, int $platformID, int $encodingID) | ||
{ | ||
// https://learn.microsoft.com/en-us/typography/opentype/spec/name#platform-ids | ||
switch ($platformID) { | ||
case self::PLATFORM_ID_UNICODE: | ||
// Strings for the Unicode platform must be encoded in UTF-16BE. | ||
return mb_convert_encoding($string, 'UTF-8', 'UTF-16BE'); | ||
case self::PLATFORM_ID_MACINTOSH: | ||
// Strings for the Macintosh platform (platform ID 1) use platform-specific single- or double-byte | ||
// encodings according to the specified encoding ID for a given name record. | ||
// TODO: implement | ||
return $string; | ||
case self::PLATFORM_ID_WINDOWS: | ||
// All string data for platform 3 must be encoded in UTF-16BE. | ||
return mb_convert_encoding($string, 'UTF-8', 'UTF-16BE'); | ||
default: | ||
return $string; | ||
} | ||
} | ||
} |
Oops, something went wrong.