<?php | |
/******************************************************************************* | |
* Class to parse and subset TrueType fonts * | |
* * | |
* Version: 1.11 * | |
* Date: 2021-04-18 * | |
* Author: Olivier PLATHEY * | |
*******************************************************************************/ | |
class TTFParser | |
{ | |
protected $f; | |
protected $tables; | |
protected $numberOfHMetrics; | |
protected $numGlyphs; | |
protected $glyphNames; | |
protected $indexToLocFormat; | |
protected $subsettedChars; | |
protected $subsettedGlyphs; | |
public $chars; | |
public $glyphs; | |
public $unitsPerEm; | |
public $xMin, $yMin, $xMax, $yMax; | |
public $postScriptName; | |
public $embeddable; | |
public $bold; | |
public $typoAscender; | |
public $typoDescender; | |
public $capHeight; | |
public $italicAngle; | |
public $underlinePosition; | |
public $underlineThickness; | |
public $isFixedPitch; | |
function __construct($file) | |
{ | |
$this->f = fopen($file, 'rb'); | |
if(!$this->f) | |
$this->Error('Can\'t open file: '.$file); | |
} | |
function __destruct() | |
{ | |
if(is_resource($this->f)) | |
fclose($this->f); | |
} | |
function Parse() | |
{ | |
$this->ParseOffsetTable(); | |
$this->ParseHead(); | |
$this->ParseHhea(); | |
$this->ParseMaxp(); | |
$this->ParseHmtx(); | |
$this->ParseLoca(); | |
$this->ParseGlyf(); | |
$this->ParseCmap(); | |
$this->ParseName(); | |
$this->ParseOS2(); | |
$this->ParsePost(); | |
} | |
function ParseOffsetTable() | |
{ | |
$version = $this->Read(4); | |
if($version=='OTTO') | |
$this->Error('OpenType fonts based on PostScript outlines are not supported'); | |
if($version!="\x00\x01\x00\x00") | |
$this->Error('Unrecognized file format'); | |
$numTables = $this->ReadUShort(); | |
$this->Skip(3*2); // searchRange, entrySelector, rangeShift | |
$this->tables = array(); | |
for($i=0;$i<$numTables;$i++) | |
{ | |
$tag = $this->Read(4); | |
$checkSum = $this->Read(4); | |
$offset = $this->ReadULong(); | |
$length = $this->ReadULong(); | |
$this->tables[$tag] = array('offset'=>$offset, 'length'=>$length, 'checkSum'=>$checkSum); | |
} | |
} | |
function ParseHead() | |
{ | |
$this->Seek('head'); | |
$this->Skip(3*4); // version, fontRevision, checkSumAdjustment | |
$magicNumber = $this->ReadULong(); | |
if($magicNumber!=0x5F0F3CF5) | |
$this->Error('Incorrect magic number'); | |
$this->Skip(2); // flags | |
$this->unitsPerEm = $this->ReadUShort(); | |
$this->Skip(2*8); // created, modified | |
$this->xMin = $this->ReadShort(); | |
$this->yMin = $this->ReadShort(); | |
$this->xMax = $this->ReadShort(); | |
$this->yMax = $this->ReadShort(); | |
$this->Skip(3*2); // macStyle, lowestRecPPEM, fontDirectionHint | |
$this->indexToLocFormat = $this->ReadShort(); | |
} | |
function ParseHhea() | |
{ | |
$this->Seek('hhea'); | |
$this->Skip(4+15*2); | |
$this->numberOfHMetrics = $this->ReadUShort(); | |
} | |
function ParseMaxp() | |
{ | |
$this->Seek('maxp'); | |
$this->Skip(4); | |
$this->numGlyphs = $this->ReadUShort(); | |
} | |
function ParseHmtx() | |
{ | |
$this->Seek('hmtx'); | |
$this->glyphs = array(); | |
for($i=0;$i<$this->numberOfHMetrics;$i++) | |
{ | |
$advanceWidth = $this->ReadUShort(); | |
$lsb = $this->ReadShort(); | |
$this->glyphs[$i] = array('w'=>$advanceWidth, 'lsb'=>$lsb); | |
} | |
for($i=$this->numberOfHMetrics;$i<$this->numGlyphs;$i++) | |
{ | |
$lsb = $this->ReadShort(); | |
$this->glyphs[$i] = array('w'=>$advanceWidth, 'lsb'=>$lsb); | |
} | |
} | |
function ParseLoca() | |
{ | |
$this->Seek('loca'); | |
$offsets = array(); | |
if($this->indexToLocFormat==0) | |
{ | |
// Short format | |
for($i=0;$i<=$this->numGlyphs;$i++) | |
$offsets[] = 2*$this->ReadUShort(); | |
} | |
else | |
{ | |
// Long format | |
for($i=0;$i<=$this->numGlyphs;$i++) | |
$offsets[] = $this->ReadULong(); | |
} | |
for($i=0;$i<$this->numGlyphs;$i++) | |
{ | |
$this->glyphs[$i]['offset'] = $offsets[$i]; | |
$this->glyphs[$i]['length'] = $offsets[$i+1] - $offsets[$i]; | |
} | |
} | |
function ParseGlyf() | |
{ | |
$tableOffset = $this->tables['glyf']['offset']; | |
foreach($this->glyphs as &$glyph) | |
{ | |
if($glyph['length']>0) | |
{ | |
fseek($this->f, $tableOffset+$glyph['offset'], SEEK_SET); | |
if($this->ReadShort()<0) | |
{ | |
// Composite glyph | |
$this->Skip(4*2); // xMin, yMin, xMax, yMax | |
$offset = 5*2; | |
$a = array(); | |
do | |
{ | |
$flags = $this->ReadUShort(); | |
$index = $this->ReadUShort(); | |
$a[$offset+2] = $index; | |
if($flags & 1) // ARG_1_AND_2_ARE_WORDS | |
$skip = 2*2; | |
else | |
$skip = 2; | |
if($flags & 8) // WE_HAVE_A_SCALE | |
$skip += 2; | |
elseif($flags & 64) // WE_HAVE_AN_X_AND_Y_SCALE | |
$skip += 2*2; | |
elseif($flags & 128) // WE_HAVE_A_TWO_BY_TWO | |
$skip += 4*2; | |
$this->Skip($skip); | |
$offset += 2*2 + $skip; | |
} | |
while($flags & 32); // MORE_COMPONENTS | |
$glyph['components'] = $a; | |
} | |
} | |
} | |
} | |
function ParseCmap() | |
{ | |
$this->Seek('cmap'); | |
$this->Skip(2); // version | |
$numTables = $this->ReadUShort(); | |
$offset31 = 0; | |
for($i=0;$i<$numTables;$i++) | |
{ | |
$platformID = $this->ReadUShort(); | |
$encodingID = $this->ReadUShort(); | |
$offset = $this->ReadULong(); | |
if($platformID==3 && $encodingID==1) | |
$offset31 = $offset; | |
} | |
if($offset31==0) | |
$this->Error('No Unicode encoding found'); | |
$startCount = array(); | |
$endCount = array(); | |
$idDelta = array(); | |
$idRangeOffset = array(); | |
$this->chars = array(); | |
fseek($this->f, $this->tables['cmap']['offset']+$offset31, SEEK_SET); | |
$format = $this->ReadUShort(); | |
if($format!=4) | |
$this->Error('Unexpected subtable format: '.$format); | |
$this->Skip(2*2); // length, language | |
$segCount = $this->ReadUShort()/2; | |
$this->Skip(3*2); // searchRange, entrySelector, rangeShift | |
for($i=0;$i<$segCount;$i++) | |
$endCount[$i] = $this->ReadUShort(); | |
$this->Skip(2); // reservedPad | |
for($i=0;$i<$segCount;$i++) | |
$startCount[$i] = $this->ReadUShort(); | |
for($i=0;$i<$segCount;$i++) | |
$idDelta[$i] = $this->ReadShort(); | |
$offset = ftell($this->f); | |
for($i=0;$i<$segCount;$i++) | |
$idRangeOffset[$i] = $this->ReadUShort(); | |
for($i=0;$i<$segCount;$i++) | |
{ | |
$c1 = $startCount[$i]; | |
$c2 = $endCount[$i]; | |
$d = $idDelta[$i]; | |
$ro = $idRangeOffset[$i]; | |
if($ro>0) | |
fseek($this->f, $offset+2*$i+$ro, SEEK_SET); | |
for($c=$c1;$c<=$c2;$c++) | |
{ | |
if($c==0xFFFF) | |
break; | |
if($ro>0) | |
{ | |
$gid = $this->ReadUShort(); | |
if($gid>0) | |
$gid += $d; | |
} | |
else | |
$gid = $c+$d; | |
if($gid>=65536) | |
$gid -= 65536; | |
if($gid>0) | |
$this->chars[$c] = $gid; | |
} | |
} | |
} | |
function ParseName() | |
{ | |
$this->Seek('name'); | |
$tableOffset = $this->tables['name']['offset']; | |
$this->postScriptName = ''; | |
$this->Skip(2); // format | |
$count = $this->ReadUShort(); | |
$stringOffset = $this->ReadUShort(); | |
for($i=0;$i<$count;$i++) | |
{ | |
$this->Skip(3*2); // platformID, encodingID, languageID | |
$nameID = $this->ReadUShort(); | |
$length = $this->ReadUShort(); | |
$offset = $this->ReadUShort(); | |
if($nameID==6) | |
{ | |
// PostScript name | |
fseek($this->f, $tableOffset+$stringOffset+$offset, SEEK_SET); | |
$s = $this->Read($length); | |
$s = str_replace(chr(0), '', $s); | |
$s = preg_replace('|[ \[\](){}<>/%]|', '', $s); | |
$this->postScriptName = $s; | |
break; | |
} | |
} | |
if($this->postScriptName=='') | |
$this->Error('PostScript name not found'); | |
} | |
function ParseOS2() | |
{ | |
$this->Seek('OS/2'); | |
$version = $this->ReadUShort(); | |
$this->Skip(3*2); // xAvgCharWidth, usWeightClass, usWidthClass | |
$fsType = $this->ReadUShort(); | |
$this->embeddable = ($fsType!=2) && ($fsType & 0x200)==0; | |
$this->Skip(11*2+10+4*4+4); | |
$fsSelection = $this->ReadUShort(); | |
$this->bold = ($fsSelection & 32)!=0; | |
$this->Skip(2*2); // usFirstCharIndex, usLastCharIndex | |
$this->typoAscender = $this->ReadShort(); | |
$this->typoDescender = $this->ReadShort(); | |
if($version>=2) | |
{ | |
$this->Skip(3*2+2*4+2); | |
$this->capHeight = $this->ReadShort(); | |
} | |
else | |
$this->capHeight = 0; | |
} | |
function ParsePost() | |
{ | |
$this->Seek('post'); | |
$version = $this->ReadULong(); | |
$this->italicAngle = $this->ReadShort(); | |
$this->Skip(2); // Skip decimal part | |
$this->underlinePosition = $this->ReadShort(); | |
$this->underlineThickness = $this->ReadShort(); | |
$this->isFixedPitch = ($this->ReadULong()!=0); | |
if($version==0x20000) | |
{ | |
// Extract glyph names | |
$this->Skip(4*4); // min/max usage | |
$this->Skip(2); // numberOfGlyphs | |
$glyphNameIndex = array(); | |
$names = array(); | |
$numNames = 0; | |
for($i=0;$i<$this->numGlyphs;$i++) | |
{ | |
$index = $this->ReadUShort(); | |
$glyphNameIndex[] = $index; | |
if($index>=258 && $index-257>$numNames) | |
$numNames = $index-257; | |
} | |
for($i=0;$i<$numNames;$i++) | |
{ | |
$len = ord($this->Read(1)); | |
$names[] = $this->Read($len); | |
} | |
foreach($glyphNameIndex as $i=>$index) | |
{ | |
if($index>=258) | |
$this->glyphs[$i]['name'] = $names[$index-258]; | |
else | |
$this->glyphs[$i]['name'] = $index; | |
} | |
$this->glyphNames = true; | |
} | |
else | |
$this->glyphNames = false; | |
} | |
function Subset($chars) | |
{ | |
$this->subsettedGlyphs = array(); | |
$this->AddGlyph(0); | |
$this->subsettedChars = array(); | |
foreach($chars as $char) | |
{ | |
if(isset($this->chars[$char])) | |
{ | |
$this->subsettedChars[] = $char; | |
$this->AddGlyph($this->chars[$char]); | |
} | |
} | |
} | |
function AddGlyph($id) | |
{ | |
if(!isset($this->glyphs[$id]['ssid'])) | |
{ | |
$this->glyphs[$id]['ssid'] = count($this->subsettedGlyphs); | |
$this->subsettedGlyphs[] = $id; | |
if(isset($this->glyphs[$id]['components'])) | |
{ | |
foreach($this->glyphs[$id]['components'] as $cid) | |
$this->AddGlyph($cid); | |
} | |
} | |
} | |
function Build() | |
{ | |
$this->BuildCmap(); | |
$this->BuildHhea(); | |
$this->BuildHmtx(); | |
$this->BuildLoca(); | |
$this->BuildGlyf(); | |
$this->BuildMaxp(); | |
$this->BuildPost(); | |
return $this->BuildFont(); | |
} | |
function BuildCmap() | |
{ | |
if(!isset($this->subsettedChars)) | |
return; | |
// Divide charset in contiguous segments | |
$chars = $this->subsettedChars; | |
sort($chars); | |
$segments = array(); | |
$segment = array($chars[0], $chars[0]); | |
for($i=1;$i<count($chars);$i++) | |
{ | |
if($chars[$i]>$segment[1]+1) | |
{ | |
$segments[] = $segment; | |
$segment = array($chars[$i], $chars[$i]); | |
} | |
else | |
$segment[1]++; | |
} | |
$segments[] = $segment; | |
$segments[] = array(0xFFFF, 0xFFFF); | |
$segCount = count($segments); | |
// Build a Format 4 subtable | |
$startCount = array(); | |
$endCount = array(); | |
$idDelta = array(); | |
$idRangeOffset = array(); | |
$glyphIdArray = ''; | |
for($i=0;$i<$segCount;$i++) | |
{ | |
list($start, $end) = $segments[$i]; | |
$startCount[] = $start; | |
$endCount[] = $end; | |
if($start!=$end) | |
{ | |
// Segment with multiple chars | |
$idDelta[] = 0; | |
$idRangeOffset[] = strlen($glyphIdArray) + ($segCount-$i)*2; | |
for($c=$start;$c<=$end;$c++) | |
{ | |
$ssid = $this->glyphs[$this->chars[$c]]['ssid']; | |
$glyphIdArray .= pack('n', $ssid); | |
} | |
} | |
else | |
{ | |
// Segment with a single char | |
if($start<0xFFFF) | |
$ssid = $this->glyphs[$this->chars[$start]]['ssid']; | |
else | |
$ssid = 0; | |
$idDelta[] = $ssid - $start; | |
$idRangeOffset[] = 0; | |
} | |
} | |
$entrySelector = 0; | |
$n = $segCount; | |
while($n!=1) | |
{ | |
$n = $n>>1; | |
$entrySelector++; | |
} | |
$searchRange = (1<<$entrySelector)*2; | |
$rangeShift = 2*$segCount - $searchRange; | |
$cmap = pack('nnnn', 2*$segCount, $searchRange, $entrySelector, $rangeShift); | |
foreach($endCount as $val) | |
$cmap .= pack('n', $val); | |
$cmap .= pack('n', 0); // reservedPad | |
foreach($startCount as $val) | |
$cmap .= pack('n', $val); | |
foreach($idDelta as $val) | |
$cmap .= pack('n', $val); | |
foreach($idRangeOffset as $val) | |
$cmap .= pack('n', $val); | |
$cmap .= $glyphIdArray; | |
$data = pack('nn', 0, 1); // version, numTables | |
$data .= pack('nnN', 3, 1, 12); // platformID, encodingID, offset | |
$data .= pack('nnn', 4, 6+strlen($cmap), 0); // format, length, language | |
$data .= $cmap; | |
$this->SetTable('cmap', $data); | |
} | |
function BuildHhea() | |
{ | |
$this->LoadTable('hhea'); | |
$numberOfHMetrics = count($this->subsettedGlyphs); | |
$data = substr_replace($this->tables['hhea']['data'], pack('n',$numberOfHMetrics), 4+15*2, 2); | |
$this->SetTable('hhea', $data); | |
} | |
function BuildHmtx() | |
{ | |
$data = ''; | |
foreach($this->subsettedGlyphs as $id) | |
{ | |
$glyph = $this->glyphs[$id]; | |
$data .= pack('nn', $glyph['w'], $glyph['lsb']); | |
} | |
$this->SetTable('hmtx', $data); | |
} | |
function BuildLoca() | |
{ | |
$data = ''; | |
$offset = 0; | |
foreach($this->subsettedGlyphs as $id) | |
{ | |
if($this->indexToLocFormat==0) | |
$data .= pack('n', $offset/2); | |
else | |
$data .= pack('N', $offset); | |
$offset += $this->glyphs[$id]['length']; | |
} | |
if($this->indexToLocFormat==0) | |
$data .= pack('n', $offset/2); | |
else | |
$data .= pack('N', $offset); | |
$this->SetTable('loca', $data); | |
} | |
function BuildGlyf() | |
{ | |
$tableOffset = $this->tables['glyf']['offset']; | |
$data = ''; | |
foreach($this->subsettedGlyphs as $id) | |
{ | |
$glyph = $this->glyphs[$id]; | |
fseek($this->f, $tableOffset+$glyph['offset'], SEEK_SET); | |
$glyph_data = $this->Read($glyph['length']); | |
if(isset($glyph['components'])) | |
{ | |
// Composite glyph | |
foreach($glyph['components'] as $offset=>$cid) | |
{ | |
$ssid = $this->glyphs[$cid]['ssid']; | |
$glyph_data = substr_replace($glyph_data, pack('n',$ssid), $offset, 2); | |
} | |
} | |
$data .= $glyph_data; | |
} | |
$this->SetTable('glyf', $data); | |
} | |
function BuildMaxp() | |
{ | |
$this->LoadTable('maxp'); | |
$numGlyphs = count($this->subsettedGlyphs); | |
$data = substr_replace($this->tables['maxp']['data'], pack('n',$numGlyphs), 4, 2); | |
$this->SetTable('maxp', $data); | |
} | |
function BuildPost() | |
{ | |
$this->Seek('post'); | |
if($this->glyphNames) | |
{ | |
// Version 2.0 | |
$numberOfGlyphs = count($this->subsettedGlyphs); | |
$numNames = 0; | |
$names = ''; | |
$data = $this->Read(2*4+2*2+5*4); | |
$data .= pack('n', $numberOfGlyphs); | |
foreach($this->subsettedGlyphs as $id) | |
{ | |
$name = $this->glyphs[$id]['name']; | |
if(is_string($name)) | |
{ | |
$data .= pack('n', 258+$numNames); | |
$names .= chr(strlen($name)).$name; | |
$numNames++; | |
} | |
else | |
$data .= pack('n', $name); | |
} | |
$data .= $names; | |
} | |
else | |
{ | |
// Version 3.0 | |
$this->Skip(4); | |
$data = "\x00\x03\x00\x00"; | |
$data .= $this->Read(4+2*2+5*4); | |
} | |
$this->SetTable('post', $data); | |
} | |
function BuildFont() | |
{ | |
$tags = array(); | |
foreach(array('cmap', 'cvt ', 'fpgm', 'glyf', 'head', 'hhea', 'hmtx', 'loca', 'maxp', 'name', 'post', 'prep') as $tag) | |
{ | |
if(isset($this->tables[$tag])) | |
$tags[] = $tag; | |
} | |
$numTables = count($tags); | |
$offset = 12 + 16*$numTables; | |
foreach($tags as $tag) | |
{ | |
if(!isset($this->tables[$tag]['data'])) | |
$this->LoadTable($tag); | |
$this->tables[$tag]['offset'] = $offset; | |
$offset += strlen($this->tables[$tag]['data']); | |
} | |
// Build offset table | |
$entrySelector = 0; | |
$n = $numTables; | |
while($n!=1) | |
{ | |
$n = $n>>1; | |
$entrySelector++; | |
} | |
$searchRange = 16*(1<<$entrySelector); | |
$rangeShift = 16*$numTables - $searchRange; | |
$offsetTable = pack('nnnnnn', 1, 0, $numTables, $searchRange, $entrySelector, $rangeShift); | |
foreach($tags as $tag) | |
{ | |
$table = $this->tables[$tag]; | |
$offsetTable .= $tag.$table['checkSum'].pack('NN', $table['offset'], $table['length']); | |
} | |
// Compute checkSumAdjustment (0xB1B0AFBA - font checkSum) | |
$s = $this->CheckSum($offsetTable); | |
foreach($tags as $tag) | |
$s .= $this->tables[$tag]['checkSum']; | |
$a = unpack('n2', $this->CheckSum($s)); | |
$high = 0xB1B0 + ($a[1]^0xFFFF); | |
$low = 0xAFBA + ($a[2]^0xFFFF) + 1; | |
$checkSumAdjustment = pack('nn', $high+($low>>16), $low); | |
$this->tables['head']['data'] = substr_replace($this->tables['head']['data'], $checkSumAdjustment, 8, 4); | |
$font = $offsetTable; | |
foreach($tags as $tag) | |
$font .= $this->tables[$tag]['data']; | |
return $font; | |
} | |
function LoadTable($tag) | |
{ | |
$this->Seek($tag); | |
$length = $this->tables[$tag]['length']; | |
$n = $length % 4; | |
if($n>0) | |
$length += 4 - $n; | |
$this->tables[$tag]['data'] = $this->Read($length); | |
} | |
function SetTable($tag, $data) | |
{ | |
$length = strlen($data); | |
$n = $length % 4; | |
if($n>0) | |
$data = str_pad($data, $length+4-$n, "\x00"); | |
$this->tables[$tag]['data'] = $data; | |
$this->tables[$tag]['length'] = $length; | |
$this->tables[$tag]['checkSum'] = $this->CheckSum($data); | |
} | |
function Seek($tag) | |
{ | |
if(!isset($this->tables[$tag])) | |
$this->Error('Table not found: '.$tag); | |
fseek($this->f, $this->tables[$tag]['offset'], SEEK_SET); | |
} | |
function Skip($n) | |
{ | |
fseek($this->f, $n, SEEK_CUR); | |
} | |
function Read($n) | |
{ | |
return $n>0 ? fread($this->f, $n) : ''; | |
} | |
function ReadUShort() | |
{ | |
$a = unpack('nn', fread($this->f,2)); | |
return $a['n']; | |
} | |
function ReadShort() | |
{ | |
$a = unpack('nn', fread($this->f,2)); | |
$v = $a['n']; | |
if($v>=0x8000) | |
$v -= 65536; | |
return $v; | |
} | |
function ReadULong() | |
{ | |
$a = unpack('NN', fread($this->f,4)); | |
return $a['N']; | |
} | |
function CheckSum($s) | |
{ | |
$n = strlen($s); | |
$high = 0; | |
$low = 0; | |
for($i=0;$i<$n;$i+=4) | |
{ | |
$high += (ord($s[$i])<<8) + ord($s[$i+1]); | |
$low += (ord($s[$i+2])<<8) + ord($s[$i+3]); | |
} | |
return pack('nn', $high+($low>>16), $low); | |
} | |
function Error($msg) | |
{ | |
throw new Exception($msg); | |
} | |
} | |
?> |