PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / trunk
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant vtrunk
2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
easyfonts / src / Parser / FontMetricsReader.php

FontMetricsReader.php in EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant trunk, at src/Parser/FontMetricsReader.php

267 lines 6.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Font metrics reader.
4 *
5 * Parses the head / hhea / OS-2 tables out of a real font binary so we can
6 * compute size-adjust + *-override values for a metric-matched fallback face
7 * (the technique that drives layout shift to ~0 during font swap).
8 *
9 * TTF/OTF (sfnt) parse directly. WOFF is zlib-per-table, decompressed inline.
10 * WOFF2 is Brotli and cannot be decoded in stock PHP, so we return null and let
11 * the caller fetch a TTF copy purely for metric extraction.
12 *
13 * @package EasyFonts
14 */
15
16 namespace EasyFonts\Parser;
17
18 defined( 'ABSPATH' ) || exit;
19
20 /**
21 * Reads vertical + horizontal metrics from font binaries.
22 */
23 class FontMetricsReader {
24
25 /**
26 * Detect the container and extract metrics.
27 *
28 * @param string $bytes Raw font bytes.
29 * @return array<string,float|int>|null
30 */
31 public function read( string $bytes ): ?array {
32 if ( strlen( $bytes ) < 12 ) {
33 return null;
34 }
35
36 $sig = substr( $bytes, 0, 4 );
37
38 if ( 'wOF2' === $sig ) {
39 return null; // Brotli — not decodable here.
40 }
41
42 if ( 'wOFF' === $sig ) {
43 $bytes = $this->woff_to_tables( $bytes );
44
45 return $bytes ? $this->parse_tables( $bytes ) : null;
46 }
47
48 // 0x00010000 (TrueType) or 'OTTO' (CFF/OpenType) or 'true'/'typ1'.
49 return $this->parse_sfnt( $bytes );
50 }
51
52 /**
53 * Parse a raw sfnt (TTF/OTF) buffer.
54 *
55 * @param string $bytes sfnt bytes.
56 * @return array<string,float|int>|null
57 */
58 private function parse_sfnt( string $bytes ): ?array {
59 $num_tables = $this->u16( $bytes, 4 );
60
61 if ( $num_tables <= 0 || $num_tables > 4096 ) {
62 return null;
63 }
64
65 $tables = array();
66 $cursor = 12;
67
68 for ( $i = 0; $i < $num_tables; $i++ ) {
69 $tag = substr( $bytes, $cursor, 4 );
70 $offset = $this->u32( $bytes, $cursor + 8 );
71 $length = $this->u32( $bytes, $cursor + 12 );
72
73 $tables[ $tag ] = substr( $bytes, $offset, $length );
74 $cursor += 16;
75 }
76
77 return $this->extract( $tables );
78 }
79
80 /**
81 * Convenience wrapper used by the WOFF path (tables already keyed).
82 *
83 * @param array<string,string> $tables Tag => bytes.
84 * @return array<string,float|int>|null
85 */
86 private function parse_tables( array $tables ): ?array {
87 return $this->extract( $tables );
88 }
89
90 /**
91 * Decompress a WOFF container into a tag => bytes map.
92 *
93 * @param string $bytes WOFF bytes.
94 * @return array<string,string>|null
95 */
96 private function woff_to_tables( string $bytes ): ?array {
97 $num_tables = $this->u16( $bytes, 12 );
98
99 if ( $num_tables <= 0 || $num_tables > 4096 ) {
100 return null;
101 }
102
103 $tables = array();
104 $cursor = 44; // WOFF header length.
105
106 for ( $i = 0; $i < $num_tables; $i++ ) {
107 $tag = substr( $bytes, $cursor, 4 );
108 $offset = $this->u32( $bytes, $cursor + 4 );
109 $comp_len = $this->u32( $bytes, $cursor + 8 );
110 $orig_len = $this->u32( $bytes, $cursor + 12 );
111
112 $chunk = substr( $bytes, $offset, $comp_len );
113
114 if ( $comp_len < $orig_len ) {
115 $chunk = @gzuncompress( $chunk );
116
117 if ( false === $chunk ) {
118 $cursor += 20;
119 continue;
120 }
121 }
122
123 $tables[ $tag ] = $chunk;
124 $cursor += 20;
125 }
126
127 return $tables;
128 }
129
130 /**
131 * Extract the numbers we care about from parsed tables.
132 *
133 * @param array<string,string> $tables Tag => bytes.
134 * @return array<string,float|int>|null
135 */
136 private function extract( array $tables ): ?array {
137 if ( empty( $tables['head'] ) ) {
138 return null;
139 }
140
141 $head = $tables['head'];
142 $units_per_em = $this->u16( $head, 18 );
143
144 if ( $units_per_em <= 0 ) {
145 return null;
146 }
147
148 $ascent = 0;
149 $descent = 0;
150 $line_gap = 0;
151 $x_avg = 0;
152 $cap = 0;
153 $x_height = 0;
154
155 // Prefer OS/2 typographic metrics; they're the most consistent across renderers.
156 if ( ! empty( $tables['OS/2'] ) ) {
157 $os2 = $tables['OS/2'];
158 $version = $this->u16( $os2, 0 );
159
160 $x_avg = $this->s16( $os2, 2 );
161 $ascent = $this->s16( $os2, 68 ); // sTypoAscender
162 $descent = $this->s16( $os2, 70 ); // sTypoDescender
163 $line_gap = $this->s16( $os2, 72 ); // sTypoLineGap
164
165 if ( $version >= 2 && strlen( $os2 ) >= 90 ) {
166 $x_height = $this->s16( $os2, 86 );
167 $cap = $this->s16( $os2, 88 );
168 }
169 }
170
171 // Fall back to hhea for ascent/descent if OS/2 was missing or zeroed.
172 if ( ( 0 === $ascent || 0 === $descent ) && ! empty( $tables['hhea'] ) ) {
173 $hhea = $tables['hhea'];
174 $ascent = $this->s16( $hhea, 4 );
175 $descent = $this->s16( $hhea, 6 );
176 $line_gap = $this->s16( $hhea, 8 );
177 }
178
179 if ( 0 === $ascent ) {
180 return null;
181 }
182
183 return array(
184 'unitsPerEm' => $units_per_em,
185 'ascent' => $ascent,
186 'descent' => $descent,
187 'lineGap' => $line_gap,
188 'xAvgCharWidth' => $x_avg,
189 'capHeight' => $cap,
190 'xHeight' => $x_height,
191 );
192 }
193
194 /**
195 * Reference avg-width ratios for common system fallbacks (xAvgCharWidth / unitsPerEm).
196 * Values are objective font metrics; used only to size the local() fallback.
197 *
198 * @return array<string,array{stack:string,ratio:float}>
199 */
200 public static function system_fallbacks(): array {
201 return array(
202 'sans-serif' => array(
203 'stack' => 'Arial, "Helvetica Neue", Helvetica, sans-serif',
204 'local' => 'Arial',
205 'ratio' => 0.4414, // Arial: 904 / 2048
206 ),
207 'serif' => array(
208 'stack' => 'Georgia, "Times New Roman", Times, serif',
209 'local' => 'Georgia',
210 'ratio' => 0.4438, // Georgia: 909 / 2048
211 ),
212 'monospace' => array(
213 'stack' => '"Courier New", Courier, monospace',
214 'local' => 'Courier New',
215 'ratio' => 0.6024, // Courier New: 1233 / 2048
216 ),
217 );
218 }
219
220 /**
221 * Read an unsigned 16-bit big-endian integer.
222 *
223 * @param string $bin Binary string.
224 * @param int $offset Byte offset.
225 * @return int
226 */
227 private function u16( string $bin, int $offset ): int {
228 if ( $offset + 2 > strlen( $bin ) ) {
229 return 0;
230 }
231
232 $parts = unpack( 'n', substr( $bin, $offset, 2 ) );
233
234 return $parts ? (int) $parts[1] : 0;
235 }
236
237 /**
238 * Read a signed 16-bit big-endian integer.
239 *
240 * @param string $bin Binary string.
241 * @param int $offset Byte offset.
242 * @return int
243 */
244 private function s16( string $bin, int $offset ): int {
245 $value = $this->u16( $bin, $offset );
246
247 return $value >= 0x8000 ? $value - 0x10000 : $value;
248 }
249
250 /**
251 * Read an unsigned 32-bit big-endian integer.
252 *
253 * @param string $bin Binary string.
254 * @param int $offset Byte offset.
255 * @return int
256 */
257 private function u32( string $bin, int $offset ): int {
258 if ( $offset + 4 > strlen( $bin ) ) {
259 return 0;
260 }
261
262 $parts = unpack( 'N', substr( $bin, $offset, 4 ) );
263
264 return $parts ? (int) $parts[1] : 0;
265 }
266 }
267