[offset, length]. * * @var array */ private $tables = array(); /** * Code point => glyph ID. * * @var array */ private $cmap = array(); /** * Advance widths in font units, by glyph ID. * * @var int[] */ private $advances = array(); /** * Glyph offsets from the loca table (numGlyphs + 1 entries). * * @var int[] */ private $loca = array(); /** * Font units per em. * * @var int */ public $units_per_em = 1000; /** * Ascender in font units. * * @var int */ public $ascent = 0; /** * Descender (negative). * * @var int */ public $descent = 0; /** * Cap height. * * @var int */ public $cap_height = 0; /** * Bounding box [xMin, yMin, xMax, yMax]. * * @var int[] */ public $bbox = array( 0, 0, 0, 0 ); /** * Italic angle in degrees. * * @var float */ public $italic_angle = 0.0; /** * PostScript name. * * @var string */ public $name = 'Font'; /** * Number of glyphs. * * @var int */ public $num_glyphs = 0; /** * Weight class from OS/2 (400 regular, 700 bold). * * @var int */ public $weight = 400; /** * Loads a font file. * * @param string $path Path to a .ttf file. * @return self * @throws \RuntimeException When the file can't be read or isn't a TrueType font. */ public static function load( $path ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local font file shipped with the plugin. $data = is_readable( $path ) ? file_get_contents( $path ) : false; if ( false === $data || strlen( $data ) < 12 ) { throw new \RuntimeException( 'The PDF font file is missing or unreadable.' ); } return new self( $data ); } /** * Parses font data. * * @param string $data Font file contents. * @throws \RuntimeException When the font isn't usable. */ public function __construct( $data ) { $this->data = $data; $version = $this->u32( 0 ); if ( 0x00010000 !== $version && 0x74727565 !== $version ) { throw new \RuntimeException( 'The PDF font is not a TrueType font.' ); } $count = $this->u16( 4 ); for ( $i = 0; $i < $count; $i++ ) { $p = 12 + 16 * $i; $tag = substr( $data, $p, 4 ); $this->tables[ $tag ] = array( $this->u32( $p + 8 ), $this->u32( $p + 12 ) ); } foreach ( array( 'head', 'hhea', 'maxp', 'hmtx', 'loca', 'glyf', 'cmap' ) as $required ) { if ( ! isset( $this->tables[ $required ] ) ) { throw new \RuntimeException( 'The PDF font has no ' . $required . ' table.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Caught by the plugin; escaped where the message is shown. } } $this->parse_head(); $this->parse_metrics(); $this->parse_loca(); $this->parse_cmap(); $this->parse_extras(); } /** * Glyph ID for a code point (0 = .notdef when the font lacks it). * * @param int $cp Code point. * @return int */ public function glyph( $cp ) { return isset( $this->cmap[ $cp ] ) ? $this->cmap[ $cp ] : 0; } /** * Whether the font has a glyph for a code point. * * @param int $cp Code point. * @return bool */ public function has( $cp ) { return isset( $this->cmap[ $cp ] ); } /** * Advance width of a glyph in thousandths of an em (PDF text space). * * @param int $gid Glyph ID. * @return float */ public function width( $gid ) { $n = count( $this->advances ); $adv = $gid < $n ? $this->advances[ $gid ] : ( $n ? $this->advances[ $n - 1 ] : 0 ); return $adv * 1000 / $this->units_per_em; } /** * Scales a font-unit value to thousandths of an em. * * @param float $v Value in font units. * @return int */ public function scale( $v ) { return (int) round( $v * 1000 / $this->units_per_em ); } /** * Builds a subset that keeps only the given glyphs' outlines. * * @param int[] $gids Glyph IDs used (0 is always kept). * @return string Font file. */ public function subset( array $gids ) { $keep = array( 0 => true ); foreach ( $gids as $gid ) { $keep[ (int) $gid ] = true; } // Composite glyphs need their components. $queue = array_keys( $keep ); while ( $queue ) { $gid = array_pop( $queue ); foreach ( $this->components( $gid ) as $component ) { if ( ! isset( $keep[ $component ] ) ) { $keep[ $component ] = true; $queue[] = $component; } } } $glyf_offset = $this->tables['glyf'][0]; $glyf = ''; $loca = array(); for ( $gid = 0; $gid < $this->num_glyphs; $gid++ ) { $loca[] = strlen( $glyf ); if ( isset( $keep[ $gid ] ) ) { $len = $this->loca[ $gid + 1 ] - $this->loca[ $gid ]; if ( $len > 0 ) { $glyf .= substr( $this->data, $glyf_offset + $this->loca[ $gid ], $len ); // Long loca offsets must stay 4-byte aligned for some readers; short ones need 2. $glyf .= str_repeat( "\0", ( 4 - strlen( $glyf ) % 4 ) % 4 ); } } } $loca[] = strlen( $glyf ); $long = strlen( $glyf ) > 0x1FFFE; $loca_b = ''; foreach ( $loca as $off ) { $loca_b .= $long ? pack( 'N', $off ) : pack( 'n', $off / 2 ); } $tables = array(); foreach ( array( 'cmap', 'cvt ', 'fpgm', 'glyf', 'head', 'hhea', 'hmtx', 'loca', 'maxp', 'name', 'OS/2', 'post', 'prep' ) as $tag ) { if ( 'glyf' === $tag ) { $tables[ $tag ] = $glyf; } elseif ( 'loca' === $tag ) { $tables[ $tag ] = $loca_b; } elseif ( isset( $this->tables[ $tag ] ) ) { $tables[ $tag ] = substr( $this->data, $this->tables[ $tag ][0], $this->tables[ $tag ][1] ); } } // post: keep the metrics, drop glyph names (format 3); they aren't needed in a PDF. if ( isset( $tables['post'] ) && strlen( $tables['post'] ) >= 32 ) { $tables['post'] = pack( 'N', 0x00030000 ) . substr( $tables['post'], 4, 28 ); } // head: clear checkSumAdjustment and set the loca format used above. $head = $tables['head']; $head = substr_replace( $head, "\0\0\0\0", 8, 4 ); $head = substr_replace( $head, pack( 'n', $long ? 1 : 0 ), 50, 2 ); $tables['head'] = $head; list( $font, $offsets ) = self::assemble( $tables ); // Fix the whole-font checksum in head. $sum = ( 0xB1B0AFBA - self::checksum( $font ) ) & 0xFFFFFFFF; return substr_replace( $font, pack( 'N', $sum ), $offsets['head'] + 8, 4 ); } /** * Writes a font file from tables. * * @param array $tables Tag => data. * @return array{0:string,1:array} Font file, and each table's offset. */ private static function assemble( array $tables ) { ksort( $tables, SORT_STRING ); $n = count( $tables ); $pow = 1; $log = 0; while ( $pow * 2 <= $n ) { $pow *= 2; ++$log; } $header = pack( 'Nnnnn', 0x00010000, $n, $pow * 16, $log, $n * 16 - $pow * 16 ); $dir = ''; $body = ''; $offset = 12 + 16 * $n; $offsets = array(); foreach ( $tables as $tag => $data ) { $len = strlen( $data ); $pad = ( 4 - $len % 4 ) % 4; $dir .= $tag . pack( 'NNN', self::checksum( $data ), $offset, $len ); $body .= $data . str_repeat( "\0", $pad ); $offsets[ $tag ] = $offset; $offset += $len + $pad; } return array( $header . $dir . $body, $offsets ); } /** * TrueType table checksum. * * @param string $data Data. * @return int */ private static function checksum( $data ) { $data .= str_repeat( "\0", ( 4 - strlen( $data ) % 4 ) % 4 ); $sum = 0; foreach ( unpack( 'N*', $data ) as $word ) { $sum = ( $sum + $word ) & 0xFFFFFFFF; } return $sum; } /** * Glyph IDs a composite glyph is built from. * * @param int $gid Glyph ID. * @return int[] */ private function components( $gid ) { if ( $gid < 0 || $gid >= $this->num_glyphs ) { return array(); } $start = $this->tables['glyf'][0] + $this->loca[ $gid ]; $len = $this->loca[ $gid + 1 ] - $this->loca[ $gid ]; if ( $len < 10 || $this->i16( $start ) >= 0 ) { return array(); } $out = array(); $p = $start + 10; $end = $start + $len; do { if ( $p + 4 > $end ) { break; } $flags = $this->u16( $p ); $out[] = $this->u16( $p + 2 ); $p += 4 + ( ( $flags & 0x0001 ) ? 4 : 2 ); if ( $flags & 0x0008 ) { $p += 2; } elseif ( $flags & 0x0040 ) { $p += 4; } elseif ( $flags & 0x0080 ) { $p += 8; } } while ( $flags & 0x0020 ); return $out; } /** * Reads head. * * @return void */ private function parse_head() { $p = $this->tables['head'][0]; $this->units_per_em = max( 16, $this->u16( $p + 18 ) ); $this->bbox = array( $this->i16( $p + 36 ), $this->i16( $p + 38 ), $this->i16( $p + 40 ), $this->i16( $p + 42 ) ); $this->num_glyphs = $this->u16( $this->tables['maxp'][0] + 4 ); } /** * Reads hhea and hmtx. * * @return void */ private function parse_metrics() { $h = $this->tables['hhea'][0]; $this->ascent = $this->i16( $h + 4 ); $this->descent = $this->i16( $h + 6 ); $metrics = $this->u16( $h + 34 ); $p = $this->tables['hmtx'][0]; for ( $i = 0; $i < $metrics; $i++ ) { $this->advances[] = $this->u16( $p + 4 * $i ); } } /** * Reads loca. * * @return void */ private function parse_loca() { $long = 1 === $this->i16( $this->tables['head'][0] + 50 ); $p = $this->tables['loca'][0]; for ( $i = 0; $i <= $this->num_glyphs; $i++ ) { $this->loca[] = $long ? $this->u32( $p + 4 * $i ) : 2 * $this->u16( $p + 2 * $i ); } } /** * Reads the Unicode cmap (format 12 when present, else format 4). * * @return void * @throws \RuntimeException Without a Unicode map. */ private function parse_cmap() { $base = $this->tables['cmap'][0]; $n = $this->u16( $base + 2 ); $f4 = 0; $f12 = 0; for ( $i = 0; $i < $n; $i++ ) { $p = $base + 4 + 8 * $i; $platform = $this->u16( $p ); $encoding = $this->u16( $p + 2 ); $offset = $base + $this->u32( $p + 4 ); $format = $this->u16( $offset ); if ( 12 === $format && ( ( 3 === $platform && 10 === $encoding ) || 0 === $platform ) ) { $f12 = $offset; } elseif ( 4 === $format && ( ( 3 === $platform && 1 === $encoding ) || 0 === $platform ) ) { $f4 = $f4 ? $f4 : $offset; } } if ( $f12 ) { $groups = $this->u32( $f12 + 12 ); for ( $g = 0; $g < $groups; $g++ ) { $p = $f12 + 16 + 12 * $g; $start = $this->u32( $p ); $end = min( $this->u32( $p + 4 ), $start + 0x10000 ); $gid = $this->u32( $p + 8 ); for ( $c = $start; $c <= $end; $c++ ) { $this->cmap[ $c ] = $gid + $c - $start; } } return; } if ( ! $f4 ) { throw new \RuntimeException( 'The PDF font has no Unicode character map.' ); } $segs = $this->u16( $f4 + 6 ) / 2; $ends = $f4 + 14; $starts = $ends + 2 * $segs + 2; $deltas = $starts + 2 * $segs; $ranges = $deltas + 2 * $segs; for ( $s = 0; $s < $segs; $s++ ) { $end = $this->u16( $ends + 2 * $s ); $start = $this->u16( $starts + 2 * $s ); $delta = $this->i16( $deltas + 2 * $s ); $range = $this->u16( $ranges + 2 * $s ); if ( 0xFFFF === $start ) { continue; } for ( $c = $start; $c <= $end; $c++ ) { if ( 0 === $range ) { $gid = ( $c + $delta ) & 0xFFFF; } else { $p = $ranges + 2 * $s + $range + 2 * ( $c - $start ); $gid = $this->u16( $p ); $gid = $gid ? ( $gid + $delta ) & 0xFFFF : 0; } if ( $gid ) { $this->cmap[ $c ] = $gid; } } } } /** * Reads OS/2, post and name, which are optional. * * @return void */ private function parse_extras() { if ( isset( $this->tables['OS/2'] ) ) { $p = $this->tables['OS/2'][0]; $this->weight = $this->u16( $p + 4 ); if ( $this->u16( $p ) >= 2 && $this->tables['OS/2'][1] >= 90 ) { $this->cap_height = $this->i16( $p + 88 ); } } if ( ! $this->cap_height ) { $this->cap_height = (int) round( $this->ascent * 0.7 ); } if ( isset( $this->tables['post'] ) ) { $p = $this->tables['post'][0]; $this->italic_angle = $this->i16( $p + 4 ) + $this->u16( $p + 6 ) / 65536; } if ( isset( $this->tables['name'] ) ) { $p = $this->tables['name'][0]; $count = $this->u16( $p + 2 ); $storage = $p + $this->u16( $p + 4 ); for ( $i = 0; $i < $count; $i++ ) { $r = $p + 6 + 12 * $i; if ( 6 !== $this->u16( $r + 6 ) ) { continue; } $raw = substr( $this->data, $storage + $this->u16( $r + 10 ), $this->u16( $r + 8 ) ); $name = 3 === $this->u16( $r ) ? preg_replace( '/[^A-Za-z0-9-]/', '', str_replace( "\0", '', $raw ) ) : preg_replace( '/[^A-Za-z0-9-]/', '', $raw ); if ( '' !== $name ) { $this->name = $name; break; } } } } /** * Unsigned 16-bit value. * * @param int $p Offset. * @return int */ private function u16( $p ) { $v = unpack( 'n', substr( $this->data, $p, 2 ) . "\0\0" ); return (int) $v[1]; } /** * Signed 16-bit value. * * @param int $p Offset. * @return int */ private function i16( $p ) { $v = $this->u16( $p ); return $v >= 0x8000 ? $v - 0x10000 : $v; } /** * Unsigned 32-bit value. * * @param int $p Offset. * @return int */ private function u32( $p ) { $v = unpack( 'N', substr( $this->data, $p, 4 ) . "\0\0\0\0" ); return (int) ( $v[1] & 0xFFFFFFFF ); } }