Skip to content

Commit

Permalink
feat!: Implement a font registry for text rendering
Browse files Browse the repository at this point in the history
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
meyfa committed Dec 28, 2022
1 parent ef0ca16 commit 7bc3783
Show file tree
Hide file tree
Showing 15 changed files with 467 additions and 128 deletions.
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,40 @@ $rasterImage = $image->toRasterImage(200, 200, '#FFFFFF');
imagejpeg($rasterImage, 'path/to/output.jpg');
```

### Text rendering (loading fonts)

PHP-SVG implements support for TrueType fonts (`.ttf` files) using a handcrafted TTF parser. Since PHP doesn't come
with any built-in font files, you will need to provide your own. The following example shows how to load a set of font
files. PHP-SVG will try to pick the best matching font for a given text element, based on algorithms from the CSS spec.

```php
<?php
require __DIR__ . '/vendor/autoload.php';

use SVG\SVG;

// load a set of fonts
SVG::addFont(FONTS_DIR . 'Ubuntu-Regular.ttf');
SVG::addFont(FONTS_DIR . 'Ubuntu-Bold.ttf');
SVG::addFont(FONTS_DIR . 'Ubuntu-Italic.ttf');
SVG::addFont(FONTS_DIR . 'Ubuntu-BoldItalic.ttf');

$image = SVG::fromString('
<svg width="220" height="220">
<rect x="0" y="0" width="100%" height="100%" fill="lightgray"/>
<g font-size="15">
<text y="20">hello world</text>
<text y="100" font-weight="bold">in bold!</text>
<text y="120" font-style="italic">and italic!</text>
<text y="140" font-weight="bold" font-style="italic">bold and italic</text>
</g>
</svg>
');

header('Content-Type: image/png');
imagepng($image->toRasterImage(220, 220));
```


## Document model

Expand Down
30 changes: 30 additions & 0 deletions src/Fonts/FontFile.php
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;
}
57 changes: 57 additions & 0 deletions src/Fonts/FontRegistry.php
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];
}
}
238 changes: 238 additions & 0 deletions src/Fonts/TrueTypeFontFile.php
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;
}
}
}
Loading

0 comments on commit 7bc3783

Please sign in to comment.