blob: b5acf29b14f57bca4330725c7c6b649f08fde5d9 [file] [log] [blame]
Copybara botbe50d492023-11-30 00:16:42 +01001<?php
2/*******************************************************************************
3* Class to parse and subset TrueType fonts *
4* *
5* Version: 1.11 *
6* Date: 2021-04-18 *
7* Author: Olivier PLATHEY *
8*******************************************************************************/
9
10class TTFParser
11{
12 protected $f;
13 protected $tables;
14 protected $numberOfHMetrics;
15 protected $numGlyphs;
16 protected $glyphNames;
17 protected $indexToLocFormat;
18 protected $subsettedChars;
19 protected $subsettedGlyphs;
20 public $chars;
21 public $glyphs;
22 public $unitsPerEm;
23 public $xMin, $yMin, $xMax, $yMax;
24 public $postScriptName;
25 public $embeddable;
26 public $bold;
27 public $typoAscender;
28 public $typoDescender;
29 public $capHeight;
30 public $italicAngle;
31 public $underlinePosition;
32 public $underlineThickness;
33 public $isFixedPitch;
34
35 function __construct($file)
36 {
37 $this->f = fopen($file, 'rb');
38 if(!$this->f)
39 $this->Error('Can\'t open file: '.$file);
40 }
41
42 function __destruct()
43 {
44 if(is_resource($this->f))
45 fclose($this->f);
46 }
47
48 function Parse()
49 {
50 $this->ParseOffsetTable();
51 $this->ParseHead();
52 $this->ParseHhea();
53 $this->ParseMaxp();
54 $this->ParseHmtx();
55 $this->ParseLoca();
56 $this->ParseGlyf();
57 $this->ParseCmap();
58 $this->ParseName();
59 $this->ParseOS2();
60 $this->ParsePost();
61 }
62
63 function ParseOffsetTable()
64 {
65 $version = $this->Read(4);
66 if($version=='OTTO')
67 $this->Error('OpenType fonts based on PostScript outlines are not supported');
68 if($version!="\x00\x01\x00\x00")
69 $this->Error('Unrecognized file format');
70 $numTables = $this->ReadUShort();
71 $this->Skip(3*2); // searchRange, entrySelector, rangeShift
72 $this->tables = array();
73 for($i=0;$i<$numTables;$i++)
74 {
75 $tag = $this->Read(4);
76 $checkSum = $this->Read(4);
77 $offset = $this->ReadULong();
78 $length = $this->ReadULong();
79 $this->tables[$tag] = array('offset'=>$offset, 'length'=>$length, 'checkSum'=>$checkSum);
80 }
81 }
82
83 function ParseHead()
84 {
85 $this->Seek('head');
86 $this->Skip(3*4); // version, fontRevision, checkSumAdjustment
87 $magicNumber = $this->ReadULong();
88 if($magicNumber!=0x5F0F3CF5)
89 $this->Error('Incorrect magic number');
90 $this->Skip(2); // flags
91 $this->unitsPerEm = $this->ReadUShort();
92 $this->Skip(2*8); // created, modified
93 $this->xMin = $this->ReadShort();
94 $this->yMin = $this->ReadShort();
95 $this->xMax = $this->ReadShort();
96 $this->yMax = $this->ReadShort();
97 $this->Skip(3*2); // macStyle, lowestRecPPEM, fontDirectionHint
98 $this->indexToLocFormat = $this->ReadShort();
99 }
100
101 function ParseHhea()
102 {
103 $this->Seek('hhea');
104 $this->Skip(4+15*2);
105 $this->numberOfHMetrics = $this->ReadUShort();
106 }
107
108 function ParseMaxp()
109 {
110 $this->Seek('maxp');
111 $this->Skip(4);
112 $this->numGlyphs = $this->ReadUShort();
113 }
114
115 function ParseHmtx()
116 {
117 $this->Seek('hmtx');
118 $this->glyphs = array();
119 for($i=0;$i<$this->numberOfHMetrics;$i++)
120 {
121 $advanceWidth = $this->ReadUShort();
122 $lsb = $this->ReadShort();
123 $this->glyphs[$i] = array('w'=>$advanceWidth, 'lsb'=>$lsb);
124 }
125 for($i=$this->numberOfHMetrics;$i<$this->numGlyphs;$i++)
126 {
127 $lsb = $this->ReadShort();
128 $this->glyphs[$i] = array('w'=>$advanceWidth, 'lsb'=>$lsb);
129 }
130 }
131
132 function ParseLoca()
133 {
134 $this->Seek('loca');
135 $offsets = array();
136 if($this->indexToLocFormat==0)
137 {
138 // Short format
139 for($i=0;$i<=$this->numGlyphs;$i++)
140 $offsets[] = 2*$this->ReadUShort();
141 }
142 else
143 {
144 // Long format
145 for($i=0;$i<=$this->numGlyphs;$i++)
146 $offsets[] = $this->ReadULong();
147 }
148 for($i=0;$i<$this->numGlyphs;$i++)
149 {
150 $this->glyphs[$i]['offset'] = $offsets[$i];
151 $this->glyphs[$i]['length'] = $offsets[$i+1] - $offsets[$i];
152 }
153 }
154
155 function ParseGlyf()
156 {
157 $tableOffset = $this->tables['glyf']['offset'];
158 foreach($this->glyphs as &$glyph)
159 {
160 if($glyph['length']>0)
161 {
162 fseek($this->f, $tableOffset+$glyph['offset'], SEEK_SET);
163 if($this->ReadShort()<0)
164 {
165 // Composite glyph
166 $this->Skip(4*2); // xMin, yMin, xMax, yMax
167 $offset = 5*2;
168 $a = array();
169 do
170 {
171 $flags = $this->ReadUShort();
172 $index = $this->ReadUShort();
173 $a[$offset+2] = $index;
174 if($flags & 1) // ARG_1_AND_2_ARE_WORDS
175 $skip = 2*2;
176 else
177 $skip = 2;
178 if($flags & 8) // WE_HAVE_A_SCALE
179 $skip += 2;
180 elseif($flags & 64) // WE_HAVE_AN_X_AND_Y_SCALE
181 $skip += 2*2;
182 elseif($flags & 128) // WE_HAVE_A_TWO_BY_TWO
183 $skip += 4*2;
184 $this->Skip($skip);
185 $offset += 2*2 + $skip;
186 }
187 while($flags & 32); // MORE_COMPONENTS
188 $glyph['components'] = $a;
189 }
190 }
191 }
192 }
193
194 function ParseCmap()
195 {
196 $this->Seek('cmap');
197 $this->Skip(2); // version
198 $numTables = $this->ReadUShort();
199 $offset31 = 0;
200 for($i=0;$i<$numTables;$i++)
201 {
202 $platformID = $this->ReadUShort();
203 $encodingID = $this->ReadUShort();
204 $offset = $this->ReadULong();
205 if($platformID==3 && $encodingID==1)
206 $offset31 = $offset;
207 }
208 if($offset31==0)
209 $this->Error('No Unicode encoding found');
210
211 $startCount = array();
212 $endCount = array();
213 $idDelta = array();
214 $idRangeOffset = array();
215 $this->chars = array();
216 fseek($this->f, $this->tables['cmap']['offset']+$offset31, SEEK_SET);
217 $format = $this->ReadUShort();
218 if($format!=4)
219 $this->Error('Unexpected subtable format: '.$format);
220 $this->Skip(2*2); // length, language
221 $segCount = $this->ReadUShort()/2;
222 $this->Skip(3*2); // searchRange, entrySelector, rangeShift
223 for($i=0;$i<$segCount;$i++)
224 $endCount[$i] = $this->ReadUShort();
225 $this->Skip(2); // reservedPad
226 for($i=0;$i<$segCount;$i++)
227 $startCount[$i] = $this->ReadUShort();
228 for($i=0;$i<$segCount;$i++)
229 $idDelta[$i] = $this->ReadShort();
230 $offset = ftell($this->f);
231 for($i=0;$i<$segCount;$i++)
232 $idRangeOffset[$i] = $this->ReadUShort();
233
234 for($i=0;$i<$segCount;$i++)
235 {
236 $c1 = $startCount[$i];
237 $c2 = $endCount[$i];
238 $d = $idDelta[$i];
239 $ro = $idRangeOffset[$i];
240 if($ro>0)
241 fseek($this->f, $offset+2*$i+$ro, SEEK_SET);
242 for($c=$c1;$c<=$c2;$c++)
243 {
244 if($c==0xFFFF)
245 break;
246 if($ro>0)
247 {
248 $gid = $this->ReadUShort();
249 if($gid>0)
250 $gid += $d;
251 }
252 else
253 $gid = $c+$d;
254 if($gid>=65536)
255 $gid -= 65536;
256 if($gid>0)
257 $this->chars[$c] = $gid;
258 }
259 }
260 }
261
262 function ParseName()
263 {
264 $this->Seek('name');
265 $tableOffset = $this->tables['name']['offset'];
266 $this->postScriptName = '';
267 $this->Skip(2); // format
268 $count = $this->ReadUShort();
269 $stringOffset = $this->ReadUShort();
270 for($i=0;$i<$count;$i++)
271 {
272 $this->Skip(3*2); // platformID, encodingID, languageID
273 $nameID = $this->ReadUShort();
274 $length = $this->ReadUShort();
275 $offset = $this->ReadUShort();
276 if($nameID==6)
277 {
278 // PostScript name
279 fseek($this->f, $tableOffset+$stringOffset+$offset, SEEK_SET);
280 $s = $this->Read($length);
281 $s = str_replace(chr(0), '', $s);
282 $s = preg_replace('|[ \[\](){}<>/%]|', '', $s);
283 $this->postScriptName = $s;
284 break;
285 }
286 }
287 if($this->postScriptName=='')
288 $this->Error('PostScript name not found');
289 }
290
291 function ParseOS2()
292 {
293 $this->Seek('OS/2');
294 $version = $this->ReadUShort();
295 $this->Skip(3*2); // xAvgCharWidth, usWeightClass, usWidthClass
296 $fsType = $this->ReadUShort();
297 $this->embeddable = ($fsType!=2) && ($fsType & 0x200)==0;
298 $this->Skip(11*2+10+4*4+4);
299 $fsSelection = $this->ReadUShort();
300 $this->bold = ($fsSelection & 32)!=0;
301 $this->Skip(2*2); // usFirstCharIndex, usLastCharIndex
302 $this->typoAscender = $this->ReadShort();
303 $this->typoDescender = $this->ReadShort();
304 if($version>=2)
305 {
306 $this->Skip(3*2+2*4+2);
307 $this->capHeight = $this->ReadShort();
308 }
309 else
310 $this->capHeight = 0;
311 }
312
313 function ParsePost()
314 {
315 $this->Seek('post');
316 $version = $this->ReadULong();
317 $this->italicAngle = $this->ReadShort();
318 $this->Skip(2); // Skip decimal part
319 $this->underlinePosition = $this->ReadShort();
320 $this->underlineThickness = $this->ReadShort();
321 $this->isFixedPitch = ($this->ReadULong()!=0);
322 if($version==0x20000)
323 {
324 // Extract glyph names
325 $this->Skip(4*4); // min/max usage
326 $this->Skip(2); // numberOfGlyphs
327 $glyphNameIndex = array();
328 $names = array();
329 $numNames = 0;
330 for($i=0;$i<$this->numGlyphs;$i++)
331 {
332 $index = $this->ReadUShort();
333 $glyphNameIndex[] = $index;
334 if($index>=258 && $index-257>$numNames)
335 $numNames = $index-257;
336 }
337 for($i=0;$i<$numNames;$i++)
338 {
339 $len = ord($this->Read(1));
340 $names[] = $this->Read($len);
341 }
342 foreach($glyphNameIndex as $i=>$index)
343 {
344 if($index>=258)
345 $this->glyphs[$i]['name'] = $names[$index-258];
346 else
347 $this->glyphs[$i]['name'] = $index;
348 }
349 $this->glyphNames = true;
350 }
351 else
352 $this->glyphNames = false;
353 }
354
355 function Subset($chars)
356 {
357 $this->subsettedGlyphs = array();
358 $this->AddGlyph(0);
359 $this->subsettedChars = array();
360 foreach($chars as $char)
361 {
362 if(isset($this->chars[$char]))
363 {
364 $this->subsettedChars[] = $char;
365 $this->AddGlyph($this->chars[$char]);
366 }
367 }
368 }
369
370 function AddGlyph($id)
371 {
372 if(!isset($this->glyphs[$id]['ssid']))
373 {
374 $this->glyphs[$id]['ssid'] = count($this->subsettedGlyphs);
375 $this->subsettedGlyphs[] = $id;
376 if(isset($this->glyphs[$id]['components']))
377 {
378 foreach($this->glyphs[$id]['components'] as $cid)
379 $this->AddGlyph($cid);
380 }
381 }
382 }
383
384 function Build()
385 {
386 $this->BuildCmap();
387 $this->BuildHhea();
388 $this->BuildHmtx();
389 $this->BuildLoca();
390 $this->BuildGlyf();
391 $this->BuildMaxp();
392 $this->BuildPost();
393 return $this->BuildFont();
394 }
395
396 function BuildCmap()
397 {
398 if(!isset($this->subsettedChars))
399 return;
400
401 // Divide charset in contiguous segments
402 $chars = $this->subsettedChars;
403 sort($chars);
404 $segments = array();
405 $segment = array($chars[0], $chars[0]);
406 for($i=1;$i<count($chars);$i++)
407 {
408 if($chars[$i]>$segment[1]+1)
409 {
410 $segments[] = $segment;
411 $segment = array($chars[$i], $chars[$i]);
412 }
413 else
414 $segment[1]++;
415 }
416 $segments[] = $segment;
417 $segments[] = array(0xFFFF, 0xFFFF);
418 $segCount = count($segments);
419
420 // Build a Format 4 subtable
421 $startCount = array();
422 $endCount = array();
423 $idDelta = array();
424 $idRangeOffset = array();
425 $glyphIdArray = '';
426 for($i=0;$i<$segCount;$i++)
427 {
428 list($start, $end) = $segments[$i];
429 $startCount[] = $start;
430 $endCount[] = $end;
431 if($start!=$end)
432 {
433 // Segment with multiple chars
434 $idDelta[] = 0;
435 $idRangeOffset[] = strlen($glyphIdArray) + ($segCount-$i)*2;
436 for($c=$start;$c<=$end;$c++)
437 {
438 $ssid = $this->glyphs[$this->chars[$c]]['ssid'];
439 $glyphIdArray .= pack('n', $ssid);
440 }
441 }
442 else
443 {
444 // Segment with a single char
445 if($start<0xFFFF)
446 $ssid = $this->glyphs[$this->chars[$start]]['ssid'];
447 else
448 $ssid = 0;
449 $idDelta[] = $ssid - $start;
450 $idRangeOffset[] = 0;
451 }
452 }
453 $entrySelector = 0;
454 $n = $segCount;
455 while($n!=1)
456 {
457 $n = $n>>1;
458 $entrySelector++;
459 }
460 $searchRange = (1<<$entrySelector)*2;
461 $rangeShift = 2*$segCount - $searchRange;
462 $cmap = pack('nnnn', 2*$segCount, $searchRange, $entrySelector, $rangeShift);
463 foreach($endCount as $val)
464 $cmap .= pack('n', $val);
465 $cmap .= pack('n', 0); // reservedPad
466 foreach($startCount as $val)
467 $cmap .= pack('n', $val);
468 foreach($idDelta as $val)
469 $cmap .= pack('n', $val);
470 foreach($idRangeOffset as $val)
471 $cmap .= pack('n', $val);
472 $cmap .= $glyphIdArray;
473
474 $data = pack('nn', 0, 1); // version, numTables
475 $data .= pack('nnN', 3, 1, 12); // platformID, encodingID, offset
476 $data .= pack('nnn', 4, 6+strlen($cmap), 0); // format, length, language
477 $data .= $cmap;
478 $this->SetTable('cmap', $data);
479 }
480
481 function BuildHhea()
482 {
483 $this->LoadTable('hhea');
484 $numberOfHMetrics = count($this->subsettedGlyphs);
485 $data = substr_replace($this->tables['hhea']['data'], pack('n',$numberOfHMetrics), 4+15*2, 2);
486 $this->SetTable('hhea', $data);
487 }
488
489 function BuildHmtx()
490 {
491 $data = '';
492 foreach($this->subsettedGlyphs as $id)
493 {
494 $glyph = $this->glyphs[$id];
495 $data .= pack('nn', $glyph['w'], $glyph['lsb']);
496 }
497 $this->SetTable('hmtx', $data);
498 }
499
500 function BuildLoca()
501 {
502 $data = '';
503 $offset = 0;
504 foreach($this->subsettedGlyphs as $id)
505 {
506 if($this->indexToLocFormat==0)
507 $data .= pack('n', $offset/2);
508 else
509 $data .= pack('N', $offset);
510 $offset += $this->glyphs[$id]['length'];
511 }
512 if($this->indexToLocFormat==0)
513 $data .= pack('n', $offset/2);
514 else
515 $data .= pack('N', $offset);
516 $this->SetTable('loca', $data);
517 }
518
519 function BuildGlyf()
520 {
521 $tableOffset = $this->tables['glyf']['offset'];
522 $data = '';
523 foreach($this->subsettedGlyphs as $id)
524 {
525 $glyph = $this->glyphs[$id];
526 fseek($this->f, $tableOffset+$glyph['offset'], SEEK_SET);
527 $glyph_data = $this->Read($glyph['length']);
528 if(isset($glyph['components']))
529 {
530 // Composite glyph
531 foreach($glyph['components'] as $offset=>$cid)
532 {
533 $ssid = $this->glyphs[$cid]['ssid'];
534 $glyph_data = substr_replace($glyph_data, pack('n',$ssid), $offset, 2);
535 }
536 }
537 $data .= $glyph_data;
538 }
539 $this->SetTable('glyf', $data);
540 }
541
542 function BuildMaxp()
543 {
544 $this->LoadTable('maxp');
545 $numGlyphs = count($this->subsettedGlyphs);
546 $data = substr_replace($this->tables['maxp']['data'], pack('n',$numGlyphs), 4, 2);
547 $this->SetTable('maxp', $data);
548 }
549
550 function BuildPost()
551 {
552 $this->Seek('post');
553 if($this->glyphNames)
554 {
555 // Version 2.0
556 $numberOfGlyphs = count($this->subsettedGlyphs);
557 $numNames = 0;
558 $names = '';
559 $data = $this->Read(2*4+2*2+5*4);
560 $data .= pack('n', $numberOfGlyphs);
561 foreach($this->subsettedGlyphs as $id)
562 {
563 $name = $this->glyphs[$id]['name'];
564 if(is_string($name))
565 {
566 $data .= pack('n', 258+$numNames);
567 $names .= chr(strlen($name)).$name;
568 $numNames++;
569 }
570 else
571 $data .= pack('n', $name);
572 }
573 $data .= $names;
574 }
575 else
576 {
577 // Version 3.0
578 $this->Skip(4);
579 $data = "\x00\x03\x00\x00";
580 $data .= $this->Read(4+2*2+5*4);
581 }
582 $this->SetTable('post', $data);
583 }
584
585 function BuildFont()
586 {
587 $tags = array();
588 foreach(array('cmap', 'cvt ', 'fpgm', 'glyf', 'head', 'hhea', 'hmtx', 'loca', 'maxp', 'name', 'post', 'prep') as $tag)
589 {
590 if(isset($this->tables[$tag]))
591 $tags[] = $tag;
592 }
593 $numTables = count($tags);
594 $offset = 12 + 16*$numTables;
595 foreach($tags as $tag)
596 {
597 if(!isset($this->tables[$tag]['data']))
598 $this->LoadTable($tag);
599 $this->tables[$tag]['offset'] = $offset;
600 $offset += strlen($this->tables[$tag]['data']);
601 }
602
603 // Build offset table
604 $entrySelector = 0;
605 $n = $numTables;
606 while($n!=1)
607 {
608 $n = $n>>1;
609 $entrySelector++;
610 }
611 $searchRange = 16*(1<<$entrySelector);
612 $rangeShift = 16*$numTables - $searchRange;
613 $offsetTable = pack('nnnnnn', 1, 0, $numTables, $searchRange, $entrySelector, $rangeShift);
614 foreach($tags as $tag)
615 {
616 $table = $this->tables[$tag];
617 $offsetTable .= $tag.$table['checkSum'].pack('NN', $table['offset'], $table['length']);
618 }
619
620 // Compute checkSumAdjustment (0xB1B0AFBA - font checkSum)
621 $s = $this->CheckSum($offsetTable);
622 foreach($tags as $tag)
623 $s .= $this->tables[$tag]['checkSum'];
624 $a = unpack('n2', $this->CheckSum($s));
625 $high = 0xB1B0 + ($a[1]^0xFFFF);
626 $low = 0xAFBA + ($a[2]^0xFFFF) + 1;
627 $checkSumAdjustment = pack('nn', $high+($low>>16), $low);
628 $this->tables['head']['data'] = substr_replace($this->tables['head']['data'], $checkSumAdjustment, 8, 4);
629
630 $font = $offsetTable;
631 foreach($tags as $tag)
632 $font .= $this->tables[$tag]['data'];
633
634 return $font;
635 }
636
637 function LoadTable($tag)
638 {
639 $this->Seek($tag);
640 $length = $this->tables[$tag]['length'];
641 $n = $length % 4;
642 if($n>0)
643 $length += 4 - $n;
644 $this->tables[$tag]['data'] = $this->Read($length);
645 }
646
647 function SetTable($tag, $data)
648 {
649 $length = strlen($data);
650 $n = $length % 4;
651 if($n>0)
652 $data = str_pad($data, $length+4-$n, "\x00");
653 $this->tables[$tag]['data'] = $data;
654 $this->tables[$tag]['length'] = $length;
655 $this->tables[$tag]['checkSum'] = $this->CheckSum($data);
656 }
657
658 function Seek($tag)
659 {
660 if(!isset($this->tables[$tag]))
661 $this->Error('Table not found: '.$tag);
662 fseek($this->f, $this->tables[$tag]['offset'], SEEK_SET);
663 }
664
665 function Skip($n)
666 {
667 fseek($this->f, $n, SEEK_CUR);
668 }
669
670 function Read($n)
671 {
672 return $n>0 ? fread($this->f, $n) : '';
673 }
674
675 function ReadUShort()
676 {
677 $a = unpack('nn', fread($this->f,2));
678 return $a['n'];
679 }
680
681 function ReadShort()
682 {
683 $a = unpack('nn', fread($this->f,2));
684 $v = $a['n'];
685 if($v>=0x8000)
686 $v -= 65536;
687 return $v;
688 }
689
690 function ReadULong()
691 {
692 $a = unpack('NN', fread($this->f,4));
693 return $a['N'];
694 }
695
696 function CheckSum($s)
697 {
698 $n = strlen($s);
699 $high = 0;
700 $low = 0;
701 for($i=0;$i<$n;$i+=4)
702 {
703 $high += (ord($s[$i])<<8) + ord($s[$i+1]);
704 $low += (ord($s[$i+2])<<8) + ord($s[$i+3]);
705 }
706 return pack('nn', $high+($low>>16), $low);
707 }
708
709 function Error($msg)
710 {
711 throw new Exception($msg);
712 }
713}
714?>