['Aptos','Aptos,Calibri,Arial,sans-serif'], 'aptos-display'=>['Aptos Display','"Aptos Display",Aptos,Arial,sans-serif'], 'arial-black'=>['Arial Black','"Arial Black",Arial,sans-serif'], 'arial-narrow'=>['Arial Narrow','"Arial Narrow",Arial,sans-serif'], 'bahnschrift'=>['Bahnschrift','Bahnschrift,Arial,sans-serif'], 'book-antiqua'=>['Book Antiqua','"Book Antiqua",Palatino,serif'], 'calibri-light'=>['Calibri Light','"Calibri Light",Calibri,Arial,sans-serif'], 'corbel-light'=>['Corbel Light','"Corbel Light",Corbel,Arial,sans-serif'], 'franklin'=>['Franklin Gothic Medium','"Franklin Gothic Medium",Arial,sans-serif'], 'lucida-console'=>['Lucida Console','"Lucida Console",monospace'], 'lucida-sans'=>['Lucida Sans Unicode','"Lucida Sans Unicode",Arial,sans-serif'], 'lucida-handwriting'=>['Lucida Handwriting','"Lucida Handwriting",cursive'], 'segoe-light'=>['Segoe UI Light','"Segoe UI Light","Segoe UI",sans-serif'], 'segoe-symbol'=>['Segoe UI Symbol','"Segoe UI Symbol",sans-serif'], 'ink-free'=>['Ink Free','"Ink Free",cursive'], 'gabriola'=>['Gabriola','Gabriola,Georgia,serif'], 'system'=>['System','system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif'], 'arial'=>['Arial','Arial,Helvetica,sans-serif'], 'verdana'=>['Verdana','Verdana,Geneva,sans-serif'], 'tahoma'=>['Tahoma','Tahoma,Geneva,sans-serif'], 'trebuchet'=>['Trebuchet MS','"Trebuchet MS",sans-serif'], 'serif'=>['Georgia','Georgia,"Times New Roman",serif'], 'times'=>['Times New Roman','"Times New Roman",Times,serif'], 'monospace'=>['Monospace','ui-monospace,"Courier New",monospace'], 'courier'=>['Courier New','"Courier New",Courier,monospace'], 'segoe'=>['Segoe UI','"Segoe UI",Arial,sans-serif'], 'calibri'=>['Calibri','Calibri,Carlito,Arial,sans-serif'], 'cambria'=>['Cambria','Cambria,Georgia,serif'], 'candara'=>['Candara','Candara,Arial,sans-serif'], 'constantia'=>['Constantia','Constantia,Georgia,serif'], 'corbel'=>['Corbel','Corbel,Arial,sans-serif'], 'consolas'=>['Consolas','Consolas,ui-monospace,monospace'], 'impact'=>['Impact','Impact,"Arial Narrow",sans-serif'], 'palatino'=>['Palatino','Palatino,"Palatino Linotype",Georgia,serif'], 'bookman'=>['Bookman','"Bookman Old Style",Georgia,serif'], 'garamond'=>['Garamond','Garamond,Georgia,serif'], 'optima'=>['Optima','Optima,Arial,sans-serif'], 'gill'=>['Gill Sans','"Gill Sans","Trebuchet MS",sans-serif'], 'century'=>['Century Gothic','"Century Gothic",Arial,sans-serif'], 'comic'=>['Comic Sans MS','"Comic Sans MS",cursive'], ]; } public static function bundled(): array { $out=[];foreach(['Inter','Inter Tight'] as $name){$id=self::id($name);$out[]=['id'=>$id,'name'=>$name,'category'=>'sans-serif','stack'=>'"DIGIBLFont_'.$id.'",Arial,sans-serif','installed'=>true,'bundled'=>true,'css'=>DIGIBL_URL.'assets/fonts/bundled.css?v='.DIGIBL_VERSION];}return $out; } public static function default_link(): string {return Assets::stylesheet(DIGIBL_URL.'assets/fonts/bundled.css','bundled-fonts');} public function __construct(){add_action('rest_api_init',[$this,'routes']);} public function allowed(): bool {return current_user_can('edit_posts') && current_user_can('upload_files');} public function routes(): void { register_rest_route('digiter-builder/v1','/fonts',['methods'=>'GET','permission_callback'=>[$this,'allowed'],'callback'=>[$this,'catalog']]); register_rest_route('digiter-builder/v1','/fonts/refresh',['methods'=>'POST','permission_callback'=>[$this,'allowed'],'callback'=>[$this,'refresh']]); register_rest_route('digiter-builder/v1','/fonts/(?Pgf-[a-f0-9]{12})',['methods'=>'POST','permission_callback'=>[$this,'allowed'],'callback'=>[$this,'install']]); } public static function id(string $name): string {return 'gf-'.substr(hash('sha256',$name),0,12);} public static function normalize(array $rows): array { $out=[]; foreach($rows as $row){ if(!is_array($row))continue; $name=trim((string)($row['name']??$row['family']??'')); if(!$name || strlen($name)>140 || !preg_match('/^[\p{L}\p{N} ._()\-]+$/uD',$name))continue; $id=self::id($name);$cat=strtolower(str_replace('_','-',(string)($row['category']??'sans-serif'))); if(!in_array($cat,['sans-serif','serif','display','handwriting','monospace'],true))$cat='sans-serif'; $v=['id'=>$id,'name'=>$name,'category'=>$cat]; $styles=[]; foreach(array_keys(is_array($row['fonts']??null)?$row['fonts']:[]) as $variant){if(preg_match('/^([1-9]00)(i?)$/D',(string)$variant,$m))$styles[]=[(int)$m[1],!empty($m[2])?1:0];} if(!empty($row['variants'])&&is_array($row['variants']))foreach($row['variants'] as $variant){if(is_array($variant)&&count($variant)===2&&in_array((int)$variant[0],range(100,900,100),true)&&in_array((int)$variant[1],[0,1],true))$styles[]=[(int)$variant[0],(int)$variant[1]];} if($styles)$v['variants']=array_values(array_unique($styles,SORT_REGULAR)); foreach((array)($row['axes']??[]) as $axis){if(is_array($axis)&&($axis['tag']??'')==='wght'&&is_numeric($axis['min']??null)&&is_numeric($axis['max']??null)&&$axis['min']>=1&&$axis['max']<=1000)$v['range']=[(float)$axis['min'],(float)$axis['max']];} if(isset($row['range'])&&is_array($row['range'])&&count($row['range'])===2&&is_numeric($row['range'][0])&&is_numeric($row['range'][1])&&$row['range'][0]>=1&&$row['range'][1]<=1000&&$row['range'][0]<$row['range'][1])$v['range']=[(float)$row['range'][0],(float)$row['range'][1]]; $out[$id]=$v; } return $out; } public static function starter(): array { $seed=json_decode((string)file_get_contents(DIGIBL_PATH.'data/font-catalog.json'),true); return self::normalize($seed['families']??[]); } public static function families(): array { if(self::$families!==null)return self::$families; $saved=get_option('digibl_font_catalog',[]); self::$families=array_replace(self::starter(),self::normalize(is_array($saved)?($saved['families']??[]):[]),self::normalize(self::bundled())); return self::$families; } public static function clean($id): string {return is_string($id)&&(isset(self::system()[$id])||isset(self::families()[$id]))?$id:'inherit';} public static function stack(string $id): string { if(isset(self::system()[$id]))return self::system()[$id][1]; $f=self::families()[$id]??null;if(!$f)return 'inherit'; $fallback=in_array($f['category'],['serif','monospace'],true)?$f['category']:($f['category']==='handwriting'?'cursive':'sans-serif'); return '"DIGIBLFont_'.$id.'",'.$fallback; } private static function location(): array { $u=wp_upload_dir(null,false);if(!empty($u['error'])||empty($u['basedir'])||empty($u['baseurl']))throw new \RuntimeException('WordPress uploads is unavailable.'); return ['path'=>rtrim($u['basedir'],'/\\').'/digiter-builder-fonts','url'=>rtrim($u['baseurl'],'/').'/digiter-builder-fonts']; } public static function installed(string $id): ?array { foreach(self::bundled() as $font){if($font['id']===$id)return ['css'=>$font['css'],'count'=>2,'bundled'=>true];} if(!preg_match('/^gf-[a-f0-9]{12}$/D',$id))return null; try{$l=self::location();}catch(\Throwable $e){return null;} $dir=$l['path'].'/'.$id;$manifest=$dir.'/manifest.json'; if(is_link($dir)||is_link($manifest)||!is_readable($manifest)||filesize($manifest)>524288)return null; $m=json_decode((string)file_get_contents($manifest),true);if(!is_array($m)||empty($m['files'])||!is_array($m['files'])||count($m['files'])>1200||!is_file($dir.'/fonts.css')||is_link($dir.'/fonts.css'))return null; foreach($m['files'] as $f=>$size){if(!preg_match('/^[a-f0-9]{32}\.woff2$/D',$f)||is_link($dir.'/'.$f)||!is_file($dir.'/'.$f)||filesize($dir.'/'.$f)!==$size)return null;} return ['css'=>$l['url'].'/'.$id.'/fonts.css?v='.($m['hash']??'1'),'count'=>count($m['files'])]; } public function catalog(): array { $out=[];foreach(self::system() as $id=>$f)$out[]=['id'=>$id,'name'=>$f[0],'category'=>'system','stack'=>$f[1],'installed'=>true]; foreach(self::families() as $id=>$f){$local=self::installed($id);$out[]=$f+['stack'=>self::stack($id),'installed'=>$local!==null,'css'=>$local['css']??'','bundled'=>!empty($local['bundled'])];} $saved=get_option('digibl_font_catalog',[]); return ['families'=>$out,'complete'=>!empty($saved['complete']),'updated'=>$saved['updated']??null,'source'=>'Google Fonts / WordPress font collection']; } /** Fixed provider endpoints only; redirects and TLS bypasses are not allowed. */ private static function http(string $url,int $limit) { $r=wp_safe_remote_get($url,['timeout'=>30,'redirection'=>0,'sslverify'=>true,'limit_response_size'=>$limit+1, 'headers'=>['User-Agent'=>'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36']]); if(is_wp_error($r))throw new \RuntimeException('Font download: '.$r->get_error_message()); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception is caught and returned as WP_Error by the REST endpoint. if(wp_remote_retrieve_response_code($r)!==200)throw new \RuntimeException('Font provider returned HTTP '.wp_remote_retrieve_response_code($r).'. No font was changed.'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception is caught and returned as WP_Error by the REST endpoint. $body=wp_remote_retrieve_body($r);if(strlen($body)>$limit||$body==='')throw new \RuntimeException('Font response is empty or exceeds the safe size limit.');return $body; } public function refresh($request) { if(!$this->allowed())return new \WP_Error('digibl_fonts_permission','Editing and upload permissions required.',['status'=>403]); if($request->get_param('allowRemote')!==true)return new \WP_Error('digibl_fonts_consent','Confirm downloading font metadata from Google / WordPress.',['status'=>400]); $last=get_option('digibl_font_catalog',[]);if(!empty($last['complete'])&&time()-(int)($last['timestamp']??0)<300)return $this->catalog(); try{ try{$raw=self::http(self::CATALOG,16777216);$raw=preg_replace('/^\)\]\}\x27\s*/','',$raw);$data=json_decode($raw,true,128,JSON_THROW_ON_ERROR);$rows=self::normalize($data['familyMetadataList']??[]);if(count($rows)<100)throw new \RuntimeException('Incomplete font metadata.');} catch(\Throwable $e){$raw=self::http(self::WP_CATALOG,16777216);$data=json_decode($raw,true,128,JSON_THROW_ON_ERROR);$rows=[];foreach($data['font_families']??[] as $entry){$f=$entry['font_family_settings']??[];$r=['name'=>$f['name']??'','category'=>$entry['categories'][0]??'sans-serif','variants'=>[]];foreach($f['fontFace']??[] as $face){$w=$face['fontWeight']??'400';if(preg_match('/^(\d+) (\d+)$/',(string)$w,$m)){$r['range']=[(float)$m[1],(float)$m[2]];$w='400';}if(ctype_digit((string)$w))$r['variants'][]=[(int)$w,($face['fontStyle']??'normal')==='italic'?1:0];}$rows=array_replace($rows,self::normalize([$r]));}} if(count($rows)<100)throw new \RuntimeException('The provider catalogue was incomplete. The previous catalogue has been retained.'); $merged=array_replace(self::families(),$rows);$record=['complete'=>true,'timestamp'=>time(),'updated'=>gmdate('c'),'families'=>array_values($merged)];if(!update_option('digibl_font_catalog',$record,false)&&get_option('digibl_font_catalog')!==$record)throw new \RuntimeException('The font catalogue could not be saved to WordPress. Check database storage and try again.');self::$families=$merged; return $this->catalog(); }catch(\Throwable $e){return new \WP_Error('digibl_font_catalog',$e->getMessage(),['status'=>502]);} } public static function css_url(array $font): string { $spec=$font['name'];$variants=$font['variants']??[]; if(!empty($font['range'])){ $range=$font['range'][0].'..'.$font['range'][1];$italics=$variants?array_values(array_unique(array_column($variants,1))):[0];sort($italics,SORT_NUMERIC); $spec.=$italics===[0]?':wght@'.$range:':ital,wght@'.implode(';',array_map(static fn($i)=>$i.','.$range,$italics)); }elseif($variants){usort($variants,static fn($a,$b)=>[$a[1],$a[0]]<=>[$b[1],$b[0]]);$spec.=':ital,wght@'.implode(';',array_map(static fn($v)=>$v[1].','.$v[0],$variants));} return 'https://fonts.googleapis.com/css2?family='.rawurlencode($spec).'&display=swap'; } public static function font_url(string $url): bool { $p=wp_parse_url($url);return is_array($p)&&($p['scheme']??'')==='https'&&($p['host']??'')==='fonts.gstatic.com'&&!isset($p['port'])&&!isset($p['user'])&&!isset($p['pass'])&&!isset($p['fragment'])&&preg_match('~^/s/[A-Za-z0-9/_\-]+\.woff2$~D',$p['path']??'')===1&&!isset($p['query']); } /** Rebuild only allowlisted @font-face descriptors; never store provider CSS verbatim. */ public static function faces(string $css): array { preg_match_all('/@font-face\s*\{([^}]+)\}/i',$css,$blocks);$out=[]; foreach($blocks[1] as $block){ if(!preg_match('~url\(\s*[\'"]?(https://[^\s)\'"<>]+)[\'"]?\s*\)~i',$block,$m)||!self::font_url($m[1]))continue; $face=['url'=>$m[1],'weight'=>'400','style'=>'normal','stretch'=>'normal','unicode'=>'']; foreach(['weight'=>'font-weight','style'=>'font-style','stretch'=>'font-stretch','unicode'=>'unicode-range'] as $key=>$prop){if(preg_match('/'.preg_quote($prop,'/').'\s*:\s*([^;]+);/i',$block,$v))$face[$key]=trim($v[1]);} if(!preg_match('/^(?:[1-9]\d{0,2}|1000)(?: (?:[1-9]\d{0,2}|1000))?$/D',$face['weight'])||!in_array($face['style'],['normal','italic','oblique'],true))continue; if(!preg_match('/^(normal|[0-9.]+%(?: [0-9.]+%)?)$/D',$face['stretch']))$face['stretch']='normal'; if($face['unicode']!==''&&!preg_match('/^U\+[0-9A-F?]+(?:-[0-9A-F]+)?(?:\s*,\s*U\+[0-9A-F?]+(?:-[0-9A-F]+)?)*$/iD',$face['unicode']))continue; $out[]=$face; } if(!$out||count($out)>1200)throw new \RuntimeException('The provider did not return a supported, bounded WOFF2 font.'); return $out; } public function install($request) { if(!$this->allowed())return new \WP_Error('digibl_fonts_permission','Editing and upload permissions required.',['status'=>403]); $id=(string)$request['font'];$font=self::families()[$id]??null;if(!$font)return new \WP_Error('digibl_font_unknown','Refresh the font catalogue first.',['status'=>404]); if($local=self::installed($id))return $font+['stack'=>self::stack($id),'installed'=>true]+$local; if($request->get_param('allowRemote')!==true)return new \WP_Error('digibl_fonts_consent','Confirm installing this font locally from Google.',['status'=>400]); $key='digibl_font_lock_'.$id;$old=(int)get_option($key,0);if($old&&$old409]); $stage='';$started=microtime(true); try{ $l=self::location();if(!wp_mkdir_p($l['path'])||!wp_is_writable($l['path'])||is_link($l['path']))throw new \RuntimeException('The local font directory is not writable.'); $dest=$l['path'].'/'.$id;if(file_exists($dest))throw new \RuntimeException('An incomplete font directory already exists. It has been retained for inspection.'); $stage=$l['path'].'/.install-'.$id.'-'.bin2hex(random_bytes(8));if(!LocalFiles::get()->mkdir($stage,0755))throw new \RuntimeException('Cannot create font staging directory.'); if(function_exists('set_time_limit'))@set_time_limit(180); // Resolve variants once for a starter family, so real available weights/italics are requested. if(empty($font['variants'])&&empty($font['range'])){$this->refresh($request);$font=self::families()[$id]??$font;} $faces=self::faces(self::http(self::css_url($font),2097152));$files=[];$bytes=0;$css="/* Downloaded on request; served from this site. */\n"; foreach($faces as $face){ if(microtime(true)-$started>140)throw new \RuntimeException('The font transfer took too long. Retry the installation; the previous font is unchanged.'); $file=substr(hash('sha256',$face['url']),0,32).'.woff2'; if(!isset($files[$file])){ $body=self::http($face['url'],8388608);$bytes+=strlen($body); if(strlen($body)<48||substr($body,0,4)!=='wOF2'||$bytes>self::MAX_BYTES)throw new \RuntimeException('Invalid WOFF2 file or font exceeds 50 MB. No previous font was changed.'); $header=unpack('Nlength',substr($body,8,4));if(($header['length']??0)!==strlen($body))throw new \RuntimeException('The font download is incomplete.'); if(file_put_contents($stage.'/'.$file,$body,LOCK_EX)!==strlen($body))throw new \RuntimeException('Cannot write the font file.');$files[$file]=strlen($body); } $css.='@font-face{font-family:"DIGIBLFont_'.$id.'";font-style:'.$face['style'].';font-weight:'.$face['weight'].';font-stretch:'.$face['stretch'].';font-display:swap;src:url("'.$file.'") format("woff2");'.($face['unicode']?'unicode-range:'.$face['unicode'].';':'')."}\n"; } $meta=['name'=>$font['name'],'source'=>'https://fonts.google.com/specimen/'.rawurlencode($font['name']),'installed'=>gmdate('c'),'files'=>$files,'hash'=>substr(hash('sha256',$css),0,12)]; foreach(['fonts.css'=>$css,'manifest.json'=>wp_json_encode($meta),'index.html'=>''] as $name=>$body){if(file_put_contents($stage.'/'.$name,$body,LOCK_EX)!==strlen($body))throw new \RuntimeException('Cannot commit font metadata.');} if(!LocalFiles::get()->move($stage,$dest))throw new \RuntimeException('Cannot finish the local font installation.');$stage=''; return $font+['installed'=>true,'stack'=>self::stack($id)]+self::installed($id); }catch(\Throwable $e){return new \WP_Error('digibl_font_install',$e->getMessage(),['status'=>502]);} finally{delete_option($key);if($stage&&is_dir($stage)&&!is_link($stage)){foreach(glob($stage.'/*')?:[] as $f)if(is_file($f))@wp_delete_file($f);@LocalFiles::get()->rmdir($stage);}} } public static function links(array $doc): string { $json=wp_json_encode($doc);preg_match_all('/gf-[a-f0-9]{12}/',$json,$m);$ids=array_unique($m[0]); if(class_exists(Typography::class)&&class_exists(ThemeBuilder::class)){$global=Typography::current();if(!empty($global['enabled']))foreach($global['styles']??[] as $style){if(str_starts_with($style['family']??'','gf-'))$ids[]=$style['family'];}} $out=self::default_link();foreach(array_unique($ids) as $id){$local=self::installed($id);if($local&&empty($local['bundled']))$out.=Assets::stylesheet($local['css'],'font-'.$id);}return $out; } }