[ CSS format, MIME for preload ]. */ const TYPES = array( 'woff2' => array( 'woff2', 'font/woff2' ), 'woff' => array( 'woff', 'font/woff' ), 'ttf' => array( 'truetype', 'font/ttf' ), 'otf' => array( 'opentype', 'font/otf' ), ); /** Largest upload (bytes). */ const MAX_UPLOAD = 10485760; /** * Hooks. */ public function __construct() { add_action( 'wp_head', array( __CLASS__, 'preload' ), 2 ); add_filter( 'bricks/builder/standard_fonts', array( __CLASS__, 'bricks_fonts' ) ); add_filter( 'wp_theme_json_data_theme', array( __CLASS__, 'theme_json' ) ); } /** * Defaults. * * @return array */ public static function defaults() { return \TokenForge\Factory::merge( 'fonts', self::builtin_defaults() ); } /** * Built-in defaults (the factory settings in data/defaults.json are laid over them). * * @return array */ public static function builtin_defaults() { return array( 'enabled' => true, // output the @font-face rules 'bricks' => true, // list the families in the Bricks font picker 'blocks' => true, // list them in the block editor font picker too 'fonts' => array(), // [ { family, src, src2, weight, style, display, variable, stretch, variation, preload } ] ); } /** * One empty font. * * @return array */ public static function blank() { return array( 'family' => '', 'src' => '', 'src2' => '', 'weight' => '400', 'style' => 'normal', 'display' => 'swap', 'variable' => false, 'stretch' => '', 'variation' => '', 'preload' => false, 'unicode' => '' ); } /** * Settings. * * @return array */ public static function settings() { $s = get_option( self::OPTION, array() ); $s = wp_parse_args( is_array( $s ) ? $s : array(), self::defaults() ); $s['fonts'] = self::clean_fonts( $s['fonts'] ); return Modules::apply( 'fonts', $s ); } /** * Save settings. * * @param array $in Raw input. * @return void */ public static function save_settings( $in ) { update_option( self::OPTION, array( 'enabled' => ! empty( $in['enabled'] ), 'bricks' => ! empty( $in['bricks'] ), 'blocks' => ! empty( $in['blocks'] ), 'fonts' => self::convert_local( self::clean_fonts( isset( $in['fonts'] ) ? $in['fonts'] : array() ) ), ), false ); } /** * Sanitise the font list (entries without a family or a file are kept, so a * half-filled row survives a save, but they output nothing). * * @param mixed $list Raw list. * @return array */ private static function clean_fonts( $list ) { $out = array(); foreach ( array_slice( array_values( (array) $list ), 0, 40 ) as $f ) { if ( ! is_array( $f ) ) { continue; } $f = wp_parse_args( $f, self::blank() ); $weight = strtolower( trim( (string) $f['weight'] ) ); $out[] = array( 'family' => trim( preg_replace( '/["\';{}<>\\\\]/', '', sanitize_text_field( (string) $f['family'] ) ) ), 'src' => self::url( $f['src'] ), 'src2' => self::url( $f['src2'] ), 'weight' => preg_match( '/^(normal|bold|[1-9]00|[1-9]\d{0,2}(\s+[1-9]\d{0,2})?)$/', $weight ) ? $weight : '400', 'style' => in_array( $f['style'], array( 'normal', 'italic', 'oblique' ), true ) ? $f['style'] : 'normal', 'display' => in_array( $f['display'], array( 'auto', 'block', 'swap', 'fallback', 'optional' ), true ) ? $f['display'] : 'swap', 'variable' => ! empty( $f['variable'] ), 'stretch' => preg_match( '/^[a-z0-9.%\s-]*$/i', (string) $f['stretch'] ) ? trim( (string) $f['stretch'] ) : '', 'variation' => trim( str_replace( array( ';', '{', '}', '<', '>', '\\' ), '', (string) $f['variation'] ) ), 'preload' => ! empty( $f['preload'] ), // unicode-range: U+0000-00FF, U+0131 … 'unicode' => preg_match( '/^[Uu+0-9A-Fa-f?,\s-]*$/', (string) $f['unicode'] ) ? trim( preg_replace( '/\s+/', ' ', (string) $f['unicode'] ) ) : '', ); } return $out; } /** * A font URL (http(s) or site-relative), or ''. * * @param mixed $v Value. * @return string */ private static function url( $v ) { $v = trim( (string) $v ); if ( '' === $v ) { return ''; } if ( 0 === strpos( $v, '/' ) && 0 !== strpos( $v, '//' ) ) { return preg_replace( '/[^A-Za-z0-9\/._~%+-]/', '', $v ); } return esc_url_raw( $v, array( 'http', 'https' ) ); } /** * File type from a URL's extension. * * @param string $url URL. * @return array|null [ format, mime ] */ public static function type( $url ) { $ext = strtolower( pathinfo( (string) wp_parse_url( $url, PHP_URL_PATH ), PATHINFO_EXTENSION ) ); return isset( self::TYPES[ $ext ] ) ? self::TYPES[ $ext ] : null; } /** * Fonts that output something. * * @return array */ public static function active() { $s = self::settings(); if ( empty( $s['enabled'] ) ) { return array(); } return array_values( array_filter( $s['fonts'], function ( $f ) { return '' !== $f['family'] && '' !== $f['src']; } ) ); } /** * The @font-face rules. * * @return string */ public static function css() { $out = ''; foreach ( self::active() as $f ) { $src = array(); foreach ( array( $f['src'], $f['src2'] ) as $u ) { if ( '' === $u ) { continue; } $t = self::type( $u ); $src[] = 'url("' . str_replace( array( '"', ')' ), array( '%22', '%29' ), $u ) . '")' . ( $t ? ' format("' . $t[0] . '")' : '' ); } $out .= '@font-face{font-family:"' . $f['family'] . '";src:' . implode( ',', $src ) . ';font-weight:' . $f['weight'] . ';font-style:' . $f['style'] . ';font-display:' . $f['display'] . ';' . ( $f['variable'] && '' !== $f['stretch'] ? 'font-stretch:' . $f['stretch'] . ';' : '' ) . ( $f['variable'] && '' !== $f['variation'] ? 'font-variation-settings:' . $f['variation'] . ';' : '' ) . ( '' !== $f['unicode'] ? 'unicode-range:' . $f['unicode'] . ';' : '' ) . "}\n"; } return '' === $out ? '' : "\n/* --- TokenForge fonts --- */\n" . $out; } /** * Families to suggest in the dashboard's font fields: TokenForge fonts and the * builder's custom fonts. * * @return string[] */ public static function choices() { $out = self::families(); if ( post_type_exists( 'bricks_fonts' ) ) { $ids = get_posts( array( 'post_type' => 'bricks_fonts', 'post_status' => 'publish', 'posts_per_page' => 50, 'fields' => 'ids', 'no_found_rows' => true, ) ); foreach ( $ids as $id ) { $t = html_entity_decode( (string) get_the_title( $id ), ENT_QUOTES, 'UTF-8' ); if ( '' !== trim( $t ) ) { $out[] = trim( $t ); } } } return array_values( array_unique( $out ) ); } /** * Family names (for the Bricks font picker, the dashboard and the AI guide). * * @return array */ public static function families() { return array_values( array_unique( array_column( self::active(), 'family' ) ) ); } /** * for fonts marked "preload" (front end). * * @return void */ public static function preload() { if ( is_admin() ) { return; } foreach ( self::active() as $f ) { if ( empty( $f['preload'] ) ) { continue; } $t = self::type( $f['src'] ); printf( '' . "\n", esc_url( $f['src'] ), esc_attr( $t ? $t[1] : 'font/woff2' ) ); } } /** * Our families in the block editor's font picker (the theme's font families, * WordPress 6.1+). The @font-face rules already reach the editor with the * TokenForge stylesheet, so only the families are listed. * * @param object $theme_json WP_Theme_JSON_Data. * @return object */ public static function theme_json( $theme_json ) { $s = self::settings(); $fams = self::families(); if ( empty( $s['blocks'] ) || ! $fams || ! is_object( $theme_json ) || ! method_exists( $theme_json, 'update_with' ) ) { return $theme_json; } $data = method_exists( $theme_json, 'get_data' ) ? (array) $theme_json->get_data() : array(); $list = isset( $data['settings']['typography']['fontFamilies'] ) && is_array( $data['settings']['typography']['fontFamilies'] ) ? $data['settings']['typography']['fontFamilies'] : array(); if ( isset( $list['theme'] ) && is_array( $list['theme'] ) ) { $list = $list['theme']; // stored by origin } $list = array_values( array_filter( $list, 'is_array' ) ); $slugs = array_column( $list, 'slug' ); foreach ( $fams as $fam ) { $slug = sanitize_title( $fam ); if ( '' === $slug || in_array( $slug, $slugs, true ) ) { continue; } $list[] = array( 'name' => $fam, 'slug' => $slug, 'fontFamily' => '"' . $fam . '", ' . self::fallback( $fam ), ); $slugs[] = $slug; } return $theme_json->update_with( array( 'version' => 2, 'settings' => array( 'typography' => array( 'fontFamilies' => $list ) ), ) ); } /** * The fallback of a family: the one used with it in the TokenForge * typography, else sans-serif. * * @param string $family Family. * @return string */ private static function fallback( $family ) { $c = Components::settings(); foreach ( array( 'text_font_family', 'heading_font_family', 'btn_font_family' ) as $k ) { $parts = array_map( 'trim', explode( ',', isset( $c[ $k ] ) ? (string) $c[ $k ] : '' ) ); if ( count( $parts ) > 1 && strtolower( trim( $parts[0], "\"' " ) ) === strtolower( $family ) ) { return implode( ', ', array_slice( $parts, 1 ) ); } } return 'sans-serif'; } /** * Add our families to Bricks' font list (typography controls). * * @param array $fonts Standard fonts. * @return array */ public static function bricks_fonts( $fonts ) { $s = self::settings(); if ( empty( $s['bricks'] ) ) { return $fonts; } return array_values( array_unique( array_merge( self::families(), (array) $fonts ) ) ); } /** * Store an uploaded font file in uploads/tokenforge/fonts/ after checking * its extension, size and file signature. * * @param array $file One entry of $_FILES. * @return array [ 'ok' => bool, 'url' => string, 'error' => string, 'family' => guess ] */ public static function upload( $file ) { if ( ! is_array( $file ) || ! isset( $file['tmp_name'], $file['name'] ) || ! empty( $file['error'] ) ) { return array( 'ok' => false, 'error' => 'No file received.' ); } $name = sanitize_file_name( wp_basename( (string) $file['name'] ) ); $ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ); if ( ! isset( self::TYPES[ $ext ] ) ) { return array( 'ok' => false, 'error' => 'Use a .woff2, .woff, .ttf or .otf file.' ); } $size = isset( $file['size'] ) ? (int) $file['size'] : 0; if ( $size <= 0 || $size > self::MAX_UPLOAD ) { return array( 'ok' => false, 'error' => 'The file must be smaller than 10 MB.' ); } if ( ! self::signature_ok( (string) $file['tmp_name'], $ext ) ) { return array( 'ok' => false, 'error' => 'That file is not a valid ' . $ext . ' font.' ); } // WordPress stores the file (same checks as its own font library), in uploads/tokenforge/fonts. if ( ! function_exists( 'wp_handle_upload' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $file['name'] = $name; add_filter( 'upload_dir', array( __CLASS__, 'upload_dir' ) ); $done = wp_handle_upload( $file, array( 'test_form' => false, 'mimes' => self::upload_mimes(), ) ); remove_filter( 'upload_dir', array( __CLASS__, 'upload_dir' ) ); if ( empty( $done['file'] ) || empty( $done['url'] ) ) { return array( 'ok' => false, 'error' => ! empty( $done['error'] ) ? (string) $done['error'] : 'Could not store the file.' ); } $dest = (string) $done['file']; $name = wp_basename( $dest ); $up = wp_upload_dir(); $url = (string) $done['url']; $converted = ''; // TTF / OTF: use a WOFF2 (or WOFF) copy, much smaller; the original stays. if ( in_array( $ext, array( 'ttf', 'otf' ), true ) ) { $made = Font_Convert::convert_file( $dest ); if ( $made ) { $url = trailingslashit( $up['baseurl'] ) . 'tokenforge/fonts/' . basename( $made['path'] ); $converted = $made['format']; } } return array( 'ok' => true, 'url' => $url, 'converted' => $converted, 'family' => self::family_guess( $name ), 'error' => '', ); } /** Where a Google Fonts download asks for the family's CSS. */ const GOOGLE_CSS = 'https://fonts.googleapis.com/css2'; /** The only host font files may come from. */ const GOOGLE_FILES = 'fonts.gstatic.com'; /** * Download a Google Fonts family into uploads/tokenforge/fonts, once, when an * administrator asks for it (visitors never contact Google). Every character * set Google has for the family comes as its own .woff2 file with its * unicode-range, so the browser only loads the ones a page needs. * * @param string $family Family name ("Inter"). * @param string $weights Weights ("400, 700"). * @param bool $italic Italic too. * @return array [ 'ok' => bool, 'fonts' => [ font entries ], 'error' => string ] */ public static function google( $family, $weights, $italic ) { $family = trim( preg_replace( '/\s+/', ' ', (string) $family ) ); if ( ! preg_match( '/^[A-Za-z0-9][A-Za-z0-9 ]{0,59}$/', $family ) ) { return array( 'ok' => false, 'fonts' => array(), 'error' => 'Type a Google Fonts family name, for example Inter.' ); } $ws = array(); foreach ( preg_split( '/[\s,;]+/', (string) $weights ) as $w ) { $w = (int) $w; if ( $w >= 100 && $w <= 900 && 0 === $w % 100 ) { $ws[ $w ] = $w; } } if ( ! $ws ) { $ws = array( 400 => 400, 700 => 700 ); } ksort( $ws ); $axes = array(); foreach ( $italic ? array( 0, 1 ) : array( 0 ) as $i ) { foreach ( $ws as $w ) { $axes[] = $i . ',' . $w; } } $url = self::GOOGLE_CSS . '?family=' . str_replace( '%20', '+', rawurlencode( $family ) ) . ':ital,wght@' . implode( ';', $axes ) . '&display=swap'; // A current browser's user agent: Google then answers with .woff2 files. $res = wp_safe_remote_get( $url, array( 'timeout' => 15, 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36' ) ); if ( is_wp_error( $res ) ) { return array( 'ok' => false, 'fonts' => array(), 'error' => 'Google Fonts could not be reached: ' . $res->get_error_message() ); } if ( 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { return array( 'ok' => false, 'fonts' => array(), 'error' => 'Google Fonts has no family called ' . $family . ' with those weights.' ); } $faces = array(); preg_match_all( '#(?:/\*\s*([a-z0-9-]+)\s*\*/\s*)?@font-face\s*\{([^}]*)\}#i', (string) wp_remote_retrieve_body( $res ), $m, PREG_SET_ORDER ); foreach ( $m as $f ) { $body = $f[2]; if ( ! preg_match( '#url\((https://fonts\.gstatic\.com/[A-Za-z0-9/._~-]+\.woff2)\)#', $body, $u ) ) { continue; } $host = wp_parse_url( $u[1], PHP_URL_HOST ); if ( self::GOOGLE_FILES !== $host ) { continue; } preg_match( '/font-weight:\s*(\d{3})/', $body, $w ); preg_match( '/font-style:\s*(normal|italic)/', $body, $st ); preg_match( '/unicode-range:\s*([U+0-9A-Fa-f?,\s-]+);/', $body, $range ); $faces[] = array( 'url' => $u[1], 'weight' => isset( $w[1] ) ? $w[1] : '400', 'style' => isset( $st[1] ) ? $st[1] : 'normal', 'subset' => ! empty( $f[1] ) ? sanitize_key( $f[1] ) : '', 'range' => isset( $range[1] ) ? trim( preg_replace( '/\s+/', ' ', $range[1] ) ) : '', ); } if ( ! $faces ) { return array( 'ok' => false, 'fonts' => array(), 'error' => 'Google Fonts sent no font files for ' . $family . '.' ); } $faces = array_slice( $faces, 0, 80 ); if ( ! function_exists( 'download_url' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $slug = sanitize_title( $family ); $out = array(); $fail = 0; add_filter( 'upload_dir', array( __CLASS__, 'upload_dir' ) ); foreach ( $faces as $f ) { $tmp = download_url( $f['url'], 20 ); if ( is_wp_error( $tmp ) ) { ++$fail; continue; } if ( ! self::signature_ok( $tmp, 'woff2' ) ) { wp_delete_file( $tmp ); ++$fail; continue; } $name = $slug . '-' . $f['weight'] . ( 'italic' === $f['style'] ? '-italic' : '' ) . ( '' !== $f['subset'] ? '-' . $f['subset'] : '' ) . '.woff2'; $file = array( 'name' => $name, 'tmp_name' => $tmp, 'error' => 0, 'size' => (int) filesize( $tmp ) ); $done = wp_handle_sideload( $file, array( 'test_form' => false, 'mimes' => self::upload_mimes() ) ); if ( empty( $done['url'] ) ) { wp_delete_file( $tmp ); ++$fail; continue; } $out[] = array_merge( self::blank(), array( 'family' => $family, 'src' => (string) $done['url'], 'weight' => (string) $f['weight'], 'style' => $f['style'], 'unicode' => $f['range'] ) ); } remove_filter( 'upload_dir', array( __CLASS__, 'upload_dir' ) ); if ( ! $out ) { return array( 'ok' => false, 'fonts' => array(), 'error' => 'The font files could not be stored.' ); } return array( 'ok' => true, 'fonts' => $out, 'error' => $fail ? $fail . ' file(s) could not be stored.' : '' ); } /** * Font uploads go to uploads/tokenforge/fonts (upload_dir filter, only while TokenForge stores a font). * * @param array $dirs Upload folder data. * @return array */ public static function upload_dir( $dirs ) { $dirs['subdir'] = '/tokenforge/fonts'; $dirs['path'] = $dirs['basedir'] . '/tokenforge/fonts'; $dirs['url'] = $dirs['baseurl'] . '/tokenforge/fonts'; return $dirs; } /** * Allowed font types for an upload: WordPress's own list when it has one (6.5+), the same list otherwise. * * @return array extension => MIME */ public static function upload_mimes() { if ( class_exists( '\WP_Font_Utils' ) && method_exists( '\WP_Font_Utils', 'get_allowed_font_mime_types' ) ) { return array_intersect_key( (array) \WP_Font_Utils::get_allowed_font_mime_types(), self::TYPES ); } return array( 'otf' => 'application/vnd.ms-opentype', 'ttf' => PHP_VERSION_ID >= 70400 ? 'font/sfnt' : 'application/font-sfnt', 'woff' => PHP_VERSION_ID >= 80112 ? 'font/woff' : 'application/font-woff', 'woff2' => PHP_VERSION_ID >= 80112 ? 'font/woff2' : 'application/font-woff2', ); } /** * The file behind a font URL in this site's uploads folder, or ''. * * @param string $url URL. * @return string */ public static function local_file( $url ) { $up = wp_upload_dir(); $base = trailingslashit( $up['baseurl'] ); $url = (string) $url; if ( 0 === strpos( $url, '/' ) && 0 !== strpos( $url, '//' ) ) { $url = home_url( $url ); } foreach ( array( $base, set_url_scheme( $base, 'http' ), set_url_scheme( $base, 'https' ) ) as $b ) { if ( 0 === strpos( $url, $b ) ) { $path = realpath( trailingslashit( $up['basedir'] ) . rawurldecode( substr( $url, strlen( $b ) ) ) ); $root = realpath( $up['basedir'] ); return ( $path && $root && 0 === strpos( $path, $root ) && is_file( $path ) ) ? $path : ''; } } return ''; } /** * Fonts that point at a TTF/OTF file in this site's uploads get a WOFF2 (or * WOFF) copy, used from then on. * * @param array $fonts Clean font list. * @return array */ private static function convert_local( $fonts ) { $up = wp_upload_dir(); foreach ( $fonts as $i => $f ) { $t = self::type( $f['src'] ); if ( ! $t || ! in_array( $t[0], array( 'truetype', 'opentype' ), true ) ) { continue; } $file = self::local_file( $f['src'] ); $made = $file ? Font_Convert::convert_file( $file ) : null; if ( $made ) { $rel = ltrim( str_replace( '\\', '/', substr( $made['path'], strlen( realpath( $up['basedir'] ) ) ) ), '/' ); $fonts[ $i ]['src'] = trailingslashit( $up['baseurl'] ) . $rel; } } return $fonts; } /** * Does the file start like a font of that type? * * @param string $path File. * @param string $ext Extension. * @return bool */ public static function signature_ok( $path, $ext ) { $h = @fopen( $path, 'rb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions if ( ! $h ) { return false; } $head = (string) fread( $h, 4 ); // phpcs:ignore WordPress.WP.AlternativeFunctions fclose( $h ); // phpcs:ignore WordPress.WP.AlternativeFunctions switch ( $ext ) { case 'woff2': return 'wOF2' === $head; case 'woff': return 'wOFF' === $head; default: // ttf / otf: TrueType or CFF outlines, either extension. return in_array( $head, array( "\x00\x01\x00\x00", 'true', 'OTTO' ), true ); } } /** * "Inter-VariableFont_wght.woff2" → "Inter". * * @param string $file File name. * @return string */ public static function family_guess( $file ) { $n = pathinfo( $file, PATHINFO_FILENAME ); $n = preg_replace( '/[-_](variable|var|vf|regular|italic|bold|light|medium|semibold|black|thin|extralight|extrabold|latin|ext|subset|wght|opsz|\d+).*$/i', '', $n ); $n = trim( preg_replace( '/([a-z])([A-Z])/', '$1 $2', str_replace( array( '-', '_' ), ' ', $n ) ) ); return '' === $n ? 'My Font' : ucwords( $n ); } }