wptt-webfont-loader.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. <?php
  2. /**
  3. * Download webfonts locally.
  4. *
  5. * @package wptt/font-loader
  6. * @license https://opensource.org/licenses/MIT
  7. */
  8. if ( ! class_exists( 'WPTT_WebFont_Loader' ) ) {
  9. /**
  10. * Download webfonts locally.
  11. */
  12. class WPTT_WebFont_Loader {
  13. /**
  14. * The font-format.
  15. *
  16. * Use "woff" or "woff2".
  17. * This will change the user-agent user to make the request.
  18. *
  19. * @access protected
  20. * @since 1.0.0
  21. * @var string
  22. */
  23. protected $font_format = 'woff2';
  24. /**
  25. * The remote URL.
  26. *
  27. * @access protected
  28. * @since 1.1.0
  29. * @var string
  30. */
  31. protected $remote_url;
  32. /**
  33. * Base path.
  34. *
  35. * @access protected
  36. * @since 1.1.0
  37. * @var string
  38. */
  39. protected $base_path;
  40. /**
  41. * Base URL.
  42. *
  43. * @access protected
  44. * @since 1.1.0
  45. * @var string
  46. */
  47. protected $base_url;
  48. /**
  49. * Subfolder name.
  50. *
  51. * @access protected
  52. * @since 1.1.0
  53. * @var string
  54. */
  55. protected $subfolder_name;
  56. /**
  57. * The fonts folder.
  58. *
  59. * @access protected
  60. * @since 1.1.0
  61. * @var string
  62. */
  63. protected $fonts_folder;
  64. /**
  65. * The local stylesheet's path.
  66. *
  67. * @access protected
  68. * @since 1.1.0
  69. * @var string
  70. */
  71. protected $local_stylesheet_path;
  72. /**
  73. * The local stylesheet's URL.
  74. *
  75. * @access protected
  76. * @since 1.1.0
  77. * @var string
  78. */
  79. protected $local_stylesheet_url;
  80. /**
  81. * The remote CSS.
  82. *
  83. * @access protected
  84. * @since 1.1.0
  85. * @var string
  86. */
  87. protected $remote_styles;
  88. /**
  89. * The final CSS.
  90. *
  91. * @access protected
  92. * @since 1.1.0
  93. * @var string
  94. */
  95. protected $css;
  96. /**
  97. * Cleanup routine frequency.
  98. */
  99. const CLEANUP_FREQUENCY = 'monthly';
  100. /**
  101. * Constructor.
  102. *
  103. * Get a new instance of the object for a new URL.
  104. *
  105. * @access public
  106. * @since 1.1.0
  107. * @param string $url The remote URL.
  108. */
  109. public function __construct( $url = '' ) {
  110. $this->remote_url = $url;
  111. // Add a cleanup routine.
  112. $this->schedule_cleanup();
  113. add_action( 'delete_fonts_folder', array( $this, 'delete_fonts_folder' ) );
  114. }
  115. /**
  116. * Get the local URL which contains the styles.
  117. *
  118. * Fallback to the remote URL if we were unable to write the file locally.
  119. *
  120. * @access public
  121. * @since 1.1.0
  122. * @return string
  123. */
  124. public function get_url() {
  125. // Check if the local stylesheet exists.
  126. if ( $this->local_file_exists() ) {
  127. // Attempt to update the stylesheet. Return the local URL on success.
  128. if ( $this->write_stylesheet() ) {
  129. return $this->get_local_stylesheet_url();
  130. }
  131. }
  132. // If the local file exists, return its URL, with a fallback to the remote URL.
  133. return file_exists( $this->get_local_stylesheet_path() )
  134. ? $this->get_local_stylesheet_url()
  135. : $this->remote_url;
  136. }
  137. /**
  138. * Get the local stylesheet URL.
  139. *
  140. * @access public
  141. * @since 1.1.0
  142. * @return string
  143. */
  144. public function get_local_stylesheet_url() {
  145. if ( ! $this->local_stylesheet_url ) {
  146. $this->local_stylesheet_url = str_replace(
  147. $this->get_base_path(),
  148. $this->get_base_url(),
  149. $this->get_local_stylesheet_path()
  150. );
  151. }
  152. return $this->local_stylesheet_url;
  153. }
  154. /**
  155. * Get styles with fonts downloaded locally.
  156. *
  157. * @access public
  158. * @since 1.0.0
  159. * @return string
  160. */
  161. public function get_styles() {
  162. // If we already have the local file, return its contents.
  163. $local_stylesheet_contents = $this->get_local_stylesheet_contents();
  164. if ( $local_stylesheet_contents ) {
  165. return $local_stylesheet_contents;
  166. }
  167. // Get the remote URL contents.
  168. $this->remote_styles = $this->get_remote_url_contents();
  169. // Get an array of locally-hosted files.
  170. $files = $this->get_local_files_from_css();
  171. // Convert paths to URLs.
  172. foreach ( $files as $remote => $local ) {
  173. $files[ $remote ] = str_replace(
  174. $this->get_base_path(),
  175. $this->get_base_url(),
  176. $local
  177. );
  178. }
  179. $this->css = str_replace(
  180. array_keys( $files ),
  181. array_values( $files ),
  182. $this->remote_styles
  183. );
  184. $this->write_stylesheet();
  185. return $this->css;
  186. }
  187. /**
  188. * Get local stylesheet contents.
  189. *
  190. * @access public
  191. * @since 1.1.0
  192. * @return string|false Returns the remote URL contents.
  193. */
  194. public function get_local_stylesheet_contents() {
  195. $local_path = $this->get_local_stylesheet_path();
  196. // Check if the local stylesheet exists.
  197. if ( $this->local_file_exists() ) {
  198. // Attempt to update the stylesheet. Return false on fail.
  199. if ( ! $this->write_stylesheet() ) {
  200. return false;
  201. }
  202. }
  203. ob_start();
  204. include $local_path;
  205. return ob_get_clean();
  206. }
  207. /**
  208. * Get remote file contents.
  209. *
  210. * @access public
  211. * @since 1.0.0
  212. * @return string Returns the remote URL contents.
  213. */
  214. public function get_remote_url_contents() {
  215. /**
  216. * The user-agent we want to use.
  217. *
  218. * The default user-agent is the only one compatible with woff (not woff2)
  219. * which also supports unicode ranges.
  220. */
  221. $user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/603.3.8 (KHTML, like Gecko) Version/10.1.2 Safari/603.3.8';
  222. // Switch to a user-agent supporting woff2 if we don't need to support IE.
  223. if ( 'woff2' === $this->font_format ) {
  224. $user_agent = 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:73.0) Gecko/20100101 Firefox/73.0';
  225. }
  226. // Get the response.
  227. $response = wp_remote_get( $this->remote_url, array( 'user-agent' => $user_agent ) );
  228. // Early exit if there was an error.
  229. if ( is_wp_error( $response ) ) {
  230. return '';
  231. }
  232. // Get the CSS from our response.
  233. $contents = wp_remote_retrieve_body( $response );
  234. return $contents;
  235. }
  236. /**
  237. * Download files mentioned in our CSS locally.
  238. *
  239. * @access public
  240. * @since 1.0.0
  241. * @return array Returns an array of remote URLs and their local counterparts.
  242. */
  243. public function get_local_files_from_css() {
  244. $font_files = $this->get_remote_files_from_css();
  245. $stored = get_site_option( 'downloaded_font_files', array() );
  246. $change = false; // If in the end this is true, we need to update the cache option.
  247. if ( ! defined( 'FS_CHMOD_DIR' ) ) {
  248. define( 'FS_CHMOD_DIR', ( 0755 & ~ umask() ) );
  249. }
  250. // If the fonts folder don't exist, create it.
  251. if ( ! file_exists( $this->get_fonts_folder() ) ) {
  252. $this->get_filesystem()->mkdir( $this->get_fonts_folder(), FS_CHMOD_DIR );
  253. }
  254. foreach ( $font_files as $font_family => $files ) {
  255. // The folder path for this font-family.
  256. $folder_path = $this->get_fonts_folder() . '/' . $font_family;
  257. // If the folder doesn't exist, create it.
  258. if ( ! file_exists( $folder_path ) ) {
  259. $this->get_filesystem()->mkdir( $folder_path, FS_CHMOD_DIR );
  260. }
  261. foreach ( $files as $url ) {
  262. // Get the filename.
  263. $filename = basename( wp_parse_url( $url, PHP_URL_PATH ) );
  264. $font_path = $folder_path . '/' . $filename;
  265. // Check if the file already exists.
  266. if ( file_exists( $font_path ) ) {
  267. // Skip if already cached.
  268. if ( isset( $stored[ $url ] ) ) {
  269. continue;
  270. }
  271. // Add file to the cache and change the $changed var to indicate we need to update the option.
  272. $stored[ $url ] = $font_path;
  273. $change = true;
  274. // Since the file exists we don't need to proceed with downloading it.
  275. continue;
  276. }
  277. /**
  278. * If we got this far, we need to download the file.
  279. */
  280. // require file.php if the download_url function doesn't exist.
  281. if ( ! function_exists( 'download_url' ) ) {
  282. require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/file.php' );
  283. }
  284. // Download file to temporary location.
  285. $tmp_path = download_url( $url );
  286. // Make sure there were no errors.
  287. if ( is_wp_error( $tmp_path ) ) {
  288. continue;
  289. }
  290. // Move temp file to final destination.
  291. $success = $this->get_filesystem()->move( $tmp_path, $font_path, true );
  292. if ( $success ) {
  293. $stored[ $url ] = $font_path;
  294. $change = true;
  295. }
  296. }
  297. }
  298. // If there were changes, update the option.
  299. if ( $change ) {
  300. // Cleanup the option and then save it.
  301. foreach ( $stored as $url => $path ) {
  302. if ( ! file_exists( $path ) ) {
  303. unset( $stored[ $url ] );
  304. }
  305. }
  306. update_site_option( 'downloaded_font_files', $stored );
  307. }
  308. return $stored;
  309. }
  310. /**
  311. * Get font files from the CSS.
  312. *
  313. * @access public
  314. * @since 1.0.0
  315. * @return array Returns an array of font-families and the font-files used.
  316. */
  317. public function get_remote_files_from_css() {
  318. $font_faces = explode( '@font-face', $this->remote_styles );
  319. $result = array();
  320. // Loop all our font-face declarations.
  321. foreach ( $font_faces as $font_face ) {
  322. // Make sure we only process styles inside this declaration.
  323. $style = explode( '}', $font_face )[0];
  324. // Sanity check.
  325. if ( false === strpos( $style, 'font-family' ) ) {
  326. continue;
  327. }
  328. // Get an array of our font-families.
  329. preg_match_all( '/font-family.*?\;/', $style, $matched_font_families );
  330. // Get an array of our font-files.
  331. preg_match_all( '/url\(.*?\)/i', $style, $matched_font_files );
  332. // Get the font-family name.
  333. $font_family = 'unknown';
  334. if ( isset( $matched_font_families[0] ) && isset( $matched_font_families[0][0] ) ) {
  335. $font_family = rtrim( ltrim( $matched_font_families[0][0], 'font-family:' ), ';' );
  336. $font_family = trim( str_replace( array( "'", ';' ), '', $font_family ) );
  337. $font_family = sanitize_key( strtolower( str_replace( ' ', '-', $font_family ) ) );
  338. }
  339. // Make sure the font-family is set in our array.
  340. if ( ! isset( $result[ $font_family ] ) ) {
  341. $result[ $font_family ] = array();
  342. }
  343. // Get files for this font-family and add them to the array.
  344. foreach ( $matched_font_files as $match ) {
  345. // Sanity check.
  346. if ( ! isset( $match[0] ) ) {
  347. continue;
  348. }
  349. // Add the file URL.
  350. $font_family_url = rtrim( ltrim( $match[0], 'url(' ), ')' );
  351. // Make sure to convert relative URLs to absolute.
  352. $font_family_url = $this->get_absolute_path( $font_family_url );
  353. $result[ $font_family ][] = $font_family_url;
  354. }
  355. // Make sure we have unique items.
  356. // We're using array_flip here instead of array_unique for improved performance.
  357. $result[ $font_family ] = array_flip( array_flip( $result[ $font_family ] ) );
  358. }
  359. return $result;
  360. }
  361. /**
  362. * Write the CSS to the filesystem.
  363. *
  364. * @access protected
  365. * @since 1.1.0
  366. * @return string|false Returns the absolute path of the file on success, or false on fail.
  367. */
  368. protected function write_stylesheet() {
  369. $file_path = $this->get_local_stylesheet_path();
  370. $filesystem = $this->get_filesystem();
  371. if ( ! defined( 'FS_CHMOD_DIR' ) ) {
  372. define( 'FS_CHMOD_DIR', ( 0755 & ~ umask() ) );
  373. }
  374. // If the folder doesn't exist, create it.
  375. if ( ! file_exists( $this->get_fonts_folder() ) ) {
  376. $this->get_filesystem()->mkdir( $this->get_fonts_folder(), FS_CHMOD_DIR );
  377. }
  378. // If the file doesn't exist, create it. Return false if it can not be created.
  379. if ( ! $filesystem->exists( $file_path ) && ! $filesystem->touch( $file_path ) ) {
  380. return false;
  381. }
  382. // If we got this far, we need to write the file.
  383. // Get the CSS.
  384. if ( ! $this->css ) {
  385. $this->get_styles();
  386. }
  387. // Put the contents in the file. Return false if that fails.
  388. if ( ! $filesystem->put_contents( $file_path, $this->css ) ) {
  389. return false;
  390. }
  391. return $file_path;
  392. }
  393. /**
  394. * Get the stylesheet path.
  395. *
  396. * @access public
  397. * @since 1.1.0
  398. * @return string
  399. */
  400. public function get_local_stylesheet_path() {
  401. if ( ! $this->local_stylesheet_path ) {
  402. $this->local_stylesheet_path = $this->get_fonts_folder() . '/' . $this->get_local_stylesheet_filename() . '.css';
  403. }
  404. return $this->local_stylesheet_path;
  405. }
  406. /**
  407. * Get the local stylesheet filename.
  408. *
  409. * This is a hash, generated from the site-URL, the wp-content path and the URL.
  410. * This way we can avoid issues with sites changing their URL, or the wp-content path etc.
  411. *
  412. * @access public
  413. * @since 1.1.0
  414. * @return string
  415. */
  416. public function get_local_stylesheet_filename() {
  417. return md5( $this->get_base_url() . $this->get_base_path() . $this->remote_url . $this->font_format );
  418. }
  419. /**
  420. * Set the font-format to be used.
  421. *
  422. * @access public
  423. * @since 1.0.0
  424. * @param string $format The format to be used. Use "woff" or "woff2".
  425. * @return void
  426. */
  427. public function set_font_format( $format = 'woff2' ) {
  428. $this->font_format = $format;
  429. }
  430. /**
  431. * Check if the local stylesheet exists.
  432. *
  433. * @access public
  434. * @since 1.1.0
  435. * @return bool
  436. */
  437. public function local_file_exists() {
  438. return ( ! file_exists( $this->get_local_stylesheet_path() ) );
  439. }
  440. /**
  441. * Get the base path.
  442. *
  443. * @access public
  444. * @since 1.1.0
  445. * @return string
  446. */
  447. public function get_base_path() {
  448. if ( ! $this->base_path ) {
  449. $this->base_path = apply_filters( 'wptt_get_local_fonts_base_path', $this->get_filesystem()->wp_content_dir() );
  450. }
  451. return $this->base_path;
  452. }
  453. /**
  454. * Get the base URL.
  455. *
  456. * @access public
  457. * @since 1.1.0
  458. * @return string
  459. */
  460. public function get_base_url() {
  461. if ( ! $this->base_url ) {
  462. $this->base_url = apply_filters( 'wptt_get_local_fonts_base_url', content_url() );
  463. }
  464. return $this->base_url;
  465. }
  466. /**
  467. * Get the subfolder name.
  468. *
  469. * @access public
  470. * @since 1.1.0
  471. * @return string
  472. */
  473. public function get_subfolder_name() {
  474. if ( ! $this->subfolder_name ) {
  475. $this->subfolder_name = apply_filters( 'wptt_get_local_fonts_subfolder_name', 'fonts' );
  476. }
  477. return $this->subfolder_name;
  478. }
  479. /**
  480. * Get the folder for fonts.
  481. *
  482. * @access public
  483. * @return string
  484. */
  485. public function get_fonts_folder() {
  486. if ( ! $this->fonts_folder ) {
  487. $this->fonts_folder = $this->get_base_path();
  488. if ( $this->get_subfolder_name() ) {
  489. $this->fonts_folder .= '/' . $this->get_subfolder_name();
  490. }
  491. }
  492. return $this->fonts_folder;
  493. }
  494. /**
  495. * Schedule a cleanup.
  496. *
  497. * Deletes the fonts files on a regular basis.
  498. * This way font files will get updated regularly,
  499. * and we avoid edge cases where unused files remain in the server.
  500. *
  501. * @access public
  502. * @since 1.1.0
  503. * @return void
  504. */
  505. public function schedule_cleanup() {
  506. if ( ! is_multisite() || ( is_multisite() && is_main_site() ) ) {
  507. if ( ! wp_next_scheduled( 'delete_fonts_folder' ) && ! wp_installing() ) {
  508. wp_schedule_event( time(), self::CLEANUP_FREQUENCY, 'delete_fonts_folder' );
  509. }
  510. }
  511. }
  512. /**
  513. * Delete the fonts folder.
  514. *
  515. * This runs as part of a cleanup routine.
  516. *
  517. * @access public
  518. * @since 1.1.0
  519. * @return bool
  520. */
  521. public function delete_fonts_folder() {
  522. return $this->get_filesystem()->delete( $this->get_fonts_folder(), true );
  523. }
  524. /**
  525. * Get the filesystem.
  526. *
  527. * @access protected
  528. * @since 1.0.0
  529. * @return \WP_Filesystem_Base
  530. */
  531. protected function get_filesystem() {
  532. global $wp_filesystem;
  533. // If the filesystem has not been instantiated yet, do it here.
  534. if ( ! $wp_filesystem ) {
  535. if ( ! function_exists( 'WP_Filesystem' ) ) {
  536. require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/file.php' );
  537. }
  538. WP_Filesystem();
  539. }
  540. return $wp_filesystem;
  541. }
  542. /**
  543. * Get an absolute URL from a relative URL.
  544. *
  545. * @access protected
  546. *
  547. * @param string $url The URL.
  548. *
  549. * @return string
  550. */
  551. protected function get_absolute_path( $url ) {
  552. // If dealing with a root-relative URL.
  553. if ( 0 === stripos( $url, '/' ) ) {
  554. $parsed_url = parse_url( $this->remote_url );
  555. return $parsed_url['scheme'] . '://' . $parsed_url['hostname'] . $url;
  556. }
  557. return $url;
  558. }
  559. }
  560. }
  561. if ( ! function_exists( 'wptt_get_webfont_styles' ) ) {
  562. /**
  563. * Get styles for a webfont.
  564. *
  565. * This will get the CSS from the remote API,
  566. * download any fonts it contains,
  567. * replace references to remote URLs with locally-downloaded assets,
  568. * and finally return the resulting CSS.
  569. *
  570. * @since 1.0.0
  571. *
  572. * @param string $url The URL of the remote webfont.
  573. * @param string $format The font-format. If you need to support IE, change this to "woff".
  574. *
  575. * @return string Returns the CSS.
  576. */
  577. function wptt_get_webfont_styles( $url, $format = 'woff2' ) {
  578. $font = new WPTT_WebFont_Loader( $url );
  579. $font->set_font_format( $format );
  580. return $font->get_styles();
  581. }
  582. }
  583. if ( ! function_exists( 'wptt_get_webfont_url' ) ) {
  584. /**
  585. * Get a stylesheet URL for a webfont.
  586. *
  587. * @since 1.1.0
  588. *
  589. * @param string $url The URL of the remote webfont.
  590. * @param string $format The font-format. If you need to support IE, change this to "woff".
  591. *
  592. * @return string Returns the CSS.
  593. */
  594. function wptt_get_webfont_url( $url, $format = 'woff2' ) {
  595. $font = new WPTT_WebFont_Loader( $url );
  596. $font->set_font_format( $format );
  597. return $font->get_url();
  598. }
  599. }