Pop PDF
Build

Embedding Fonts

When the standard fonts aren't enough and you require specific fonts in your PDF document, you can embed font files. Handing Font a path instead of a standard name embeds the font's own program in the PDF, instead of relying on the reader's viewer to supply glyphs it never shipped.

Embedding a Font File#

Construct a Font from a path and register it with embedFont():

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Text;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Pdf;

$font = new Font(__DIR__ . '/fonts/DejaVuSans.ttf');
$font->getName();       // 'DejaVuSans' — read from the file, not the path
$font->isEmbedded();    // true
$font->isStandard();    // false

$document = new Document(new Page(Page::LETTER));
$document->embedFont($font);

$text = new Text('Café Müller — €120 naïve', size: 24);
$document->getPage(1)->addText($text, $font->getName(), x: 72, y: 700);

Pdf::writeToFile($document, filename: __DIR__ . '/embedded.pdf');

embedFonts() takes an array of Font instances for more than one file at once:

PHP
$ttf = new Font(__DIR__ . '/fonts/DejaVuSans.ttf');
$otf = new Font(__DIR__ . '/fonts/NimbusSans-Regular.otf');

$document->embedFonts([$ttf, $otf]);
$document->getAvailableFonts(); // ['DejaVuSans', 'NimbusSans-Regular']

addFont() also accepts a path directly and forwards it to embedFont() — the two calls end up identical; reach for embedFont() when the Font object is already built.

Embedding costs real space — the DejaVu Sans document above writes 863,407 bytes. The identical string in a standard font instead writes a fraction of that.

embedFont() on a Font built from a standard name — where isEmbedded() is false — hands the call straight to addFont() instead of embedding anything; either kind of Font works with either method.

Some font files mark themselves non-embeddable in their own metadata — a TrueType or OpenType file's OS/2 table, read by parser()->isEmbeddable():

  • embedFont() checks that flag and raises Document\Exception ("The font license does not allow for it to be embedded.") for a font it reports as non-embeddable
  • $embedOverride, the second argument to embedFont()/embedFonts(), defaults to false; pass true to embed anyway

Supported Formats#

A Font's extension decides which parser reads it — .ttf goes to TrueType, .otf to TrueType\OpenType, and .pfb to Type1. Any other extension raises Build\Font\Exception with the message "That font type is not supported."

Extension Parser class isCid() PDF font-program entry
.ttf Build\Font\TrueType true /FontFile2
.otf Build\Font\TrueType\OpenType true /FontFile2
.pfb Build\Font\Type1 false /FontFile

TrueType#

A .ttf file is read table by table:

  • head — the font's bounding box and units-per-em
  • hhea/hmtx — ascent, descent and glyph widths
  • cmap — the Unicode-to-glyph mapping hasGlyph() and getGlyphId() read
  • OS/2 — the embedding permission embedFont() checks

A .ttf file with no OS/2 table at all is treated as embeddable by default, since there is no restriction to read.

OpenType#

.otf goes through the same table-by-table reader — TrueType\OpenType extends TrueType rather than replacing it — overriding only the OS/2 parsing to read cap height directly, instead of the ascent-plus-descent estimate plain TrueType falls back to. Everything downstream — isCid(), glyph coverage, the CID content stream — behaves identically for .otf and .ttf; get_class($font->getParsedFont()) is the only way to tell them apart from a Font object already in hand:

PHP
use Pop\Pdf\Document\Font;

$otf = new Font(__DIR__ . '/fonts/NimbusSans-Regular.otf');
get_class($otf->getParsedFont()); // 'Pop\Pdf\Build\Font\TrueType\OpenType'
$otf->isCid();                    // true

Type1#

Type1 is different in two ways. It needs two files on disk, not one — a .pfb for the glyph program and a same-named .afm for the metrics — and Font only finds the second one when it sits next to the first with a matching basename:

PHP
use Pop\Pdf\Document\Font;

$type1 = new Font(__DIR__ . '/fonts/NimbusSans-Regular.pfb'); // .afm found alongside it
$type1->isCid(); // false
  • a .pfb with no matching .afm next to it raises Build\Font\Exception, "The AFM font file was not found."
  • only a .pfb path is accepted — pointing Font at the .afm file itself raises "That font type is not supported," the same error any unrecognized extension produces, even sitting right next to its pair
  • a rendered Type1 document carries a /FontFile entry, not the /FontFile2 a TrueType or OpenType embed produces
  • its content stream addresses glyphs by the font's own single-byte encoding — the same one a standard font uses, not the two-byte glyph IDs a CID font uses, which is why Type1 stays off the CID path entirely

Unicode and CID Fonts#

isCid() is true for an embedded TrueType or OpenType font and false for everything else — standard fonts and embedded Type1 files both stay on the single-byte path:

PHP
use Pop\Pdf\Document\Font;

$ttf = new Font(__DIR__ . '/fonts/DejaVuSans.ttf');
$ttf->isCid(); // true

(new Font(Font::HELVETICA))->isCid(); // false — standard fonts are never CID

stringToCodeUnits() is the static conversion a CID font's content stream is built from — it splits a UTF-8 string into its UTF-16BE code units, one array entry per character on the Basic Multilingual Plane:

PHP
Font::stringToCodeUnits('AB'); // [65, 66]

Those are decimal — 65 and 66 are 0x0041 and 0x0042, matching UTF-16BE's own raw bytes for the same string byte for byte:

PHP
bin2hex(iconv('UTF-8', 'UTF-16BE', 'AB')); // '00410042'

"One entry per character" holds only within the Basic Multilingual Plane — a character outside it (an emoji, some rarer CJK extensions) splits into a UTF-16 surrogate pair instead of a single code unit, so a string with one such character produces more entries than it has characters:

PHP
Font::stringToCodeUnits('A😀B'); // [65, 55357, 56832, 66] — 4 entries for 3 characters

stringToGidHex() carries that further, converting a string all the way to the big-endian glyph-ID hex string the compiled PDF actually places in the page's content stream:

PHP
$ttf->stringToGidHex('AB'); // '00240025' — glyph IDs 0x0024 and 0x0025, not the code units themselves

Glyph IDs are a font's own internal numbering and have no fixed relationship to Unicode code points — 00240025 here is specific to DejaVu Sans's glyph table, and the same string against a different TrueType file produces different hex.

Glyph Coverage#

hasGlyph($codeUnit) answers whether a font can draw a given UTF-16BE code unit at all:

PHP
use Pop\Pdf\Document\Font;

$ttf = new Font(__DIR__ . '/fonts/DejaVuSans.ttf');
$ttf->hasGlyph(0x0041); // true — 'A'
$ttf->hasGlyph(0x4E2D); // false — DejaVu Sans ships no CJK glyphs

getGlyphId($codeUnit) is the CID-specific half of that check — it returns the glyph ID for a covered code unit and null for one it doesn't cover, and it only ever answers for a CID font; called on a standard or Type1 Font it always returns null.

hasGlyph() itself answers differently depending on what kind of Font it's called on, for the same code unit:

PHP
use Pop\Pdf\Document\Font;

$cjk = 0x4E2D; // 中

$ttf = new Font(__DIR__ . '/fonts/DejaVuSans.ttf');
$ttf->hasGlyph($cjk); // false — checked against the font's own cmap

$type1 = new Font(__DIR__ . '/fonts/NimbusSans-Regular.pfb');
$type1->hasGlyph($cjk); // true — Type1 coverage isn't tracked per codepoint, so it's assumed covered

$standard = new Font(Font::HELVETICA);
$standard->hasGlyph($cjk); // false — checked against the standard font's own glyph table

A CID font's false is a real answer, checked against the file's own character map. A Type1 font's true isn't a coverage check at all — Type1 has no per-codepoint table to consult, so it reports every code unit as covered and leaves the PDF viewer to fail however it fails on a glyph it doesn't have.

Check every character up front, rather than waiting for requireGlyphCoverage() to stop at the first miss, by running hasGlyph() over stringToCodeUnits() directly:

PHP
use Pop\Pdf\Document\Font;

$ttf     = new Font(__DIR__ . '/fonts/DejaVuSans.ttf');
$string  = 'A中B文';
$missing = [];

foreach (Font::stringToCodeUnits($string) as $i => $unit) {
    if (!$ttf->hasGlyph($unit)) {
        $missing[] = mb_substr($string, $i, 1);
    }
}
// $missing === ['中', '文']

requireGlyphCoverage($string) runs hasGlyph() across every character in a string and throws on the first one the font can't draw, naming the character and its code point:

PHP
try {
    $ttf->requireGlyphCoverage('AB中');
} catch (\Pop\Pdf\Build\Font\Exception $exception) {
    echo $exception->getMessage();
    // Error: The font 'DejaVuSans' does not contain a glyph for character '中' (U+4E2D).
}

No need to call this by hand before every string — Text calls it automatically while compiling the document, so a PDF with unrepresentable characters fails at writeToFile() with the same exception either way:

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Text;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Pdf;

$ttf      = new Font(__DIR__ . '/fonts/DejaVuSans.ttf');
$document = new Document(new Page(Page::LETTER));
$document->embedFont($ttf);

$text = new Text('中文', size: 24);
$document->getPage(1)->addText($text, $ttf->getName(), x: 72, y: 700);

Pdf::writeToFile($document, filename: __DIR__ . '/fail.pdf');
// Pop\Pdf\Build\Font\Exception: Error: The font 'DejaVuSans' does not contain a
// glyph for character '中' (U+4E2D).

stringToGidHex() calls requireGlyphCoverage() internally before converting anything, so it fails the same way rather than emitting a hex string with a hole in it.

Inspecting a Parsed Font#

getParsedFont() returns the underlying TrueType, TrueType\OpenType or Type1 instance that did the parsing, with the font's own metrics readable off it by array access:

PHP
use Pop\Pdf\Document\Font;

$ttf    = new Font(__DIR__ . '/fonts/DejaVuSans.ttf');
$parsed = $ttf->getParsedFont();
$parsed['ascent'];         // 929
$parsed['descent'];        // -235
$parsed['unitsPerEm'];     // 2048
$parsed['numberOfGlyphs']; // 6253
$parsed['capHeight'];      // 694

parser() returns the Build\Font\Parser wrapping that same instance — document-compilation details, not the font's own metrics: getFontName(), isEmbeddable() and isCompressed(), covered below:

PHP
$ttf->parser()->getFontName();    // 'DejaVuSans'
$ttf->parser()->isEmbeddable();   // true
$ttf->parser()->isCompressed();   // false

Both methods return null on a standard Font — there is nothing parsed, because there was never a file to parse.

The same array keys exist on a Type1 instance, but not every one of them is guaranteed to be populated — Type1's metrics come from whatever fields the .afm file happens to define, rather than a fixed binary table layout every TrueType file carries:

PHP
$type1  = new Font(__DIR__ . '/fonts/NimbusSans-Regular.pfb');
$parsed = $type1->getParsedFont();
$parsed['capHeight'];        // '729'
$parsed['numberOfGlyphs'];   // '855'
$parsed['ascent'];           // '0' — this AFM defines no Ascender line
$parsed['info']->fullName;   // 'Nimbus Sans'

isCompressed() reads back whatever the document's compression flag was at the moment embedFont() ran — it is false immediately after embedding into a document that has not yet called setCompression(true):

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Font;

$ttf      = new Font(__DIR__ . '/fonts/DejaVuSans.ttf');
$document = new Document(new Page(Page::LETTER));
$document->embedFont($ttf);

$ttf->parser()->isCompressed(); // false — $document hasn't turned compression on yet

That snapshot isn't what ends up in the file. Build\Compiler re-applies the document's compression setting to every embedded font's parser, unconditionally, at compile time — so setCompression(true) only has to happen before writeToFile() runs, not before embedFont(). Both orderings of the DejaVu Sans document from Embedding a Font File compile to the identical byte count:

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Text;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Pdf;

$before = new Font(__DIR__ . '/fonts/DejaVuSans.ttf');
$doc1   = new Document(new Page(Page::LETTER));
$doc1->setCompression(true);
$doc1->embedFont($before);   // compression set, then embed
$doc1->getPage(1)->addText(new Text('Café Müller — €120 naïve', size: 24), $before->getName(), x: 72, y: 700);
Pdf::writeToFile($doc1, filename: __DIR__ . '/before.pdf'); // 486,964 bytes

$after = new Font(__DIR__ . '/fonts/DejaVuSans.ttf');
$doc2  = new Document(new Page(Page::LETTER));
$doc2->embedFont($after);    // embed, then compression set
$doc2->setCompression(true);
$doc2->getPage(1)->addText(new Text('Café Müller — €120 naïve', size: 24), $after->getName(), x: 72, y: 700);
Pdf::writeToFile($doc2, filename: __DIR__ . '/after.pdf');  // 486,964 bytes — identical

Compression pays off regardless of ordering — the same document wrote uncompressed at 863,407 bytes above, 486,964 with compression on, over 40% smaller for the identical page.

See Also#

  • Fonts — the standard fonts that need none of this, and how a document tracks the ones it has registered
  • Styles — naming an embedded font, a size and a color together by getName()
  • Adding Text$fontStyle resolution and rendering once a font is registered
  • Drawing & Paths — the color interface shared by text and drawn shapes