blob: b5acf29b14f57bca4330725c7c6b649f08fde5d9 [file] [log] [blame]
<?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);
}
}
?>