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():
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:
$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 raisesDocument\Exception("The font license does not allow for it to be embedded.") for a font it reports as non-embeddable$embedOverride, the second argument toembedFont()/embedFonts(), defaults tofalse; passtrueto 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-emhhea/hmtx— ascent, descent and glyph widthscmap— the Unicode-to-glyph mappinghasGlyph()andgetGlyphId()readOS/2— the embedding permissionembedFont()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:
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:
use Pop\Pdf\Document\Font;
$type1 = new Font(__DIR__ . '/fonts/NimbusSans-Regular.pfb'); // .afm found alongside it
$type1->isCid(); // false
- a
.pfbwith no matching.afmnext to it raisesBuild\Font\Exception, "The AFM font file was not found." - only a
.pfbpath is accepted — pointingFontat the.afmfile 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
/FontFileentry, not the/FontFile2a 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:
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:
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:
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:
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:
$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:
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:
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:
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:
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:
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:
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:
$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:
$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):
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:
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 —
$fontStyleresolution and rendering once a font is registered - Drawing & Paths — the color interface shared by text and drawn shapes