<?php
/*******************************************************************************
 * Utility to parse TTF font files                                              *
 *                                                                              *
 * Version: 1.0                                                                 *
 * Date:    2011-06-18                                                          *
 * Author:  Olivier PLATHEY                                                     *
 *******************************************************************************/

class TTFParser
{
    public $f;
    public $tables;
    public $unitsPerEm;
    public $xMin;
    public $yMin;
    public $xMax;
    public $yMax;
    public $numberOfHMetrics;
    public $numGlyphs;
    public $widths;
    public $chars;
    public $postScriptName;
    public $Embeddable;
    public $Bold;
    public $typoAscender;
    public $typoDescender;
    public $capHeight;
    public $italicAngle;
    public $underlinePosition;
    public $underlineThickness;
    public $isFixedPitch;

    public function Parse($file)
    {
        $this->f = fopen($file, 'rb');
        if (!$this->f) {
            $this->Error('Can\'t open file: ' . $file);
        }

        $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 = [];
        for ($i = 0; $i < $numTables; $i++) {
            $tag = $this->Read(4);
            $this->Skip(4); // checkSum
            $offset = $this->ReadULong();
            $this->Skip(4); // length
            $this->tables[$tag] = $offset;
        }

        $this->ParseHead();
        $this->ParseHhea();
        $this->ParseMaxp();
        $this->ParseHmtx();
        $this->ParseCmap();
        $this->ParseName();
        $this->ParseOS2();
        $this->ParsePost();

        fclose($this->f);
    }

    public 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();
    }

    public function ParseHhea()
    {
        $this->Seek('hhea');
        $this->Skip(4 + 15 * 2);
        $this->numberOfHMetrics = $this->ReadUShort();
    }

    public function ParseMaxp()
    {
        $this->Seek('maxp');
        $this->Skip(4);
        $this->numGlyphs = $this->ReadUShort();
    }

    public function ParseHmtx()
    {
        $this->Seek('hmtx');
        $this->widths = [];
        for ($i = 0; $i < $this->numberOfHMetrics; $i++) {
            $advanceWidth = $this->ReadUShort();
            $this->Skip(2); // lsb
            $this->widths[$i] = $advanceWidth;
        }
        if ($this->numberOfHMetrics < $this->numGlyphs) {
            $lastWidth = $this->widths[$this->numberOfHMetrics - 1];
            $this->widths = array_pad($this->widths, $this->numGlyphs, $lastWidth);
        }
    }

    public 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 = [];
        $endCount = [];
        $idDelta = [];
        $idRangeOffset = [];
        $this->chars = [];
        fseek($this->f, $this->tables['cmap'] + $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;
                }
            }
        }
    }

    public function ParseName()
    {
        $this->Seek('name');
        $tableOffset = ftell($this->f);
        $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');
        }
    }

    public 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;
        }
    }

    public function ParsePost()
    {
        $this->Seek('post');
        $this->Skip(4); // version
        $this->italicAngle = $this->ReadShort();
        $this->Skip(2); // Skip decimal part
        $this->underlinePosition = $this->ReadShort();
        $this->underlineThickness = $this->ReadShort();
        $this->isFixedPitch = $this->ReadULong() != 0;
    }

    public function Error($msg)
    {
        if (PHP_SAPI == 'cli') {
            die("Error: $msg\n");
        } else {
            die("<b>Error</b>: $msg");
        }
    }

    public function Seek($tag)
    {
        if (!isset($this->tables[$tag])) {
            $this->Error('Table not found: ' . $tag);
        }
        fseek($this->f, $this->tables[$tag], SEEK_SET);
    }

    public function Skip($n)
    {
        fseek($this->f, $n, SEEK_CUR);
    }

    public function Read($n)
    {
        return fread($this->f, $n);
    }

    public function ReadUShort()
    {
        $a = unpack('nn', fread($this->f, 2));
        return $a['n'];
    }

    public function ReadShort()
    {
        $a = unpack('nn', fread($this->f, 2));
        $v = $a['n'];
        if ($v >= 0x8000) {
            $v -= 65536;
        }
        return $v;
    }

    public function ReadULong()
    {
        $a = unpack('NN', fread($this->f, 4));
        return $a['N'];
    }
}
