From 771fac466f70d5e53943522d6b4526908812ec30 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Thu, 5 Nov 2026 15:07:33 +1100 Subject: [PATCH 1/4] List and delete downloaded core fonts in the Font Manager On-demand downloading makes deleting a core font safe, as one a PDF needs comes back on its own, but the Font Manager only listed custom fonts, so a downloaded font could only be removed by hand from the fonts folder. The Font Manager gets a "Downloaded Fonts" section listing each family of a downloadable source with files on disk: its styles, size and why it's in use (default font, site language, PDF count, a required source, another site, or a file another required font shares). In-use families can't be deleted; the server re-checks and returns 409, and 404 for a bundled, unknown or absent family. Delete removes the family's manifest files whatever their hash (an explicit action, unlike the upgrade prune), keeps the licence files, clears the files' failure and back-off entries, flushes the family's metrics and logs what it removed. With downloads turned off the confirm says the font won't come back. The in-use reasons come from Model_Required_Fonts::get_font_reasons(), which get_required_font_files() is now built on, so the list and the background sync share one walk of the forms. On Multisite the fonts folder is shared, so only super admins can delete, and "in use" also counts every other site's default font, locale font and PDF fonts. Those come from Model_Required_Fonts::get_site_font_keys(), which reads a site's tables by prefix rather than switch_to_blog() so no other site's plugins or filters run. One options query per site also says whether Gravity Forms has run there (gf_db_version), so no site pays a separate table check. Routes live in Controller_Font_Downloader, beside the font sync it already wires up, rather than Controller_Custom_Fonts, which the multisite-safe CRUD work also changes. The E2E test copies a core font no other spec uses into the fonts folder, as downloads are off on the E2E site, and puts one in use with an X-GPDF-E2E-Default-Font header rather than a PDF selecting it, which would count it as required in the other specs' system reports. Co-Authored-By: Claude Opus 5.5 --- src/Controller/Controller_Font_Downloader.php | 78 +++++ src/Helper/Helper_Data.php | 18 + src/Model/Model_Font_Downloader.php | 258 ++++++++++++++ src/Model/Model_Font_Sources.php | 70 +++- src/Model/Model_Required_Fonts.php | 197 +++++++++-- .../js/react/actions/downloadedFonts.js | 39 +++ src/assets/js/react/api/downloadedFonts.js | 68 ++++ .../components/FontManager/DownloadedFonts.js | 239 +++++++++++++ .../components/FontManager/FontManagerBody.js | 3 + .../react/reducers/downloadedFontsReducer.js | 87 +++++ src/assets/js/react/reducers/index.js | 2 + src/assets/js/react/sagas/downloadedFonts.js | 98 ++++++ src/assets/js/react/sagas/index.js | 6 + .../Component/FontManager/_font-manager.scss | 17 + .../FontManager/DownloadedFonts.test.js | 201 +++++++++++ .../reducers/downloadedFontsReducer.test.js | 90 +++++ .../react/sagas/downloadedFonts.test.js | 110 ++++++ .../Test_Controller_Font_Downloader.php | 75 +++++ .../Model/Test_Downloaded_Fonts.php | 316 ++++++++++++++++++ .../Model/Test_Model_Required_Fonts.php | 90 +++++ .../core/managers/font-manager.spec.ts | 77 ++++- tools/mu-plugins/gravitypdf.php | 11 + 22 files changed, 2100 insertions(+), 50 deletions(-) create mode 100644 src/assets/js/react/actions/downloadedFonts.js create mode 100644 src/assets/js/react/api/downloadedFonts.js create mode 100644 src/assets/js/react/components/FontManager/DownloadedFonts.js create mode 100644 src/assets/js/react/reducers/downloadedFontsReducer.js create mode 100644 src/assets/js/react/sagas/downloadedFonts.js create mode 100644 tests/js-unit/react/components/FontManager/DownloadedFonts.test.js create mode 100644 tests/js-unit/react/reducers/downloadedFontsReducer.test.js create mode 100644 tests/js-unit/react/sagas/downloadedFonts.test.js create mode 100644 tests/phpunit/integration/Model/Test_Downloaded_Fonts.php diff --git a/src/Controller/Controller_Font_Downloader.php b/src/Controller/Controller_Font_Downloader.php index a68870750..6dec42f79 100644 --- a/src/Controller/Controller_Font_Downloader.php +++ b/src/Controller/Controller_Font_Downloader.php @@ -5,9 +5,14 @@ namespace GFPDF\Controller; use GFPDF\Helper\Helper_Abstract_Controller; +use GFPDF\Helper\Helper_Data; use GFPDF\Helper\Helper_Interface_Actions; use GFPDF\Model\Model_Font_Downloader; use GFPDF\Model\Model_Required_Fonts; +use GPDFAPI; +use WP_Error; +use WP_REST_Request; +use WP_REST_Server; /** * @package Gravity PDF @@ -61,6 +66,79 @@ public function __construct( Model_Font_Downloader $model, Model_Required_Fonts */ public function init() { $this->add_actions(); + + add_action( 'rest_api_init', [ $this, 'register_endpoints' ] ); + } + + /** + * The Font Manager's downloaded fonts list and delete, beside the custom font routes + * + * @since 6.19.0 + */ + public function register_endpoints(): void { + register_rest_route( + Helper_Data::REST_API_BASENAME . 'v1', + '/fonts/downloaded', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_downloaded_fonts' ], + 'permission_callback' => [ $this, 'can_view_fonts' ], + ] + ); + + register_rest_route( + Helper_Data::REST_API_BASENAME . 'v1', + '/fonts/downloaded/(?P[a-z0-9_\-]+)', + [ + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => [ $this, 'delete_downloaded_font' ], + 'permission_callback' => [ $this, 'can_delete_fonts' ], + ] + ); + } + + /** + * @return array{fonts: array, can_delete: bool} + * + * @since 6.19.0 + */ + public function get_downloaded_fonts(): array { + return [ + 'fonts' => $this->model->get_downloaded_fonts(), + 'can_delete' => $this->can_delete_fonts(), + ]; + } + + /** + * @return array|WP_Error + * + * @since 6.19.0 + */ + public function delete_downloaded_font( WP_REST_Request $request ) { + return $this->model->delete_downloaded_font( (string) $request->get_param( 'key' ) ); + } + + /** + * The custom font routes' capability + * + * @since 6.19.0 + */ + public function can_view_fonts(): bool { + $allowed = GPDFAPI::get_form_class()->has_capability( 'gravityforms_edit_forms' ); + if ( ! $allowed ) { + GPDFAPI::get_log_class()->warning( 'Permission denied: user does not have "gravityforms_edit_forms" capabilities' ); + } + + return $allowed; + } + + /** + * Super admins only on Multisite, as the fonts folder is shared by the network + * + * @since 6.19.0 + */ + public function can_delete_fonts(): bool { + return $this->can_view_fonts() && ( ! is_multisite() || is_super_admin() ); } /** diff --git a/src/Helper/Helper_Data.php b/src/Helper/Helper_Data.php index ea7932ba3..bf378f3d4 100644 --- a/src/Helper/Helper_Data.php +++ b/src/Helper/Helper_Data.php @@ -331,6 +331,24 @@ public function get_localised_script_data( Helper_Abstract_Options $options, Hel 'fontManagerUpdateFontAriaLabel' => esc_html__( 'Update font', 'gravity-pdf' ), 'fontManagerSelectFontAriaLabel' => esc_html__( 'Select font', 'gravity-pdf' ), 'fontManagerDeleteFontAriaLabel' => esc_html__( 'Delete font', 'gravity-pdf' ), + 'fontManagerDownloadedTitle' => esc_html__( 'Downloaded Fonts', 'gravity-pdf' ), + 'fontManagerDownloadedDesc' => esc_html__( 'Core fonts downloaded when a PDF, setting or site language needed them. A deleted font downloads again when a PDF needs it.', 'gravity-pdf' ), + 'fontManagerDownloadedEmpty' => esc_html__( 'No fonts have been downloaded.', 'gravity-pdf' ), + 'fontManagerDownloadedReadOnly' => esc_html__( 'Only a network administrator can delete downloaded fonts, as every site in the network shares them.', 'gravity-pdf' ), + 'fontManagerDownloadedFont' => esc_html__( 'Font', 'gravity-pdf' ), + 'fontManagerDownloadedStyles' => esc_html__( 'Styles', 'gravity-pdf' ), + 'fontManagerDownloadedSize' => esc_html__( 'Size', 'gravity-pdf' ), + 'fontManagerDownloadedStatus' => esc_html__( 'In use', 'gravity-pdf' ), + 'fontManagerDownloadedUnused' => esc_html__( 'Not in use', 'gravity-pdf' ), + 'fontManagerDownloadedDelete' => esc_html__( 'Delete', 'gravity-pdf' ), + /* translators: %s: the font name */ + 'fontManagerDownloadedDeleteAriaLabel' => esc_html__( 'Delete %s', 'gravity-pdf' ), + /* translators: %s: the font name */ + 'fontManagerDownloadedConfirm' => esc_html__( 'Delete %s? It downloads again when a PDF needs it.', 'gravity-pdf' ), + /* translators: %s: the font name */ + 'fontManagerDownloadedConfirmOffline' => esc_html__( 'Delete %s? Font downloads are turned off, so it won\'t come back on its own: copy its files into the fonts folder again to restore it.', 'gravity-pdf' ), + /* translators: %s: the font name */ + 'fontManagerDownloadedDeleted' => esc_html__( '%s deleted.', 'gravity-pdf' ), /* Font Manager API response */ 'fontListEmpty' => esc_html__( 'Font list empty.', 'gravity-pdf' ), diff --git a/src/Model/Model_Font_Downloader.php b/src/Model/Model_Font_Downloader.php index bcab58fcb..7e4c0c183 100644 --- a/src/Model/Model_Font_Downloader.php +++ b/src/Model/Model_Font_Downloader.php @@ -4,6 +4,7 @@ namespace GFPDF\Model; +use GFPDF\Helper\Fonts\FlushCache; use GFPDF\Helper\Helper_Abstract_Model; use GFPDF\Helper\Helper_Abstract_Options; use GFPDF\Helper\Helper_Data; @@ -11,6 +12,7 @@ use GFPDF\Helper\Mpdf\On_Demand_Font_File_Finder; use GFPDF\Statics\Git_Blob; use GFPDF_Vendor\Psr\Log\LoggerInterface; +use WP_Error; /** * @package Gravity PDF @@ -350,6 +352,262 @@ public function prune( ?array $keep = null ): array { return $removed; } + /** + * Each downloadable font family with files in the fonts folder, with why it's in use (a reason disables Delete). + * Walks every form, and on Multisite every site, so it's only for the Font Manager. + * + * @return array + * + * @since 6.19.0 + */ + public function get_downloaded_fonts(): array { + $families = $this->get_downloaded_families(); + if ( count( $families ) === 0 ) { + return []; + } + + $reasons = $this->required_fonts->get_font_reasons( is_multisite() ); + $labels = $this->get_font_labels(); + $locales = $this->font_sources->get_language_font_locales(); + $fonts = []; + + foreach ( $families as $key => $family ) { + $files = []; + foreach ( $family['files'] as $style => $file ) { + if ( isset( $family['sizes'][ $file ] ) ) { + $files[] = [ + 'style' => $style, + 'file' => $file, + 'size' => $family['sizes'][ $file ], + ]; + } + } + + $size = array_sum( $family['sizes'] ); + $fonts[] = [ + 'id' => $key, + 'label' => $labels[ $key ] ?? $key, + 'source' => $family['source'], + 'files' => $files, + 'size' => $size, + 'size_label' => (string) size_format( $size, 1 ), + 'in_use' => $this->get_in_use( $key, $reasons, $locales[ $key ] ?? [] ), + 'auto_install' => $this->can_download_all( $family['files'] ), + ]; + } + + usort( + $fonts, + static function ( array $a, array $b ): int { + return strcasecmp( $a['label'], $b['label'] ); + } + ); + + return $fonts; + } + + /** + * Delete an unused downloaded family's files whatever their hash, keeping the licence files + * + * @return array{id: string, removed: string[], bytes: int}|WP_Error 404 when nothing is on disk, 409 when in use + * + * @since 6.19.0 + */ + public function delete_downloaded_font( string $key ) { + $families = $this->get_downloaded_families(); + if ( ! isset( $families[ $key ] ) ) { + return new WP_Error( 'font_not_downloaded', __( 'The font is not downloaded.', 'gravity-pdf' ), [ 'status' => 404 ] ); + } + + $files = array_values( array_unique( $families[ $key ]['files'] ) ); + if ( count( $this->get_in_use( $key, $this->required_fonts->get_font_reasons( is_multisite() ) ) ) > 0 ) { + return new WP_Error( 'font_in_use', __( 'The font is in use, so it would be downloaded again.', 'gravity-pdf' ), [ 'status' => 409 ] ); + } + + $removed = []; + $bytes = 0; + foreach ( $files as $file ) { + $this->clear_failure( $file ); + $this->delete_back_off( $file ); + + $size = $families[ $key ]['sizes'][ $file ] ?? null; + if ( $size === null ) { + continue; + } + + if ( ! wp_delete_file_from_directory( $this->get_font_path( $file ), $this->data->template_font_location ) ) { + $this->log->warning( 'Downloaded font file could not be removed', [ 'name' => $file ] ); + continue; + } + + $removed[] = $file; + $bytes += $size; + } + + FlushCache::flush( $key ); + + $this->log->notice( + 'Downloaded font deleted', + [ + 'font' => $key, + 'files' => $removed, + 'bytes' => $bytes, + ] + ); + + return [ + 'id' => $key, + 'removed' => $removed, + 'bytes' => $bytes, + ]; + } + + /** + * The downloadable sources' families with at least one file in the fonts folder: their files on the manifest by + * style (R, B, I, BI), and the sizes of those on disk + * + * @return array, sizes: array}> + * + * @since 6.19.0 + */ + protected function get_downloaded_families(): array { + $families = []; + + foreach ( $this->font_sources->get_sources() as $id => $source ) { + if ( empty( $source['manifest'] ) ) { + continue; + } + + $manifest = $this->font_sources->get_source_manifest( (string) $id ); + foreach ( $source['families'] ?? [] as $key => $family ) { + $key = (string) $key; + if ( isset( $families[ $key ] ) || ! is_array( $family ) ) { + continue; + } + + $files = []; + foreach ( [ 'R', 'B', 'I', 'BI' ] as $style ) { + $file = $family[ $style ] ?? null; + if ( is_string( $file ) && isset( $manifest[ $file ] ) && $this->font_sources->is_core_font_file( $file ) ) { + $files[ $style ] = $file; + } + } + + $sizes = []; + foreach ( $files as $file ) { + $path = $this->get_font_path( $file ); + if ( is_file( $path ) ) { + $sizes[ $file ] = (int) filesize( $path ); + } + } + + if ( count( $sizes ) > 0 ) { + $families[ $key ] = [ + 'source' => (string) $id, + 'files' => $files, + 'sizes' => $sizes, + ]; + } + } + } + + return $families; + } + + /** + * Font list labels by key + * + * @return array + * + * @since 6.19.0 + */ + protected function get_font_labels(): array { + $labels = []; + foreach ( (array) $this->options->get_installed_fonts() as $group ) { + foreach ( is_array( $group ) ? $group : [] as $key => $label ) { + if ( is_string( $label ) && ! isset( $labels[ $key ] ) ) { + $labels[ (string) $key ] = $label; + } + } + } + + return $labels; + } + + /** + * Why a family is in use, as phrases for the Font Manager; none means it can be deleted + * + * @param array $reasons get_font_reasons() + * @param string[] $locales The locales it's a language font for + * + * @return string[] + * + * @since 6.19.0 + */ + protected function get_in_use( string $key, array $reasons, array $locales = [] ): array { + if ( ! isset( $reasons[ $key ] ) ) { + return []; + } + + $reasons = $reasons[ $key ]; + $phrases = []; + + if ( $reasons['default'] ) { + $phrases[] = __( 'Default font', 'gravity-pdf' ); + } + + if ( $reasons['language'] ) { + $names = array_map( [ $this, 'get_language_name' ], $locales ); + /* translators: %s: the site's languages, e.g. ไทย */ + $phrases[] = count( $names ) > 0 ? sprintf( __( 'Site language (%s)', 'gravity-pdf' ), implode( ', ', $names ) ) : __( 'Site language', 'gravity-pdf' ); + } + + if ( $reasons['pdfs'] > 0 ) { + /* translators: %d: the number of PDFs */ + $phrases[] = sprintf( _n( 'Used by %d PDF', 'Used by %d PDFs', $reasons['pdfs'], 'gravity-pdf' ), $reasons['pdfs'] ); + } + + if ( $reasons['source'] ) { + $phrases[] = __( 'Required by an add-on', 'gravity-pdf' ); + } + + if ( $reasons['sites'] > 0 ) { + /* translators: %d: the number of sites */ + $phrases[] = sprintf( _n( 'Used on %d other site', 'Used on %d other sites', $reasons['sites'], 'gravity-pdf' ), $reasons['sites'] ); + } + + return $phrases; + } + + /** + * A locale's native name when WordPress has the translations list cached, else the locale + * + * @since 6.19.0 + */ + protected function get_language_name( string $locale ): string { + $translations = get_site_transient( 'available_translations' ); + $name = is_array( $translations ) ? ( $translations[ $locale ]['native_name'] ?? '' ) : ''; + + return is_string( $name ) && $name !== '' ? $name : $locale; + } + + /** + * Whether every file would download again if deleted + * + * @param string[] $files + * + * @since 6.19.0 + */ + protected function can_download_all( array $files ): bool { + foreach ( $files as $file ) { + if ( ! $this->is_auto_install_enabled( $file ) ) { + return false; + } + } + + return true; + } + /** * @param array{url: string, size: int, sha: string} $entry * diff --git a/src/Model/Model_Font_Sources.php b/src/Model/Model_Font_Sources.php index 48af64c9d..23b77a865 100644 --- a/src/Model/Model_Font_Sources.php +++ b/src/Model/Model_Font_Sources.php @@ -109,6 +109,12 @@ class Model_Font_Sources extends Helper_Abstract_Model { */ protected $language_font_keys; + /** + * @var array|null Locales by font key, once get_language_font_locales() maps them + * @since 6.19.0 + */ + protected $language_font_locales; + /** * Every source's downloadable files by name, once get_manifest() reads them * @@ -309,8 +315,9 @@ public function get_font_data(): array { * @since 6.19.0 */ public function flush(): void { - $this->font_data = null; - $this->language_font_keys = null; + $this->font_data = null; + $this->language_font_keys = null; + $this->language_font_locales = null; delete_transient( self::LANGUAGE_FONT_KEYS ); } @@ -335,20 +342,7 @@ public function get_language_font_keys(): array { return $this->language_font_keys; } - $mapper = $this->get_language_mapper(); - $locales = array_filter( array_merge( (array) get_available_languages(), [ $locale ] ), 'is_string' ); - $keys = []; - - foreach ( array_unique( $locales ) as $language ) { - /* Adobe CJK fonts are off in Gravity PDF's config */ - $options = $mapper->getLanguageOptions( str_replace( '_', '-', $language ), false ); - $key = is_array( $options ) && is_string( $options[1] ?? null ) ? $options[1] : ''; - if ( $key !== '' ) { - $keys[] = $key; - } - } - - $this->language_font_keys = array_values( array_unique( $keys ) ); + $this->language_font_keys = array_map( 'strval', array_keys( $this->get_language_font_locales() ) ); set_transient( self::LANGUAGE_FONT_KEYS, @@ -362,6 +356,50 @@ public function get_language_font_keys(): array { return $this->language_font_keys; } + /** + * The installed languages and the locale mapped to their mPDF font keys, once per request + * + * @return array Locales by font key + * + * @since 6.19.0 + */ + public function get_language_font_locales(): array { + if ( $this->language_font_locales !== null ) { + return $this->language_font_locales; + } + + $this->language_font_locales = $this->map_language_font_locales( + array_merge( (array) get_available_languages(), [ get_locale() ] ) + ); + + return $this->language_font_locales; + } + + /** + * Map locales to the mPDF font keys they need, skipping those the default font covers + * + * @param mixed[] $locales + * + * @return array Locales by font key + * + * @since 6.19.0 + */ + public function map_language_font_locales( array $locales ): array { + $mapper = $this->get_language_mapper(); + $keys = []; + + foreach ( array_unique( array_filter( $locales, 'is_string' ) ) as $language ) { + /* Adobe CJK fonts are off in Gravity PDF's config */ + $options = $mapper->getLanguageOptions( str_replace( '_', '-', $language ), false ); + $key = is_array( $options ) && is_string( $options[1] ?? null ) ? $options[1] : ''; + if ( $key !== '' ) { + $keys[ $key ][] = $language; + } + } + + return $keys; + } + /** * mPDF's backupSubsFont, Arimo first. mPDF reads which characters a font has from its ranges file and downloads only a * font that has them, but Sun-ExtA is listed only once it's on disk, as it would download 22.9 MB mid-render. diff --git a/src/Model/Model_Required_Fonts.php b/src/Model/Model_Required_Fonts.php index d5c3705ae..656853b73 100644 --- a/src/Model/Model_Required_Fonts.php +++ b/src/Model/Model_Required_Fonts.php @@ -55,10 +55,10 @@ class Model_Required_Fonts extends Helper_Abstract_Model { protected $log; /** - * @var string[]|null + * @var array|null * @since 6.19.0 */ - protected $selected_font_keys; + protected $selected_font_counts; /** * Work out the required fonts from the font sources and the saved settings, logging forms it can't read @@ -77,7 +77,7 @@ public function __construct( Model_Font_Sources $font_sources, Helper_Abstract_O * @since 6.19.0 */ public function flush(): void { - $this->selected_font_keys = null; + $this->selected_font_counts = null; } /** @@ -114,15 +114,22 @@ public function get_font_files( $key ): array { * @since 6.19.0 */ public function get_required_font_files(): array { - $keys = array_merge( - [ $this->normalise_font( $this->options->get_option( 'default_font', '' ) ) ], - $this->font_sources->get_language_font_keys(), - $this->get_selected_font_keys() - ); + return $this->get_reason_files( $this->get_font_reasons() ); + } + /** + * The files of the keys get_font_reasons() returned, plus every required source's + * + * @param array $reasons + * + * @return string[] + * + * @since 6.19.0 + */ + public function get_reason_files( array $reasons ): array { $files = []; - foreach ( array_unique( $keys ) as $key ) { - $files = array_merge( $files, $this->get_font_files( $key ) ); + foreach ( array_keys( $reasons ) as $key ) { + $files = array_merge( $files, $this->get_font_files( (string) $key ) ); } foreach ( $this->font_sources->get_sources() as $id => $source ) { @@ -134,6 +141,55 @@ public function get_required_font_files(): array { return array_values( array_unique( $files ) ); } + /** + * Why each required font key is required: the default font, a site language's, selected by that many PDFs, in a + * required source and, with $network, used by that many other sites + * + * @return array + * + * @since 6.19.0 + */ + public function get_font_reasons( bool $network = false ): array { + $none = [ + 'default' => false, + 'language' => false, + 'pdfs' => 0, + 'source' => false, + 'sites' => 0, + ]; + $reasons = []; + + $default = $this->normalise_font( $this->options->get_option( 'default_font', '' ) ); + if ( $default !== '' ) { + $reasons[ $default ] = $none; + $reasons[ $default ]['default'] = true; + } + + foreach ( $this->font_sources->get_language_font_keys() as $key ) { + $reasons[ $key ] = $reasons[ $key ] ?? $none; + $reasons[ $key ]['language'] = true; + } + + foreach ( $this->get_selected_font_counts() as $key => $count ) { + $reasons[ $key ] = $reasons[ $key ] ?? $none; + $reasons[ $key ]['pdfs'] = $count; + } + + foreach ( $this->font_sources->get_sources() as $source ) { + foreach ( empty( $source['required'] ) ? [] : array_keys( $source['families'] ?? [] ) as $key ) { + $reasons[ $key ] = $reasons[ $key ] ?? $none; + $reasons[ $key ]['source'] = true; + } + } + + foreach ( $network ? $this->get_other_site_font_counts() : [] as $key => $count ) { + $reasons[ $key ] = $reasons[ $key ] ?? $none; + $reasons[ $key ]['sites'] = $count; + } + + return $reasons; + } + /** * The registered fonts among a PDF's settings, so template font pickers count too * @@ -267,48 +323,127 @@ public function get_form_pdfs( $display_meta, int $form_id ): array { } /** - * The fonts selected in every active form's PDFs + * How many of the active forms' PDFs select each font, read once per request * - * @return string[] + * @return array * * @since 6.19.0 */ - protected function get_selected_font_keys(): array { - if ( $this->selected_font_keys === null ) { - $keys = []; - - /* A page at a time, so a site with many forms doesn't hold every form's meta at once */ - $offset = 0; - do { - $page = $this->get_pdf_form_meta( $offset, $this->form_page ); - foreach ( $page as $row ) { - foreach ( $this->get_form_pdfs( $row['display_meta'], (int) $row['form_id'] ) as $pdf ) { - $keys = array_merge( $keys, $this->get_pdf_fonts( $pdf ) ); + protected function get_selected_font_counts(): array { + if ( $this->selected_font_counts === null ) { + $this->selected_font_counts = $this->count_pdf_fonts(); + } + + return $this->selected_font_counts; + } + + /** + * How many of a site's active form PDFs select each font + * + * @param string $prefix Another site's table prefix, or '' for this site + * + * @return array + * + * @since 6.19.0 + */ + protected function count_pdf_fonts( string $prefix = '' ): array { + $counts = []; + + /* A page at a time, so a site with many forms doesn't hold every form's meta at once */ + $offset = 0; + do { + $page = $this->get_pdf_form_meta( $offset, $this->form_page, $prefix ); + foreach ( $page as $row ) { + foreach ( $this->get_form_pdfs( $row['display_meta'], (int) $row['form_id'] ) as $pdf ) { + foreach ( $this->get_pdf_fonts( $pdf ) as $font ) { + $counts[ $font ] = ( $counts[ $font ] ?? 0 ) + 1; } } + } + + $offset += $this->form_page; + $full = count( $page ) === $this->form_page; + } while ( $full ); + + return $counts; + } + + /** + * How many other sites of the network use each font. Read without switch_to_blog(), so no other site's plugins or + * filters run. + * + * @return array + * + * @since 6.19.0 + */ + protected function get_other_site_font_counts(): array { + if ( ! is_multisite() ) { + return []; + } + + $counts = []; + $sites = get_sites( + [ + 'fields' => 'ids', + 'number' => 0, + 'site__not_in' => [ get_current_blog_id() ], + ] + ); - $offset += $this->form_page; - $full = count( $page ) === $this->form_page; - } while ( $full ); + foreach ( $sites as $site_id ) { + foreach ( $this->get_site_font_keys( (int) $site_id ) as $key ) { + $counts[ $key ] = ( $counts[ $key ] ?? 0 ) + 1; + } + } - $this->selected_font_keys = array_values( array_unique( $keys ) ); + return $counts; + } + + /** + * The fonts a site uses (its default font, locale and PDF fonts), read from its tables by prefix rather than by + * switching to it + * + * @param int $site_id + * @param string $unset_default The default font of a site with none saved + * + * @return string[] Font keys + * + * @since 6.19.0 + */ + public function get_site_font_keys( int $site_id, string $unset_default = '' ): array { + global $wpdb; + + $prefix = $wpdb->get_blog_prefix( $site_id ); + $options = $wpdb->get_results( "SELECT option_name, option_value FROM {$prefix}options WHERE option_name IN ( 'gfpdf_settings', 'gf_db_version', 'WPLANG' )", OBJECT_K ); //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $options = wp_list_pluck( (array) $options, 'option_value' ); + + $settings = maybe_unserialize( $options['gfpdf_settings'] ?? '' ); + $default = $this->normalise_font( is_array( $settings ) ? ( $settings['default_font'] ?? '' ) : '' ); + $locales = $this->font_sources->map_language_font_locales( [ $options['WPLANG'] ?? '' ] ); + $keys = array_merge( array_map( 'strval', array_keys( $locales ) ), [ $default !== '' ? $default : $unset_default ] ); + + /* Gravity Forms records its version as it creates a site's tables, so a site without one has no forms */ + if ( isset( $options['gf_db_version'] ) ) { + $keys = array_merge( $keys, array_keys( $this->count_pdf_fonts( $prefix ) ) ); } - return $this->selected_font_keys; + return array_values( array_unique( array_filter( array_map( [ $this, 'normalise_font' ], $keys ) ) ) ); } /** * A page of the stored meta of the active forms with PDFs, read raw so Gravity Forms doesn't build their fields * + * @param string $prefix Another site's table prefix, or '' for this site + * * @return array[] Rows of `form_id` and `display_meta` * * @since 6.19.0 */ - protected function get_pdf_form_meta( int $offset, int $limit ): array { + protected function get_pdf_form_meta( int $offset, int $limit, string $prefix = '' ): array { global $wpdb; - $meta_table = GFFormsModel::get_meta_table_name(); - $form_table = GFFormsModel::get_form_table_name(); + $meta_table = $prefix === '' ? GFFormsModel::get_meta_table_name() : $prefix . 'gf_form_meta'; + $form_table = $prefix === '' ? GFFormsModel::get_form_table_name() : $prefix . 'gf_form'; /* The table names come from Gravity Forms */ //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared diff --git a/src/assets/js/react/actions/downloadedFonts.js b/src/assets/js/react/actions/downloadedFonts.js new file mode 100644 index 000000000..6660d2ecb --- /dev/null +++ b/src/assets/js/react/actions/downloadedFonts.js @@ -0,0 +1,39 @@ +/* Redux action types */ +export const GET_DOWNLOADED_FONTS = 'GET_DOWNLOADED_FONTS'; +export const GET_DOWNLOADED_FONTS_SUCCESS = 'GET_DOWNLOADED_FONTS_SUCCESS'; +export const GET_DOWNLOADED_FONTS_ERROR = 'GET_DOWNLOADED_FONTS_ERROR'; +export const DELETE_DOWNLOADED_FONT = 'DELETE_DOWNLOADED_FONT'; +export const DELETE_DOWNLOADED_FONT_SUCCESS = 'DELETE_DOWNLOADED_FONT_SUCCESS'; +export const DELETE_DOWNLOADED_FONT_ERROR = 'DELETE_DOWNLOADED_FONT_ERROR'; + +/** + * @package Gravity PDF + * @copyright Copyright (c) 2026, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + * @since 6.19.0 + */ + +/** + * Request the downloaded fonts + * + * @return {{ type: string }} action object + * + * @since 6.19.0 + */ +export const getDownloadedFonts = () => ({ + type: GET_DOWNLOADED_FONTS, +}); + +/** + * Delete a downloaded font + * + * @param {{ id: string, label: string }} font + * + * @return {{ type: string, payload: { id: string, label: string } }} action object + * + * @since 6.19.0 + */ +export const deleteDownloadedFont = (font) => ({ + type: DELETE_DOWNLOADED_FONT, + payload: font, +}); diff --git a/src/assets/js/react/api/downloadedFonts.js b/src/assets/js/react/api/downloadedFonts.js new file mode 100644 index 000000000..d17db719c --- /dev/null +++ b/src/assets/js/react/api/downloadedFonts.js @@ -0,0 +1,68 @@ +/* APIs */ +import { api, getJsonString } from './api'; + +/** + * @package Gravity PDF + * @copyright Copyright (c) 2026, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + * @since 6.19.0 + */ + +/** + * @typedef {Object} DownloadedFont + * @property {string} id - mPDF font key + * @property {string} label - Display name + * @property {Array<{ style: string, file: string, size: number }>} files - Faces on disk + * @property {string} size_label - Size on disk, formatted + * @property {Array} in_use - Why it can't be deleted + * @property {boolean} auto_install - Whether it downloads again on its own + */ + +/** + * Send a request to the downloaded fonts endpoint + * + * @param {string} method + * @param {string} path + * + * @return {Promise<{ body: Object, status: number, ok: boolean }>} The parsed response + * + * @since 6.19.0 + */ +const request = async (method, path) => { + const response = await api(GFPDF.restUrl + 'fonts/downloaded' + path, { + method, + headers: { + 'X-WP-Nonce': GFPDF.restNonce, + Accept: 'application/json', + }, + }); + + const text = await response.text(); + + return { + body: getJsonString(text), + status: response.status, + ok: response.ok, + }; +}; + +/** + * Get the downloaded fonts (GET) + * + * @return {Promise<{ body: { fonts: Array, can_delete: boolean }, status: number, ok: boolean }>} The list + * + * @since 6.19.0 + */ +export const apiGetDownloadedFonts = () => request('GET', ''); + +/** + * Delete a downloaded font's files (DELETE) + * + * @param {string} id - mPDF font key + * + * @return {Promise<{ body: Object, status: number, ok: boolean }>} The files removed, or the error + * + * @since 6.19.0 + */ +export const apiDeleteDownloadedFont = (id) => + request('DELETE', '/' + encodeURIComponent(id)); diff --git a/src/assets/js/react/components/FontManager/DownloadedFonts.js b/src/assets/js/react/components/FontManager/DownloadedFonts.js new file mode 100644 index 000000000..1de9b856f --- /dev/null +++ b/src/assets/js/react/components/FontManager/DownloadedFonts.js @@ -0,0 +1,239 @@ +/* Dependencies */ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { sprintf } from 'sprintf-js'; +/* Redux actions */ +import { + getDownloadedFonts as getDownloadedFontsAction, + deleteDownloadedFont as deleteDownloadedFontAction, +} from '../../actions/downloadedFonts'; +/* Components */ +import Alert from '../Alert/Alert'; +import Spinner from '../Spinner'; +/* Utilities */ +import { checkFontListIncludes } from '../../utilities/FontManager/fontManagerReducer'; + +/** + * @package Gravity PDF + * @copyright Copyright (c) 2026, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + * @since 6.19.0 + */ + +/** + * Whether the font's name, key or a file name has the search keyword, matched as the custom font list does + * + * @param {Object} font + * @param {string} keyword + * + * @return {boolean} Whether to show the font + * + * @since 6.19.0 + */ +export const matchesKeyword = (font, keyword) => { + const search = keyword.toLowerCase(); + + return [font.label, font.id, ...font.files.map((face) => face.file)].some( + (text) => checkFontListIncludes(text, search) + ); +}; + +const STYLES = { + R: 'fontListRegular', + B: 'fontListBold', + I: 'fontListItalics', + BI: 'fontListBoldItalics', +}; + +/** + * The core fonts downloaded into the fonts folder, which can be deleted while unused + * + * @param {Object} props + * @param {boolean} props.loading + * @param {Array} props.fonts + * @param {boolean} props.canDelete + * @param {string} props.deletingId + * @param {string} props.keyword + * @param {Object} props.msg + * @param {Function} props.getDownloadedFonts + * @param {Function} props.deleteDownloadedFont + * + * @return {JSX.Element} DownloadedFonts component + * + * @since 6.19.0 + */ +export const DownloadedFonts = ({ + loading, + fonts, + canDelete, + deletingId, + keyword, + msg, + getDownloadedFonts, + deleteDownloadedFont, +}) => { + useEffect(() => { + getDownloadedFonts(); + }, [getDownloadedFonts]); + + const handleDelete = (font) => { + const confirmation = font.auto_install + ? GFPDF.fontManagerDownloadedConfirm + : GFPDF.fontManagerDownloadedConfirmOffline; + + // eslint-disable-next-line @wordpress/valid-sprintf + if (window.confirm(sprintf(confirmation, font.label))) { + deleteDownloadedFont({ id: font.id, label: font.label }); + } + }; + + const deleteLabel = (font) => + // eslint-disable-next-line @wordpress/valid-sprintf + sprintf(GFPDF.fontManagerDownloadedDeleteAriaLabel, font.label); + + const list = fonts.filter((font) => matchesKeyword(font, keyword)); + + return ( +
+

+ {GFPDF.fontManagerDownloadedTitle} +

+

{GFPDF.fontManagerDownloadedDesc}

+ + {!canDelete && fonts.length > 0 && ( +

+ {GFPDF.fontManagerDownloadedReadOnly} +

+ )} + + {msg.error && } + +
+ {msg.success && ( +
+

{msg.success}

+
+ )} +
+ + {loading && } + + {!loading && list.length === 0 && ( +

+ {fonts.length === 0 + ? GFPDF.fontManagerDownloadedEmpty + : GFPDF.searchResultEmpty} +

+ )} + + {!loading && list.length > 0 && ( + + + + + + + + + + + {list.map((font) => ( + + + + + + + + ))} + +
+ {GFPDF.fontManagerDownloadedFont} + + {GFPDF.fontManagerDownloadedStyles} + + {GFPDF.fontManagerDownloadedSize} + + {GFPDF.fontManagerDownloadedStatus} + +
{font.label} + {font.files + .map( + (face) => GFPDF[STYLES[face.style]] + ) + .join(', ')} + {font.size_label} + {font.in_use.length > 0 + ? font.in_use.join(', ') + : GFPDF.fontManagerDownloadedUnused} + + {deletingId === font.id ? ( + + ) : ( + + )} +
+ )} +
+ ); +}; + +/** + * PropTypes + * + * @since 6.19.0 + */ +DownloadedFonts.propTypes = { + loading: PropTypes.bool.isRequired, + fonts: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + files: PropTypes.arrayOf(PropTypes.object).isRequired, + size_label: PropTypes.string.isRequired, + in_use: PropTypes.arrayOf(PropTypes.string).isRequired, + auto_install: PropTypes.bool.isRequired, + }) + ).isRequired, + canDelete: PropTypes.bool.isRequired, + deletingId: PropTypes.string.isRequired, + keyword: PropTypes.string.isRequired, + msg: PropTypes.object.isRequired, + getDownloadedFonts: PropTypes.func.isRequired, + deleteDownloadedFont: PropTypes.func.isRequired, +}; + +/** + * Map redux state to props + * + * @param {Object} state + * + * @return {Object} mapped state + * + * @since 6.19.0 + */ +const mapStateToProps = (state) => ({ + ...state.downloadedFonts, +}); + +export default connect(mapStateToProps, { + getDownloadedFonts: getDownloadedFontsAction, + deleteDownloadedFont: deleteDownloadedFontAction, +})(DownloadedFonts); diff --git a/src/assets/js/react/components/FontManager/FontManagerBody.js b/src/assets/js/react/components/FontManager/FontManagerBody.js index 9082198ad..c21b53a66 100644 --- a/src/assets/js/react/components/FontManager/FontManagerBody.js +++ b/src/assets/js/react/components/FontManager/FontManagerBody.js @@ -17,6 +17,7 @@ import { import Alert from '../Alert/Alert'; import SearchBox from './SearchBox'; import FontList from './FontList'; +import DownloadedFonts from './DownloadedFonts'; import AddFont from './AddFont'; import UpdateFont from './UpdateFont'; import initialState from './InitialAddUpdateState'; @@ -582,6 +583,8 @@ export class FontManagerBody extends Component { )} + +
diff --git a/src/assets/js/react/reducers/downloadedFontsReducer.js b/src/assets/js/react/reducers/downloadedFontsReducer.js new file mode 100644 index 000000000..5ee1a196d --- /dev/null +++ b/src/assets/js/react/reducers/downloadedFontsReducer.js @@ -0,0 +1,87 @@ +/* Redux action types */ +import { + GET_DOWNLOADED_FONTS, + GET_DOWNLOADED_FONTS_SUCCESS, + GET_DOWNLOADED_FONTS_ERROR, + DELETE_DOWNLOADED_FONT, + DELETE_DOWNLOADED_FONT_SUCCESS, + DELETE_DOWNLOADED_FONT_ERROR, +} from '../actions/downloadedFonts'; +import { RESET_SEARCH_RESULT, SEARCH_FONT_LIST } from '../actions/fontManager'; + +/** + * @package Gravity PDF + * @copyright Copyright (c) 2026, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + * @since 6.19.0 + */ + +/** + * @typedef DownloadedFontsState + * @property {boolean} loading - Whether the list is loading + * @property {Array} fonts - The downloaded fonts + * @property {boolean} canDelete - Whether the user may delete them + * @property {string} deletingId - The font being deleted + * @property {string} keyword - The Font Manager search, which filters this list too + * @property {Object} msg - `success` or `error` notice + */ +export const initialState = { + loading: false, + fonts: [], + canDelete: false, + deletingId: '', + keyword: '', + msg: {}, +}; + +/** + * The "downloadedFonts" reducer + * + * @param {DownloadedFontsState} state + * @param {Object} action + * + * @return {DownloadedFontsState} state + * + * @since 6.19.0 + */ +export default function (state = initialState, action) { + const { payload } = action; + + switch (action.type) { + case GET_DOWNLOADED_FONTS: + return { ...state, loading: true, msg: {} }; + + case GET_DOWNLOADED_FONTS_SUCCESS: + return { + ...state, + loading: false, + fonts: payload.fonts, + canDelete: payload.can_delete, + }; + + case GET_DOWNLOADED_FONTS_ERROR: + return { ...state, loading: false, msg: { error: payload } }; + + case DELETE_DOWNLOADED_FONT: + return { ...state, deletingId: payload.id, msg: {} }; + + case DELETE_DOWNLOADED_FONT_SUCCESS: + return { + ...state, + deletingId: '', + fonts: state.fonts.filter((font) => font.id !== payload.id), + msg: { success: payload.msg }, + }; + + case DELETE_DOWNLOADED_FONT_ERROR: + return { ...state, deletingId: '', msg: { error: payload } }; + + case SEARCH_FONT_LIST: + return { ...state, keyword: payload }; + + case RESET_SEARCH_RESULT: + return { ...state, keyword: '' }; + } + + return state; +} diff --git a/src/assets/js/react/reducers/index.js b/src/assets/js/react/reducers/index.js index 18fd5b6e2..0e72b32e3 100644 --- a/src/assets/js/react/reducers/index.js +++ b/src/assets/js/react/reducers/index.js @@ -1,6 +1,7 @@ import { combineReducers } from 'redux'; import templateReducer from './templateReducer'; import fontManagerReducer from './fontManagerReducer'; +import downloadedFontsReducer from './downloadedFontsReducer'; /** * Combine our Redux reducers for use in a single store @@ -11,4 +12,5 @@ import fontManagerReducer from './fontManagerReducer'; export default combineReducers({ template: templateReducer, fontManager: fontManagerReducer, + downloadedFonts: downloadedFontsReducer, }); diff --git a/src/assets/js/react/sagas/downloadedFonts.js b/src/assets/js/react/sagas/downloadedFonts.js new file mode 100644 index 000000000..975f08030 --- /dev/null +++ b/src/assets/js/react/sagas/downloadedFonts.js @@ -0,0 +1,98 @@ +/* Dependencies */ +import { call, put, takeLatest } from 'redux-saga/effects'; +import { sprintf } from 'sprintf-js'; +/* APIs */ +import { + apiGetDownloadedFonts, + apiDeleteDownloadedFont, +} from '../api/downloadedFonts'; +/* Redux action types */ +import { + GET_DOWNLOADED_FONTS, + GET_DOWNLOADED_FONTS_SUCCESS, + GET_DOWNLOADED_FONTS_ERROR, + DELETE_DOWNLOADED_FONT, + DELETE_DOWNLOADED_FONT_SUCCESS, + DELETE_DOWNLOADED_FONT_ERROR, +} from '../actions/downloadedFonts'; + +/** + * @package Gravity PDF + * @copyright Copyright (c) 2026, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + * @since 6.19.0 + */ + +/** + * @since 6.19.0 + */ +export function* watchGetDownloadedFonts() { + yield takeLatest(GET_DOWNLOADED_FONTS, getDownloadedFonts); +} + +/** + * Load the downloaded fonts + * + * @since 6.19.0 + */ +export function* getDownloadedFonts() { + try { + const response = yield call(apiGetDownloadedFonts); + + if (!response.ok) { + throw response; + } + + yield put({ + type: GET_DOWNLOADED_FONTS_SUCCESS, + payload: response.body, + }); + } catch (error) { + yield put({ + type: GET_DOWNLOADED_FONTS_ERROR, + payload: GFPDF.addFatalError, + }); + } +} + +/** + * @since 6.19.0 + */ +export function* watchDeleteDownloadedFont() { + yield takeLatest(DELETE_DOWNLOADED_FONT, deleteDownloadedFont); +} + +/** + * Delete a downloaded font, showing the server's reason when it refuses (in use, or already gone) + * + * @param {Object} params + * @param {{ id: string, label: string }} params.payload + * + * @since 6.19.0 + */ +export function* deleteDownloadedFont({ payload }) { + try { + const response = yield call(apiDeleteDownloadedFont, payload.id); + + if (!response.ok) { + throw response; + } + + yield put({ + type: DELETE_DOWNLOADED_FONT_SUCCESS, + payload: { + id: payload.id, + // eslint-disable-next-line @wordpress/valid-sprintf + msg: sprintf(GFPDF.fontManagerDownloadedDeleted, payload.label), + }, + }); + } catch (error) { + const refused = + error && [404, 409].includes(error.status) && error.body?.message; + + yield put({ + type: DELETE_DOWNLOADED_FONT_ERROR, + payload: refused || GFPDF.addFatalError, + }); + } +} diff --git a/src/assets/js/react/sagas/index.js b/src/assets/js/react/sagas/index.js index fefcf27e7..d838bfdba 100644 --- a/src/assets/js/react/sagas/index.js +++ b/src/assets/js/react/sagas/index.js @@ -13,6 +13,10 @@ import { watchDeleteFont, watchGetCustomFontListSuccess, } from './fontManager'; +import { + watchGetDownloadedFonts, + watchDeleteDownloadedFont, +} from './downloadedFonts'; /** * @package Gravity PDF @@ -36,5 +40,7 @@ export default function* rootSaga() { watchEditFont(), watchDeleteFont(), watchGetCustomFontListSuccess(), + watchGetDownloadedFonts(), + watchDeleteDownloadedFont(), ]); } diff --git a/src/assets/scss/Component/FontManager/_font-manager.scss b/src/assets/scss/Component/FontManager/_font-manager.scss index 310c1d916..bba029c3a 100644 --- a/src/assets/scss/Component/FontManager/_font-manager.scss +++ b/src/assets/scss/Component/FontManager/_font-manager.scss @@ -143,6 +143,23 @@ } } + .downloaded-fonts { + margin-top: 2rem; + + h3 { + margin-bottom: 0.25rem; + } + + table { + margin-top: 0.5rem; + } + + .gfpdf-spinner { + display: block; + margin: 1rem auto; + } + } + /* Add font */ .add-update-font-column { display: flex; diff --git a/tests/js-unit/react/components/FontManager/DownloadedFonts.test.js b/tests/js-unit/react/components/FontManager/DownloadedFonts.test.js new file mode 100644 index 000000000..a4fc3dfec --- /dev/null +++ b/tests/js-unit/react/components/FontManager/DownloadedFonts.test.js @@ -0,0 +1,201 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { findByTestAttr } from '../../testUtils'; +import { + DownloadedFonts, + matchesKeyword, +} from '../../../../../src/assets/js/react/components/FontManager/DownloadedFonts'; + +describe('FontManager - DownloadedFonts.js', () => { + const garuda = { + id: 'garuda', + label: 'Garuda (Thai)', + source: 'core', + files: [ + { style: 'R', file: 'Garuda.ttf', size: 2048 }, + { style: 'B', file: 'Garuda-Bold.ttf', size: 2048 }, + ], + size_label: '4 KB', + in_use: [], + auto_install: true, + }; + + const sunExtA = { + id: 'sun-exta', + label: 'Sun Ext', + source: 'core', + files: [{ style: 'R', file: 'Sun-ExtA.ttf', size: 22900000 }], + size_label: '21.8 MB', + in_use: ['Default font', 'Used by 3 PDFs'], + auto_install: true, + }; + + const props = (overrides = {}) => ({ + loading: false, + fonts: [garuda, sunExtA], + canDelete: true, + deletingId: '', + keyword: '', + msg: {}, + getDownloadedFonts: jest.fn(), + deleteDownloadedFont: jest.fn(), + ...overrides, + }); + + const deleteButton = (wrapper, index) => + findByTestAttr(wrapper, 'downloaded-font').at(index).find('button'); + + beforeAll(() => { + Object.assign(window.GFPDF, { + fontManagerDownloadedConfirm: 'Delete %s?', + fontManagerDownloadedConfirmOffline: + 'Delete %s? Downloads are off.', + fontManagerDownloadedDeleteAriaLabel: 'Delete %s', + fontManagerDownloadedUnused: 'Not in use', + fontManagerDownloadedEmpty: 'No fonts have been downloaded.', + fontManagerDownloadedReadOnly: 'Read only', + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('requests the list on mount', () => { + const p = props(); + mount(); + + expect(p.getDownloadedFonts).toHaveBeenCalledTimes(1); + }); + + test('lists each font with its styles, size and in-use reasons', () => { + const wrapper = shallow(); + const rows = findByTestAttr(wrapper, 'downloaded-font'); + + expect(rows.length).toBe(2); + expect(rows.at(0).find('th').text()).toBe('Garuda (Thai)'); + expect(rows.at(0).find('td').at(0).text()).toBe('Regular, Bold'); + expect(rows.at(0).find('td').at(1).text()).toBe('4 KB'); + expect(rows.at(0).find('td').at(2).text()).toBe('Not in use'); + expect(rows.at(1).find('td').at(1).text()).toBe('21.8 MB'); + expect(rows.at(1).find('td').at(2).text()).toBe( + 'Default font, Used by 3 PDFs' + ); + expect(deleteButton(wrapper, 0).prop('aria-label')).toBe( + 'Delete Garuda (Thai)' + ); + }); + + test('disables Delete for an in-use font', () => { + const wrapper = shallow(); + + expect(deleteButton(wrapper, 0).prop('disabled')).toBe(false); + expect(deleteButton(wrapper, 1).prop('disabled')).toBe(true); + }); + + test('disables Delete and says why for a user who cannot delete', () => { + const wrapper = shallow( + + ); + + expect(deleteButton(wrapper, 0).prop('disabled')).toBe(true); + expect(wrapper.find('.description').text()).toBe('Read only'); + }); + + test('shows a spinner for the font being deleted and disables the rest', () => { + const wrapper = shallow( + + ); + const rows = findByTestAttr(wrapper, 'downloaded-font'); + + expect(rows.at(0).find('Spinner').length).toBe(1); + expect(deleteButton(wrapper, 1).prop('disabled')).toBe(true); + }); + + test('deletes after the confirm', () => { + const p = props(); + const confirm = jest.spyOn(window, 'confirm').mockReturnValue(true); + const wrapper = shallow(); + + deleteButton(wrapper, 0).simulate('click'); + + expect(confirm).toHaveBeenCalledWith('Delete Garuda (Thai)?'); + expect(p.deleteDownloadedFont).toHaveBeenCalledWith({ + id: 'garuda', + label: 'Garuda (Thai)', + }); + }); + + test('does not delete when the confirm is cancelled', () => { + const p = props(); + jest.spyOn(window, 'confirm').mockReturnValue(false); + const wrapper = shallow(); + + deleteButton(wrapper, 0).simulate('click'); + + expect(p.deleteDownloadedFont).not.toHaveBeenCalled(); + }); + + test('warns that a font will not come back with downloads off', () => { + const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false); + const wrapper = shallow( + + ); + + deleteButton(wrapper, 0).simulate('click'); + + expect(confirm).toHaveBeenCalledWith( + 'Delete Garuda (Thai)? Downloads are off.' + ); + }); + + test('filters by the search keyword', () => { + const wrapper = shallow( + + ); + + expect(findByTestAttr(wrapper, 'downloaded-font').length).toBe(1); + + wrapper.setProps({ keyword: 'nothing' }); + expect(findByTestAttr(wrapper, 'downloaded-fonts-empty').text()).toBe( + GFPDF.searchResultEmpty + ); + }); + + test('says when nothing is downloaded', () => { + const wrapper = shallow(); + + expect(findByTestAttr(wrapper, 'downloaded-fonts-empty').text()).toBe( + 'No fonts have been downloaded.' + ); + expect(wrapper.find('table').length).toBe(0); + }); + + test('shows a spinner while loading', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('Spinner').length).toBe(1); + expect(wrapper.find('table').length).toBe(0); + }); + + test('shows the success and error notices', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('.notice-success').text()).toBe('Gone.'); + + wrapper.setProps({ msg: { error: 'In use' } }); + expect(wrapper.find('Alert').prop('msg')).toBe('In use'); + }); + + test('matchesKeyword() checks the label, key and file names', () => { + expect(matchesKeyword(garuda, '')).toBe(true); + expect(matchesKeyword(garuda, 'THAI')).toBe(true); + expect(matchesKeyword(garuda, 'garuda-bold')).toBe(true); + expect(matchesKeyword(garuda, 'sun')).toBe(false); + }); +}); diff --git a/tests/js-unit/react/reducers/downloadedFontsReducer.test.js b/tests/js-unit/react/reducers/downloadedFontsReducer.test.js new file mode 100644 index 000000000..1e0348a4e --- /dev/null +++ b/tests/js-unit/react/reducers/downloadedFontsReducer.test.js @@ -0,0 +1,90 @@ +import reducer, { + initialState, +} from '../../../../src/assets/js/react/reducers/downloadedFontsReducer'; +import { + GET_DOWNLOADED_FONTS, + GET_DOWNLOADED_FONTS_SUCCESS, + GET_DOWNLOADED_FONTS_ERROR, + DELETE_DOWNLOADED_FONT, + DELETE_DOWNLOADED_FONT_SUCCESS, + DELETE_DOWNLOADED_FONT_ERROR, +} from '../../../../src/assets/js/react/actions/downloadedFonts'; +import { + SEARCH_FONT_LIST, + RESET_SEARCH_RESULT, +} from '../../../../src/assets/js/react/actions/fontManager'; + +describe('Reducers - downloadedFontsReducer.js', () => { + const fonts = [{ id: 'garuda' }, { id: 'freeserif' }]; + + test('returns the initial state', () => { + expect(reducer(undefined, { type: 'UNKNOWN' })).toEqual(initialState); + }); + + test('loads the list', () => { + const loading = reducer( + { ...initialState, msg: { error: 'old' } }, + { type: GET_DOWNLOADED_FONTS } + ); + expect(loading.loading).toBe(true); + expect(loading.msg).toEqual({}); + + const loaded = reducer(loading, { + type: GET_DOWNLOADED_FONTS_SUCCESS, + payload: { fonts, can_delete: true }, + }); + expect(loaded.loading).toBe(false); + expect(loaded.fonts).toEqual(fonts); + expect(loaded.canDelete).toBe(true); + }); + + test('records a list error', () => { + const state = reducer( + { ...initialState, loading: true }, + { type: GET_DOWNLOADED_FONTS_ERROR, payload: 'error' } + ); + + expect(state.loading).toBe(false); + expect(state.msg).toEqual({ error: 'error' }); + }); + + test('removes a deleted font', () => { + const deleting = reducer( + { ...initialState, fonts, msg: { success: 'old' } }, + { type: DELETE_DOWNLOADED_FONT, payload: { id: 'garuda' } } + ); + expect(deleting.deletingId).toBe('garuda'); + expect(deleting.msg).toEqual({}); + + const deleted = reducer(deleting, { + type: DELETE_DOWNLOADED_FONT_SUCCESS, + payload: { id: 'garuda', msg: 'deleted' }, + }); + expect(deleted.deletingId).toBe(''); + expect(deleted.fonts).toEqual([{ id: 'freeserif' }]); + expect(deleted.msg).toEqual({ success: 'deleted' }); + }); + + test('keeps a font whose delete failed', () => { + const state = reducer( + { ...initialState, fonts, deletingId: 'garuda' }, + { type: DELETE_DOWNLOADED_FONT_ERROR, payload: 'In use' } + ); + + expect(state.deletingId).toBe(''); + expect(state.fonts).toEqual(fonts); + expect(state.msg).toEqual({ error: 'In use' }); + }); + + test('follows the Font Manager search', () => { + const searched = reducer(initialState, { + type: SEARCH_FONT_LIST, + payload: 'thai', + }); + expect(searched.keyword).toBe('thai'); + + expect(reducer(searched, { type: RESET_SEARCH_RESULT }).keyword).toBe( + '' + ); + }); +}); diff --git a/tests/js-unit/react/sagas/downloadedFonts.test.js b/tests/js-unit/react/sagas/downloadedFonts.test.js new file mode 100644 index 000000000..041259a0b --- /dev/null +++ b/tests/js-unit/react/sagas/downloadedFonts.test.js @@ -0,0 +1,110 @@ +import { call, put, takeLatest } from 'redux-saga/effects'; +import { + watchGetDownloadedFonts, + getDownloadedFonts, + watchDeleteDownloadedFont, + deleteDownloadedFont, +} from '../../../../src/assets/js/react/sagas/downloadedFonts'; +import { + GET_DOWNLOADED_FONTS, + GET_DOWNLOADED_FONTS_SUCCESS, + GET_DOWNLOADED_FONTS_ERROR, + DELETE_DOWNLOADED_FONT, + DELETE_DOWNLOADED_FONT_SUCCESS, + DELETE_DOWNLOADED_FONT_ERROR, +} from '../../../../src/assets/js/react/actions/downloadedFonts'; +import * as api from '../../../../src/assets/js/react/api/downloadedFonts'; + +describe('Sagas - downloadedFonts.js', () => { + beforeAll(() => { + window.GFPDF.fontManagerDownloadedDeleted = '%s deleted.'; + }); + + test('watchGetDownloadedFonts() takes the latest list request', () => { + expect(watchGetDownloadedFonts().next().value).toEqual( + takeLatest(GET_DOWNLOADED_FONTS, getDownloadedFonts) + ); + }); + + test('watchDeleteDownloadedFont() takes the latest delete', () => { + expect(watchDeleteDownloadedFont().next().value).toEqual( + takeLatest(DELETE_DOWNLOADED_FONT, deleteDownloadedFont) + ); + }); + + test('getDownloadedFonts() stores the list', () => { + const body = { fonts: [], can_delete: true }; + const gen = getDownloadedFonts(); + + expect(gen.next().value).toEqual(call(api.apiGetDownloadedFonts)); + expect(gen.next({ ok: true, body }).value).toEqual( + put({ type: GET_DOWNLOADED_FONTS_SUCCESS, payload: body }) + ); + }); + + test('getDownloadedFonts() reports a failed request', () => { + const gen = getDownloadedFonts(); + gen.next(); + + expect(gen.next({ ok: false, status: 500 }).value).toEqual( + put({ + type: GET_DOWNLOADED_FONTS_ERROR, + payload: GFPDF.addFatalError, + }) + ); + }); + + const deleting = () => { + const gen = deleteDownloadedFont({ + payload: { id: 'garuda', label: 'Garuda' }, + }); + + expect(gen.next().value).toEqual( + call(api.apiDeleteDownloadedFont, 'garuda') + ); + + return gen; + }; + + test('deleteDownloadedFont() reports the deleted font', () => { + expect(deleting().next({ ok: true, body: {} }).value).toEqual( + put({ + type: DELETE_DOWNLOADED_FONT_SUCCESS, + payload: { id: 'garuda', msg: 'Garuda deleted.' }, + }) + ); + }); + + test.each([409, 404])( + 'deleteDownloadedFont() shows why the server refused a %d', + (status) => { + expect( + deleting().next({ + ok: false, + status, + body: { message: 'The font is in use' }, + }).value + ).toEqual( + put({ + type: DELETE_DOWNLOADED_FONT_ERROR, + payload: 'The font is in use', + }) + ); + } + ); + + test('deleteDownloadedFont() reports any other failure', () => { + expect( + deleting().next({ + ok: false, + status: 403, + body: { message: 'Forbidden' }, + }).value + ).toEqual( + put({ + type: DELETE_DOWNLOADED_FONT_ERROR, + payload: GFPDF.addFatalError, + }) + ); + }); +}); diff --git a/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php b/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php index 576358b35..8e5e71007 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php @@ -5,6 +5,7 @@ namespace GFPDF\Controller; use GFAPI; +use GFPDF\Helper\Helper_Data; use GFPDF\Model\Model_Font_Downloader; use GFPDF\Tests\Concerns\CopiesFontFixtures; use GFPDF\Tests\Integration\TestCase; @@ -253,6 +254,80 @@ public function provider_junk_form_meta(): array { ]; } + public function test_the_downloaded_font_routes_are_registered() { + $routes = rest_get_server()->get_routes( Helper_Data::REST_API_BASENAME . 'v1' ); + + $this->assertArrayHasKey( '/' . Helper_Data::REST_API_BASENAME . 'v1/fonts/downloaded', $routes ); + $this->assertArrayHasKey( '/' . Helper_Data::REST_API_BASENAME . 'v1/fonts/downloaded/(?P[a-z0-9_\-]+)', $routes ); + } + + public function test_an_unused_downloaded_font_is_listed_and_deleted_over_rest() { + $this->set_font_user( true ); + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + + $list = $this->dispatch( 'GET', 'fonts/downloaded' ); + $this->assertSame( 200, $list->get_status() ); + $this->assertTrue( $list->get_data()['can_delete'] ); + $this->assertSame( [ 'dejavuserifcondensed' ], array_column( $list->get_data()['fonts'], 'id' ) ); + + $delete = $this->dispatch( 'DELETE', 'fonts/downloaded/dejavuserifcondensed' ); + $this->assertSame( 200, $delete->get_status() ); + $this->assertSame( [ 'DejaVuSerifCondensed.ttf' ], $delete->get_data()['removed'] ); + $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + } + + public function test_an_in_use_downloaded_font_is_refused_over_rest() { + $this->set_font_user( true ); + GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavuserifcondensed' ); + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + + $this->assertSame( 409, $this->dispatch( 'DELETE', 'fonts/downloaded/dejavuserifcondensed' )->get_status() ); + $this->assertSame( 404, $this->dispatch( 'DELETE', 'fonts/downloaded/freeserif' )->get_status() ); + $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + } + + public function test_a_user_without_the_capability_cannot_list_or_delete() { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'editor' ] ) ); + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + + $this->assertSame( 403, $this->dispatch( 'GET', 'fonts/downloaded' )->get_status() ); + $this->assertSame( 403, $this->dispatch( 'DELETE', 'fonts/downloaded/dejavuserifcondensed' )->get_status() ); + $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + } + + public function test_only_a_super_admin_deletes_on_multisite() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $this->set_font_user( false ); + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + + $list = $this->dispatch( 'GET', 'fonts/downloaded' ); + $this->assertSame( 200, $list->get_status() ); + $this->assertFalse( $list->get_data()['can_delete'] ); + $this->assertSame( 403, $this->dispatch( 'DELETE', 'fonts/downloaded/dejavuserifcondensed' )->get_status() ); + $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + } + + /** + * Log in a user who can edit forms, a super admin on Multisite when $super + */ + protected function set_font_user( bool $super ): void { + $user_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + get_userdata( $user_id )->add_cap( 'gravityforms_edit_forms' ); + + if ( $super && is_multisite() ) { + grant_super_admin( $user_id ); + } + + wp_set_current_user( $user_id ); + } + + protected function dispatch( string $method, string $route ): \WP_REST_Response { + return rest_get_server()->dispatch( new \WP_REST_Request( $method, '/' . Helper_Data::REST_API_BASENAME . 'v1/' . $route ) ); + } + /** * Active PDF settings in the font given by $settings, else Arimo */ diff --git a/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php b/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php new file mode 100644 index 000000000..70b99d2d7 --- /dev/null +++ b/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php @@ -0,0 +1,316 @@ +update_option( 'default_font', 'arimo' ); + } + + public function tear_down(): void { + $this->remove_font_fixtures(); + $this->delete_font_failures(); + GPDFAPI::get_options_class()->delete_option( 'default_font' ); + + parent::tear_down(); + } + + public function test_only_downloadable_families_with_files_on_disk_are_listed() { + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSerifCondensed-Bold.ttf' ); + $this->copy_fixture( 'Chewy.ttf', 'gfpdf-Arimo-Regular.ttf' ); + $this->copy_fixture( 'Chewy.ttf', 'DejaVuinfo.txt' ); + + $fonts = $this->downloader()->get_downloaded_fonts(); + $size = filesize( $this->font_fixture( 'DejaVuSerifCondensed.ttf' ) ) + filesize( $this->font_fixture( 'Chewy.ttf' ) ); + + $this->assertSame( + [ + [ + 'id' => 'dejavuserifcondensed', + 'label' => 'Dejavu Serif Condensed', + 'source' => 'core', + 'files' => [ + [ + 'style' => 'R', + 'file' => 'DejaVuSerifCondensed.ttf', + 'size' => filesize( $this->font_fixture( 'DejaVuSerifCondensed.ttf' ) ), + ], + [ + 'style' => 'B', + 'file' => 'DejaVuSerifCondensed-Bold.ttf', + 'size' => filesize( $this->font_fixture( 'Chewy.ttf' ) ), + ], + ], + 'size' => $size, + 'size_label' => size_format( $size, 1 ), + 'in_use' => [], + 'auto_install' => true, + ], + ], + $fonts + ); + } + + public function test_nothing_is_listed_with_an_empty_fonts_folder() { + $this->assertSame( [], $this->downloader()->get_downloaded_fonts() ); + } + + public function test_in_use_families_say_why() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavuserifcondensed' ); + $this->set_languages( [ 'th' ] ); + $this->add_form_with_pdfs( [ [ 'font' => 'freeserif' ], [ 'sidebar_font' => 'FreeSerif' ], [ 'font' => 'arimo' ] ] ); + + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + $this->copy_fixture( 'Chewy.ttf', 'Garuda.ttf' ); + $this->copy_fixture( 'Chewy.ttf', 'FreeMono.ttf' ); + $this->put_freeserif_on_disk(); + + $sources = $this->stub_font_sources( + static function ( array $sources ): array { + $sources['fixture'] = $sources['core']; + $sources['fixture']['required'] = true; + $sources['fixture']['families'] = [ 'fixturemono' => [ 'R' => 'FreeMono.ttf' ] ]; + + return $sources; + } + ); + + $in_use = array_column( $this->downloader( $sources )->get_downloaded_fonts(), 'in_use', 'id' ); + + $this->assertSame( [ 'Default font' ], $in_use['dejavuserifcondensed'] ); + $this->assertSame( [ 'Site language (th)' ], $in_use['garuda'] ); + $this->assertSame( [ 'Used by 2 PDFs' ], $in_use['freeserif'] ); + $this->assertSame( [ 'Required by an add-on' ], $in_use['fixturemono'] ); + } + + public function test_the_downloads_off_flag_is_per_family() { + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + $this->put_freeserif_on_disk(); + + add_filter( + 'gfpdf_auto_install_fonts', + static function ( $download, $file ) { + return $file !== 'FreeSerifBold.ttf'; + }, + 10, + 2 + ); + + $this->assertSame( + [ + 'dejavuserifcondensed' => true, + 'freeserif' => false, + ], + array_column( $this->downloader()->get_downloaded_fonts(), 'auto_install', 'id' ) + ); + } + + public function test_delete_removes_the_family_whatever_its_hash_and_keeps_the_licences() { + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSerifCondensed-Bold.ttf' ); + $this->copy_fixture( 'Chewy.ttf', 'DejaVuinfo.txt' ); + $this->put_freeserif_on_disk(); + + $result = $this->downloader()->delete_downloaded_font( 'dejavuserifcondensed' ); + + $this->assertSame( + [ + 'id' => 'dejavuserifcondensed', + 'removed' => [ 'DejaVuSerifCondensed.ttf', 'DejaVuSerifCondensed-Bold.ttf' ], + 'bytes' => filesize( $this->font_fixture( 'DejaVuSerifCondensed.ttf' ) ) + filesize( $this->font_fixture( 'Chewy.ttf' ) ), + ], + $result + ); + + $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed-Bold.ttf' ) ); + $this->assertFileExists( $this->font_path( 'DejaVuinfo.txt' ) ); + $this->assertFileExists( $this->font_path( 'FreeSerif.ttf' ) ); + $this->assertCount( 1, $this->log_messages( 'Downloaded font deleted' ) ); + } + + public function test_delete_clears_the_familys_failures_and_metrics() { + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + $this->set_font_failures( + [ + 'DejaVuSerifCondensed-Bold.ttf' => $this->failure( time() ), + 'FreeSerif.ttf' => $this->failure(), + ] + ); + $this->set_back_off( 'DejaVuSerifCondensed-Italic.ttf' ); + + $metrics = GPDFAPI::get_misc_class()->get_mpdf_cache_location() . '/ttfontdata/'; + wp_mkdir_p( $metrics ); + file_put_contents( $metrics . 'dejavuserifcondensedB.mtx.json', '{}' ); + file_put_contents( $metrics . 'freeserif.mtx.json', '{}' ); + + try { + $this->assertIsArray( $this->downloader()->delete_downloaded_font( 'dejavuserifcondensed' ) ); + + $this->assertSame( [ 'FreeSerif.ttf' ], array_keys( $this->get_font_failures() ) ); + $this->assertFalse( $this->get_back_off( 'DejaVuSerifCondensed-Italic.ttf' ) ); + $this->assertFileDoesNotExist( $metrics . 'dejavuserifcondensedB.mtx.json' ); + $this->assertFileExists( $metrics . 'freeserif.mtx.json' ); + } finally { + wp_delete_file( $metrics . 'freeserif.mtx.json' ); + } + } + + public function test_an_in_use_family_is_not_deleted() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavuserifcondensed' ); + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + + $result = $this->downloader()->delete_downloaded_font( 'dejavuserifcondensed' ); + + $this->assert_font_error( $result, 'font_in_use', 409 ); + $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + } + + /** + * @dataProvider provider_not_downloaded + */ + public function test_a_family_that_is_not_downloaded_is_not_found( string $key ) { + $this->copy_fixture( 'Chewy.ttf', 'gfpdf-Arimo-Regular.ttf' ); + + $this->assert_font_error( $this->downloader()->delete_downloaded_font( $key ), 'font_not_downloaded', 404 ); + } + + public function provider_not_downloaded(): array { + return [ + 'bundled' => [ 'arimo' ], + 'unknown' => [ 'notafont' ], + 'not on disk' => [ 'freeserif' ], + 'traversal' => [ '../freeserif' ], + ]; + } + + public function test_another_sites_default_font_and_pdfs_are_in_use() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + $this->put_freeserif_on_disk(); + + $blog_id = self::factory()->blog->create(); + switch_to_blog( $blog_id ); + update_option( 'gfpdf_settings', [ 'default_font' => 'DejaVuSerifCondensed' ] ); + restore_current_blog(); + + /* A site without the settings or Gravity Forms tables */ + self::factory()->blog->create(); + + $downloader = $this->downloader(); + + $this->assertSame( + [ + 'dejavuserifcondensed' => [ 'Used on 1 other site' ], + 'freeserif' => [], + ], + array_column( $downloader->get_downloaded_fonts(), 'in_use', 'id' ) + ); + + $this->assert_font_error( $downloader->delete_downloaded_font( 'dejavuserifcondensed' ), 'font_in_use', 409 ); + } + + public function test_another_sites_locale_font_is_in_use() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $this->copy_fixture( 'Chewy.ttf', 'Garuda.ttf' ); + + $blog_id = self::factory()->blog->create(); + switch_to_blog( $blog_id ); + + /* WordPress only keeps a locale it has installed, but this site has no pack for it */ + $installed = static function () { + return [ 'th' ]; + }; + add_filter( 'get_available_languages', $installed ); + update_option( 'WPLANG', 'th' ); + remove_filter( 'get_available_languages', $installed ); + restore_current_blog(); + + $this->assertSame( + [ 'garuda' => [ 'Used on 1 other site' ] ], + array_column( $this->downloader()->get_downloaded_fonts(), 'in_use', 'id' ) + ); + } + + /** + * @param mixed $result + */ + private function assert_font_error( $result, string $code, int $status ): void { + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( $code, $result->get_error_code() ); + $this->assertSame( [ 'status' => $status ], $result->get_error_data() ); + } + + private function downloader( ?Model_Font_Sources $sources = null ): Seamed_Font_Downloader { + $this->log = new TestHandler(); + + return new Seamed_Font_Downloader( new Logger( 'test', [ $this->log ] ), [ 'sources' => $sources ?? $this->stub_font_sources() ] ); + } + + private function add_form_with_pdfs( array $pdfs ): void { + $this->gf_factory()->form->create( + [], + [ + 'title' => 'Fonts', + 'fields' => [], + 'gfpdf_form_settings' => $pdfs, + ] + ); + } + + /** + * @return array[] + */ + private function log_messages( string $message ): array { + return array_values( + array_filter( + $this->log->getRecords(), + static function ( $record ) use ( $message ) { + return $record['message'] === $message; + } + ) + ); + } +} diff --git a/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php b/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php index 71bd4bf16..de563421c 100644 --- a/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php +++ b/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php @@ -108,6 +108,47 @@ public function test_the_required_set_has_the_default_language_and_selected_font ); } + public function test_the_font_reasons_match_the_required_set() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'DejaVuSansCondensed' ); + $this->set_languages( [ 'th' ] ); + $this->add_form_with_pdfs( [ [ 'font' => 'freeserif' ], [ 'font' => 'garuda' ] ] ); + $this->add_form_with_pdfs( [ [ 'sidebar_font' => 'FreeSerif' ] ] ); + + $sources = $this->stub_font_sources( + static function ( array $sources ): array { + $sources['fixture'] = $sources['core']; + $sources['fixture']['required'] = true; + $sources['fixture']['families'] = [ 'fixturemono' => [ 'R' => 'FreeMono.ttf' ] ]; + + return $sources; + } + ); + + $none = [ + 'default' => false, + 'language' => false, + 'pdfs' => 0, + 'source' => false, + 'sites' => 0, + ]; + + $this->assertSame( + [ + 'dejavusanscondensed' => array_merge( $none, [ 'default' => true ] ), + 'garuda' => array_merge( + $none, + [ + 'language' => true, + 'pdfs' => 1, + ] + ), + 'freeserif' => array_merge( $none, [ 'pdfs' => 2 ] ), + 'fixturemono' => array_merge( $none, [ 'source' => true ] ), + ], + $this->required_with( $sources )->get_font_reasons( true ) + ); + } + public function test_the_font_selections_are_read_once_until_flushed() { GPDFAPI::get_options_class()->update_option( 'default_font', 'arimo' ); $this->assertSame( [], $this->required->get_required_font_files() ); @@ -328,6 +369,55 @@ public function test_a_failed_form_query_is_logged() { $this->assertStringContainsString( 'gfpdf_missing_table', $records[0][2]['error'] ); } + public function test_a_sites_fonts_are_its_default_locale_and_pdf_fonts() { + GPDFAPI::get_options_class()->update_option( 'default_font', ' DejaVuSerifCondensed ' ); + /* WordPress only keeps a locale it has installed */ + $this->set_languages( [ 'th' ] ); + update_option( 'WPLANG', 'th' ); + $this->add_form_with_pdfs( [ [ 'font' => 'freeserif' ], [ 'sidebar_font' => 'FreeSerif' ], [ 'font' => 'arimo' ] ] ); + + try { + $keys = $this->required_with( $this->stub_font_sources() )->get_site_font_keys( get_current_blog_id() ); + } finally { + delete_option( 'WPLANG' ); + } + + $this->assertEqualsCanonicalizing( [ 'garuda', 'dejavuserifcondensed', 'freeserif', 'arimo' ], $keys ); + } + + public function test_a_site_with_no_default_font_saved_uses_the_one_given() { + GPDFAPI::get_options_class()->delete_option( 'default_font' ); + $required = $this->required_with( $this->stub_font_sources() ); + + $this->assertSame( [], $required->get_site_font_keys( get_current_blog_id() ) ); + $this->assertSame( [ 'dejavusanscondensed' ], $required->get_site_font_keys( get_current_blog_id(), 'DejaVuSansCondensed' ) ); + + GPDFAPI::get_options_class()->update_option( 'default_font', 'freeserif' ); + $this->assertSame( [ 'freeserif' ], $required->get_site_font_keys( get_current_blog_id(), 'dejavusanscondensed' ) ); + } + + public function test_corrupt_settings_leave_a_sites_default_font_unset() { + update_option( 'gfpdf_settings', 'corrupt' ); + $required = $this->required_with( $this->stub_font_sources() ); + + $this->assertSame( [ 'dejavusanscondensed' ], $required->get_site_font_keys( get_current_blog_id(), 'dejavusanscondensed' ) ); + } + + public function test_a_site_gravity_forms_has_not_run_on_has_no_pdf_fonts() { + $this->add_form_with_pdfs( [ [ 'font' => 'freeserif' ] ] ); + $version = get_option( 'gf_db_version' ); + delete_option( 'gf_db_version' ); + + try { + $keys = $this->required_with( $this->stub_font_sources() )->get_site_font_keys( get_current_blog_id() ); + } finally { + update_option( 'gf_db_version', $version, false ); + } + + $this->assertSame( [], $keys ); + $this->assertSame( [ 'freeserif' ], $this->required_with( $this->stub_font_sources() )->get_site_font_keys( get_current_blog_id() ) ); + } + /** * Each log record as its level name, message and context */ diff --git a/tests/playwright/core/managers/font-manager.spec.ts b/tests/playwright/core/managers/font-manager.spec.ts index 54cc49c7a..c37c227b0 100644 --- a/tests/playwright/core/managers/font-manager.spec.ts +++ b/tests/playwright/core/managers/font-manager.spec.ts @@ -24,9 +24,10 @@ const notAFont = (name: string): FontUpload => ({ buffer: Buffer.from('not a font'), }); -// What the custom font folder holds, to check a failed save left nothing behind and took nothing away -const savedFontFiles = () => - wpCli('ls wp-content/uploads/PDF_EXTENDED_TEMPLATES/fonts'); +const FONTS_DIR = 'wp-content/uploads/PDF_EXTENDED_TEMPLATES/fonts'; + +// What the font folder holds, to check a failed save left nothing behind and took nothing away +const savedFontFiles = () => wpCli(`ls ${FONTS_DIR}`); const openFontManager = async (page: Page) => page @@ -359,4 +360,74 @@ test.describe('Font Manager', () => { ).toBeVisible(); expect(savedFontFiles()).toContain(italics); }); + + test.describe('Downloaded fonts', () => { + const seededFiles: string[] = []; + + // Downloads are off on this site (see tools/mu-plugins), so copy in a core font no other spec uses + const seedCoreFont = (...files: string[]) => { + seededFiles.push(...files); + wpCli( + ...files.map( + (file) => + `cp wp-content/plugins/gravity-pdf/fonts/gfpdf-Arimo-Regular.ttf ${FONTS_DIR}/${file}` + ) + ); + }; + + const downloadedFont = (page: Page, label: string) => + page + .locator('[data-test="downloaded-font"]') + .filter({ hasText: label }); + + test.afterEach(() => + wpCli(`cd ${FONTS_DIR} && rm -f ${seededFiles.splice(0).join(' ')}`) + ); + + test('should delete a downloaded core font that is not in use', async ({ + page, + }) => { + const label = 'Dhyana (Lao)'; + seedCoreFont('Dhyana-Regular.ttf', 'Dhyana-Bold.ttf'); + + await pdf.navigateToNewFormPdf(form.id); + await openFontManager(page); + + const font = downloadedFont(page, label); + await expect(font).toContainText('Regular, Bold'); + await expect(font).toContainText('Not in use'); + + let confirm = ''; + page.once('dialog', (dialog) => { + confirm = dialog.message(); + return dialog.accept(); + }); + await font.getByRole('button', { name: `Delete ${label}` }).click(); + + await expect(page.getByText(`${label} deleted.`)).toBeVisible(); + await expect(font).toHaveCount(0); + expect(confirm).toContain('Font downloads are turned off'); + expect(savedFontFiles()).not.toContain('Dhyana'); + }); + + test('should not delete a downloaded core font in use', async ({ + page, + }) => { + // The default font for this test's requests only (see tools/mu-plugins) + await page.setExtraHTTPHeaders({ + 'X-GPDF-E2E-Default-Font': 'jomolhari', + }); + const label = 'Jomolhari (Tibetan)'; + seedCoreFont('Jomolhari.ttf'); + + await pdf.navigateToNewFormPdf(form.id); + await openFontManager(page); + + const font = downloadedFont(page, label); + await expect(font).toContainText('Default font'); + await expect( + font.getByRole('button', { name: `Delete ${label}` }) + ).toBeDisabled(); + }); + }); }); diff --git a/tools/mu-plugins/gravitypdf.php b/tools/mu-plugins/gravitypdf.php index 8e2511759..e17d18625 100644 --- a/tools/mu-plugins/gravitypdf.php +++ b/tools/mu-plugins/gravitypdf.php @@ -143,6 +143,17 @@ static function ( $config ) { ); } +/* A default font per request: a PDF selecting the font would count it as required in other specs' system reports */ +$gfpdf_e2e_default_font = $gfpdf_e2e_header( 'default_font' ); +if ( $gfpdf_e2e_default_font !== '' ) { + add_filter( + 'gfpdf_get_option_default_font', + static function () use ( $gfpdf_e2e_default_font ) { + return $gfpdf_e2e_default_font; + } + ); +} + /* Other specs save settings and fonts, which move every PDF's cache key */ $gfpdf_e2e_cache_generation = $gfpdf_e2e_header( 'cache_generation' ); if ( $gfpdf_e2e_cache_generation !== '' ) { From 038865905c9a2ec9f74e430f5ddf19255f154d03 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Tue, 3 Nov 2026 20:44:09 +1100 Subject: [PATCH 2/4] Keep font state in one place for every site, and keep every site's fonts in the 6.19 cleanup Every site shares the fonts folder, so the font state about it is now stored the same way on single site and Multisite: the failed downloads record and the 6.19 cleanup's progress are main network options, the language font cache is a site transient, and the failed fonts notice's dismissal lives in the record rather than in each site's settings. The 6.19 cleanup keeps the fonts any site needs, reading each site's settings, locale and PDFs directly through the same reader the Font Manager uses for other sites' fonts. It runs in a cron event that reads the sites in batches within the sync's time budget, so no upgrade request pays for a large network's scan. Co-Authored-By: Claude Opus 5.5 --- src/Controller/Controller_Font_Downloader.php | 1 + .../Controller_Upgrade_Routines.php | 29 +-- src/Model/Model_Actions.php | 33 +--- src/Model/Model_Font_Downloader.php | 186 +++++++++++++----- src/Model/Model_Font_Sources.php | 35 ++-- src/Model/Model_Required_Fonts.php | 36 ++++ src/Model/Model_Uninstall.php | 40 ++-- .../phpunit/Concerns/RecordsFontFailures.php | 15 +- tests/phpunit/Concerns/StubsFontSources.php | 4 +- .../Controller/Test_Controller_Actions.php | 32 ++- .../Test_Controller_Font_Downloader.php | 1 + .../Test_Controller_Upgrade_Routines.php | 68 ++++--- .../Model/Test_Model_Font_Downloader.php | 106 +++++++--- .../Model/Test_Model_Font_Sources.php | 11 +- .../Model/Test_Model_Required_Fonts.php | 89 +++++++++ .../integration/Model/Test_Uninstaller.php | 8 +- .../core/system-status/core-fonts.spec.ts | 2 +- tools/mu-plugins/gravitypdf.php | 16 +- 18 files changed, 512 insertions(+), 200 deletions(-) diff --git a/src/Controller/Controller_Font_Downloader.php b/src/Controller/Controller_Font_Downloader.php index 6dec42f79..9e1a614ca 100644 --- a/src/Controller/Controller_Font_Downloader.php +++ b/src/Controller/Controller_Font_Downloader.php @@ -150,6 +150,7 @@ public function can_delete_fonts(): bool { */ public function add_actions() { add_action( Model_Font_Downloader::SYNC_EVENT, [ $this->model, 'sync' ] ); + add_action( Model_Font_Downloader::CLEANUP_EVENT, [ $this->model, 'cleanup' ] ); add_action( 'gfpdf_plugin_installed', [ $this->model, 'schedule_sync' ] ); add_action( 'gfpdf_version_changed', [ $this->model, 'schedule_sync' ] ); diff --git a/src/Controller/Controller_Upgrade_Routines.php b/src/Controller/Controller_Upgrade_Routines.php index 3fa02e57d..006c01846 100644 --- a/src/Controller/Controller_Upgrade_Routines.php +++ b/src/Controller/Controller_Upgrade_Routines.php @@ -30,6 +30,13 @@ */ class Controller_Upgrade_Routines { + /** + * The default font of a site upgraded from before 6.19 with none saved + * + * @since 6.19.0 + */ + const PRE_6_19_DEFAULT_FONT = 'dejavusanscondensed'; + /** * @var Helper_Abstract_Options */ @@ -102,11 +109,10 @@ public function maybe_run_upgrade( $old_version, $current_version ): void { /* A site upgraded from before Arimo was the default keeps DejaVu Sans Condensed, even if it never saved one */ if ( $old_version !== '' && version_compare( $old_version, '6.19.0', '<' ) ) { - $this->save_unset_default_font( 'dejavusanscondensed' ); + $this->save_unset_default_font( self::PRE_6_19_DEFAULT_FONT ); $this->adopt_font_source_records(); - /* After the default font save, so the font it saves is kept */ - $this->remove_unneeded_core_fonts(); + $this->schedule_core_font_cleanup(); } if ( version_compare( $current_version, '7.0.0-beta1', '>=' ) && version_compare( $old_version, '7.0.0-beta1', '<' ) ) { @@ -180,22 +186,17 @@ protected function adopt_font_source_records(): void { } /** - * Delete the core fonts a new install wouldn't have; any still needed download again on use. A network shares one - * fonts folder, so a Multisite prunes it once, for the site whose request runs the upgrade. + * Queue the removal of the core fonts a new install wouldn't have. It keeps the fonts every site needs and runs in + * the background, as every site on every network shares the fonts folder. * * @since 6.19.0 */ - protected function remove_unneeded_core_fonts(): void { + protected function schedule_core_font_cleanup(): void { $downloader = \GPDFAPI::get_mvc_class( 'Model_Font_Downloader' ); - if ( ! $downloader instanceof Model_Font_Downloader ) { - return; + if ( $downloader instanceof Model_Font_Downloader ) { + /* Sites yet to upgrade will save DejaVu Sans Condensed as their default when they do */ + $downloader->schedule_cleanup( self::PRE_6_19_DEFAULT_FONT ); } - - if ( is_multisite() && ! add_site_option( Model_Font_Downloader::PRUNED, true ) ) { - return; - } - - $downloader->prune(); } /** diff --git a/src/Model/Model_Actions.php b/src/Model/Model_Actions.php index 2975bf5ed..8bc3f3fe2 100644 --- a/src/Model/Model_Actions.php +++ b/src/Model/Model_Actions.php @@ -57,13 +57,6 @@ protected static function get_removed_methods(): array { */ const UNWRITABLE_FOLDER_DISMISSAL = 'unwritable_folder_'; - /** - * The prefix each failed core font's dismissal is recorded under in `action_dismissal` - * - * @since 6.19.0 - */ - const FAILED_CORE_FONT_DISMISSAL = 'failed_core_font_'; - /** * How long dismissing the unwritable folders notice hides a folder that keeps failing * @@ -296,22 +289,13 @@ protected function get_unwritable_folder_dismissal( string $dir ): string { } /** - * The core fonts that failed to download after their retry, less the ones dismissed since + * The core fonts that failed to download after their retry, less the ones dismissed * * @return string[] * @since 6.19.0 */ public function get_undismissed_failed_core_fonts(): array { - $files = []; - - /* A dismissal covers only the failure it followed */ - foreach ( $this->get_downloader()->get_failed_files() as $file => $gave_up ) { - if ( ! $this->is_notice_already_dismissed( $this->get_failed_core_font_dismissal( $file ), $gave_up ) ) { - $files[] = $file; - } - } - - return $files; + return $this->get_downloader()->get_undismissed_failed_files(); } /** @@ -324,12 +308,12 @@ public function has_failed_core_fonts(): bool { } /** - * Dismiss each listed font; one that fails later raises the notice again + * Dismiss the listed fonts for every site, as they share the fonts folder; one that fails later raises it again * * @since 6.19.0 */ public function dismiss_failed_core_fonts(): void { - $this->dismiss_notices( array_map( [ $this, 'get_failed_core_font_dismissal' ], $this->get_undismissed_failed_core_fonts() ) ); + $this->get_downloader()->dismiss_failed_files(); } /** @@ -341,15 +325,6 @@ protected function get_downloader(): Model_Font_Downloader { return $this->downloader ?? GPDFAPI::get_mvc_class( 'Model_Font_Downloader' ); } - /** - * The ID a failed core font's dismissal is recorded under in `action_dismissal` - * - * @since 6.19.0 - */ - protected function get_failed_core_font_dismissal( string $file ): string { - return static::FAILED_CORE_FONT_DISMISSAL . md5( $file ); - } - /** * Send the user to the system report, which lists every detection behind the notice * diff --git a/src/Model/Model_Font_Downloader.php b/src/Model/Model_Font_Downloader.php index 7e4c0c183..3b32c995d 100644 --- a/src/Model/Model_Font_Downloader.php +++ b/src/Model/Model_Font_Downloader.php @@ -33,14 +33,16 @@ class Model_Font_Downloader extends Helper_Abstract_Model { /** - * Failed files awaiting their one retry, and those that gave up, which the admin notice lists + * Failed files awaiting their one retry, and those that gave up, which the admin notice lists. An option of the + * main network, as every site shares the fonts folder * * @since 6.19.0 */ const FAILURES = 'gfpdf_core_font_failures'; /** - * Prefix of the per-file transient holding when a download last failed, which skips the network until it expires + * Prefix of the per-file site transient holding when a download last failed, which skips the network until it + * expires * * @since 6.19.0 */ @@ -80,11 +82,18 @@ class Model_Font_Downloader extends Helper_Abstract_Model { const SYNC_BUDGET = 20; /** - * Set once a Multisite network's shared fonts folder is pruned on the upgrade to 6.19 + * Main network option holding the 6.19 cleanup's progress while it runs, then true once it's done * * @since 6.19.0 */ - const PRUNED = 'gfpdf_core_font_pruned'; + const CLEANUP = 'gfpdf_core_font_cleanup'; + + /** + * Cron event that reads every site's fonts a batch at a time, then cleans the shared fonts folder + * + * @since 6.19.0 + */ + const CLEANUP_EVENT = 'gfpdf_core_font_cleanup_run'; /** * @var Model_Font_Sources @@ -117,9 +126,10 @@ class Model_Font_Downloader extends Helper_Abstract_Model { protected $log; /** - * When each failed file's retry is due (else 0) and, once the retry also failed, when it gave up (else 0) + * When each failed file's retry is due (else 0), once the retry also failed when it gave up (else 0), and whether + * an admin dismissed the notice listing it * - * @var array|null + * @var array|null * @since 6.19.0 */ protected $failures; @@ -286,13 +296,14 @@ public function download( string $file ): bool { * Delete the unmodified files (fonts and licences) the Core Font installer downloaded that aren't kept. A file whose * download is turned off stays, as it couldn't come back. Only files of the installer's size are hashed. * - * @param string[]|null $keep File names to keep, else the fonts the site's PDFs need + * @param string[]|null $keep File names to keep, else the fonts the site's PDFs need + * @param int $since Also keep files written after this time, as a download since then may be needed * * @return string[] The files removed * * @since 6.19.0 */ - public function prune( ?array $keep = null ): array { + public function prune( ?array $keep = null, int $since = 0 ): array { $manifest = $this->font_sources->get_legacy_manifest( 'core' ); /* Without the manifest nothing is known to be unmodified */ @@ -315,7 +326,7 @@ public function prune( ?array $keep = null ): array { $path = $this->get_font_path( $file ); clearstatcache( true, $path ); - if ( ! is_file( $path ) || filesize( $path ) !== $entry['size'] ) { + if ( ! is_file( $path ) || filesize( $path ) !== $entry['size'] || ( $since > 0 && filemtime( $path ) > $since ) ) { continue; } @@ -637,6 +648,40 @@ public function get_failed_files(): array { return $failed; } + /** + * Failed files no admin has dismissed the notice for + * + * @return string[] + * + * @since 6.19.0 + */ + public function get_undismissed_failed_files(): array { + $undismissed = array_filter( + $this->get_failures(), + static function ( array $failure ): bool { + return $failure['gave_up'] > 0 && ! $failure['dismissed']; + } + ); + + return array_map( 'strval', array_keys( $undismissed ) ); + } + + /** + * Dismiss the notice for every failed file, on every site. A file that downloads, then fails again, is listed anew + * + * @since 6.19.0 + */ + public function dismiss_failed_files(): void { + $failures = $this->get_failures(); + foreach ( $failures as $file => $failure ) { + if ( $failure['gave_up'] > 0 ) { + $failures[ $file ]['dismissed'] = true; + } + } + + $this->save_failures( $failures ); + } + /** * Forget the failures read this request * @@ -664,11 +709,6 @@ public function schedule_sync(): void { /* The trigger may have changed the required set */ delete_transient( self::SYNC_REMAINING ); - /* Stored empty, so the notice check reads it from the autoloaded options rather than querying for it */ - if ( get_site_option( self::FAILURES ) === false ) { - $this->save_failures( [] ); - } - $this->schedule_by( time() ); } @@ -687,7 +727,7 @@ public function sync(): void { array_map( [ $this, 'delete_back_off' ], $due ); $this->syncing = true; - $left = $this->download_within_budget( array_merge( $due, $missing ) ); + $left = $this->within_budget( array_merge( $due, $missing ), [ $this, 'download' ] ); $this->syncing = false; /* Includes the retries this run's first failures added */ @@ -708,45 +748,103 @@ public function sync(): void { } /** - * Have the sync run by the time given: queue it, or move one queued later forward. Never two, never later. + * Queue the 6.19 cleanup of the shared fonts folder, once per install + * + * @param string $unset_default The default font of a site with none saved + * + * @since 6.19.0 + */ + public function schedule_cleanup( string $unset_default ): void { + if ( get_network_option( get_main_network_id(), self::CLEANUP ) !== false ) { + return; + } + + update_network_option( + get_main_network_id(), + self::CLEANUP, + [ + 'started' => time(), + 'default' => $unset_default, + 'sites' => $this->required_fonts->get_site_ids(), + 'keys' => [], + ] + ); + + $this->schedule_by( time(), self::CLEANUP_EVENT ); + } + + /** + * Read the fonts of the sites left until the time budget runs out, then clean the fonts folder once all are read * * @since 6.19.0 */ - protected function schedule_by( int $at ): void { - $next = wp_next_scheduled( self::SYNC_EVENT ); + public function cleanup(): void { + $state = get_network_option( get_main_network_id(), self::CLEANUP ); + if ( ! is_array( $state ) ) { + return; + } + + $keys = $state['keys']; + $left = $this->within_budget( + $state['sites'], + function ( $site_id ) use ( &$keys, $state ) { + $keys = array_merge( $keys, $this->required_fonts->get_site_font_keys( (int) $site_id, $state['default'] ) ); + } + ); + + if ( $left !== [] ) { + $state['sites'] = $left; + $state['keys'] = array_values( array_unique( $keys ) ); + update_network_option( get_main_network_id(), self::CLEANUP, $state ); + $this->schedule_by( time(), self::CLEANUP_EVENT ); + + return; + } + + $this->prune( $this->required_fonts->get_install_font_files( $keys ), $state['started'] ); + update_network_option( get_main_network_id(), self::CLEANUP, true ); + } + + /** + * Have the event run by the time given: queue it, or move one queued later forward. Never two, never later. + * + * @since 6.19.0 + */ + protected function schedule_by( int $at, string $event = self::SYNC_EVENT ): void { + $next = wp_next_scheduled( $event ); if ( $next !== false && $next <= $at ) { return; } if ( $next !== false ) { - wp_unschedule_event( $next, self::SYNC_EVENT ); + wp_unschedule_event( $next, $event ); } - if ( wp_schedule_single_event( $at, self::SYNC_EVENT ) !== true ) { - $this->log->warning( 'Font sync could not be scheduled' ); + if ( wp_schedule_single_event( $at, $event ) !== true ) { + $this->log->warning( 'Font event could not be scheduled', [ 'event' => $event ] ); } } /** - * Download each file until the time budget runs out + * Run the callback on each item until the time budget runs out * - * @param string[] $files + * @param mixed[] $items * - * @return string[] The files it didn't reach + * @return mixed[] The items it didn't reach * * @since 6.19.0 */ - protected function download_within_budget( array $files ): array { - $files = array_values( $files ); + protected function within_budget( array $items, callable $each ): array { + $items = array_values( $items ); $deadline = microtime( true ) + $this->sync_budget; - foreach ( $files as $i => $file ) { - /* At least one file per run, so slow hosts make progress */ + foreach ( $items as $i => $item ) { + /* At least one per run, so slow hosts make progress */ if ( $i > 0 && microtime( true ) >= $deadline ) { - return array_slice( $files, $i ); + return array_slice( $items, $i ); } - $this->download( $file ); + $each( $item ); } return []; @@ -941,8 +1039,9 @@ protected function record_failure( string $file ): void { if ( $failure === null ) { $failures[ $file ] = [ - 'retry_at' => time() + wp_rand( 0, $this->retry_jitter ), - 'gave_up' => 0, + 'retry_at' => time() + wp_rand( 0, $this->retry_jitter ), + 'gave_up' => 0, + 'dismissed' => false, ]; $this->save_failures( $failures ); @@ -956,10 +1055,11 @@ protected function record_failure( string $file ): void { } if ( $failure['gave_up'] === 0 ) { - /* First give-up time, which notice dismissals are compared against */ + /* The first give-up, which the notice lists */ $failures[ $file ] = [ - 'retry_at' => 0, - 'gave_up' => time(), + 'retry_at' => 0, + 'gave_up' => time(), + 'dismissed' => false, ]; $this->save_failures( $failures ); @@ -991,23 +1091,20 @@ protected function delete_back_off( string $file ): void { } /** - * Network-wide like the fonts folder, and kept when empty - * - * @param array $failures + * @param array $failures * * @since 6.19.0 */ protected function save_failures( array $failures ): void { $this->failures = $failures; - /* Autoloaded on a single site, as the notice checks it on most admin pages */ - is_multisite() ? update_site_option( self::FAILURES, $failures ) : update_option( self::FAILURES, $failures, true ); + update_network_option( get_main_network_id(), self::FAILURES, $failures ); } /** * Failures by file, read once per request * - * @return array + * @return array * * @since 6.19.0 */ @@ -1015,7 +1112,7 @@ protected function get_failures(): array { if ( $this->failures === null ) { $this->failures = []; - $stored = get_site_option( self::FAILURES, [] ); + $stored = get_network_option( get_main_network_id(), self::FAILURES, [] ); foreach ( is_array( $stored ) ? $stored : [] as $file => $failure ) { $retry_at = is_array( $failure ) && is_int( $failure['retry_at'] ?? null ) ? $failure['retry_at'] : 0; $gave_up = is_array( $failure ) && is_int( $failure['gave_up'] ?? null ) ? $failure['gave_up'] : 0; @@ -1023,8 +1120,9 @@ protected function get_failures(): array { /* An entry with neither is waiting on nothing */ if ( is_string( $file ) && ( $retry_at > 0 || $gave_up > 0 ) ) { $this->failures[ $file ] = [ - 'retry_at' => $gave_up > 0 ? 0 : $retry_at, - 'gave_up' => $gave_up, + 'retry_at' => $gave_up > 0 ? 0 : $retry_at, + 'gave_up' => $gave_up, + 'dismissed' => $gave_up > 0 && ( $failure['dismissed'] ?? false ) === true, ]; } } diff --git a/src/Model/Model_Font_Sources.php b/src/Model/Model_Font_Sources.php index 23b77a865..3ba27c166 100644 --- a/src/Model/Model_Font_Sources.php +++ b/src/Model/Model_Font_Sources.php @@ -61,7 +61,7 @@ class Model_Font_Sources extends Helper_Abstract_Model { public const SUBSTITUTION_CORE_FONTS = [ 'dejavusanscondensed', 'freesans', 'garuda', 'khmeros', 'padaukbook', 'kaputaunicode', 'abyssinicasil' ]; /** - * The transient holding the language font keys with the locale they were mapped for + * The site transient holding the installed language packs' font keys, which every site shares * * @since 6.19.0 */ @@ -318,41 +318,30 @@ public function flush(): void { $this->font_data = null; $this->language_font_keys = null; $this->language_font_locales = null; - delete_transient( self::LANGUAGE_FONT_KEYS ); + delete_site_transient( self::LANGUAGE_FONT_KEYS ); } /** - * The mPDF font keys for the installed languages and the locale, cached for a day so a render doesn't scan the - * languages folder + * The mPDF font keys for the installed languages (cached for a day, network-wide) and this site's locale * * @return string[] * * @since 6.19.0 */ public function get_language_font_keys(): array { - if ( $this->language_font_keys !== null ) { - return $this->language_font_keys; - } + if ( $this->language_font_keys === null ) { + $keys = get_site_transient( self::LANGUAGE_FONT_KEYS ); + if ( ! is_array( $keys ) ) { + $keys = array_keys( $this->map_language_font_locales( (array) get_available_languages() ) ); + set_site_transient( self::LANGUAGE_FONT_KEYS, $keys, DAY_IN_SECONDS ); + } - $locale = get_locale(); - $cached = get_transient( self::LANGUAGE_FONT_KEYS ); - if ( is_array( $cached ) && ( $cached['locale'] ?? null ) === $locale && is_array( $cached['keys'] ?? null ) ) { - $this->language_font_keys = array_values( array_filter( $cached['keys'], 'is_string' ) ); + /* Per site, and may be set without its language pack */ + $locale_keys = array_keys( $this->map_language_font_locales( [ get_locale() ] ) ); - return $this->language_font_keys; + $this->language_font_keys = array_values( array_unique( array_merge( array_filter( $keys, 'is_string' ), $locale_keys ) ) ); } - $this->language_font_keys = array_map( 'strval', array_keys( $this->get_language_font_locales() ) ); - - set_transient( - self::LANGUAGE_FONT_KEYS, - [ - 'locale' => $locale, - 'keys' => $this->language_font_keys, - ], - DAY_IN_SECONDS - ); - return $this->language_font_keys; } diff --git a/src/Model/Model_Required_Fonts.php b/src/Model/Model_Required_Fonts.php index 656853b73..9648b86ae 100644 --- a/src/Model/Model_Required_Fonts.php +++ b/src/Model/Model_Required_Fonts.php @@ -141,6 +141,19 @@ public function get_reason_files( array $reasons ): array { return array_values( array_unique( $files ) ); } + /** + * The files of the fonts sites need, plus the installed languages', as every site shares the fonts folder + * + * @param string[] $keys Font keys from get_site_font_keys() + * + * @return string[] + * + * @since 6.19.0 + */ + public function get_install_font_files( array $keys ): array { + return $this->get_reason_files( array_fill_keys( array_merge( $this->font_sources->get_language_font_keys(), $keys ), [] ) ); + } + /** * Why each required font key is required: the default font, a site language's, selected by that many PDFs, in a * required source and, with $network, used by that many other sites @@ -430,6 +443,29 @@ public function get_site_font_keys( int $site_id, string $unset_default = '' ): return array_values( array_unique( array_filter( array_map( [ $this, 'normalise_font' ], $keys ) ) ) ); } + /** + * Every site's ID, across all networks + * + * @return int[] + * + * @since 6.19.0 + */ + public function get_site_ids(): array { + if ( ! is_multisite() ) { + return [ get_current_blog_id() ]; + } + + return array_map( + 'intval', + get_sites( + [ + 'fields' => 'ids', + 'number' => 0, + ] + ) + ); + } + /** * A page of the stored meta of the active forms with PDFs, read raw so Gravity Forms doesn't build their fields * diff --git a/src/Model/Model_Uninstall.php b/src/Model/Model_Uninstall.php index 2f14e10c5..e997b0d78 100644 --- a/src/Model/Model_Uninstall.php +++ b/src/Model/Model_Uninstall.php @@ -114,14 +114,16 @@ public function uninstall_plugin() { $this->remove_plugin_network_options(); } else { $this->remove_plugin_transients(); - $this->remove_font_back_offs(); $this->remove_plugin_options(); $this->remove_plugin_form_settings(); } + $this->remove_font_state(); + /* Removes background processes */ $this->queue->clear_scheduled_events(); wp_clear_scheduled_hook( Model_Font_Downloader::SYNC_EVENT ); + wp_clear_scheduled_hook( Model_Font_Downloader::CLEANUP_EVENT ); $this->queue->clear_queue( true ); $this->queue->unlock_process(); @@ -166,27 +168,30 @@ public function remove_plugin_transients() { } delete_transient( Model_Font_Downloader::SYNC_REMAINING ); - delete_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); $templates = \GPDFAPI::get_templates_class(); $templates->flush_template_transient_cache(); } /** - * Remove the font downloads' back-offs, which a single site keeps in its options table (a Multisite in sitemeta) + * Remove the font state every site shares: the failed downloads and their back-offs, the language fonts and the + * 6.19 cleanup's progress * * @since 6.19.0 */ - public function remove_font_back_offs() { - global $wpdb; + public function remove_font_state(): void { + delete_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ); + delete_network_option( get_main_network_id(), Model_Font_Downloader::CLEANUP ); + delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); - /* phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching */ - $keys = $wpdb->get_col( - $wpdb->prepare( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s", $wpdb->esc_like( '_site_transient_' . Model_Font_Downloader::BACK_OFF_KEY ) . '%' ) - ); + $font_sources = \GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + if ( ! $font_sources instanceof Model_Font_Sources ) { + return; + } - foreach ( $keys as $key ) { - delete_site_transient( substr( $key, strlen( '_site_transient_' ) ) ); + /* Only the manifest's files are downloaded, so only they back off */ + foreach ( array_keys( $font_sources->get_manifest() ) as $file ) { + delete_site_transient( Model_Font_Downloader::back_off_key( (string) $file ) ); } } @@ -205,7 +210,6 @@ public function remove_plugin_options() { delete_option( Model_Pdf_Cache::SWEEP_STATE_OPTION ); delete_option( Model_Pdf_Cache::PURGE_REQUEST_OPTION ); delete_option( Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS ); /* A Multisite keeps it in sitemeta */ - delete_option( Model_Font_Downloader::FAILURES ); /* A Multisite keeps it in sitemeta */ Deprecation::delete_stored_data(); /* Remove license API data. Deleting one by one, not with a raw DELETE, lets WordPress drop its cached copies */ @@ -222,8 +226,8 @@ public function remove_plugin_options() { } /** - * Remove the network options stored in sitemeta on a Multisite: license package caches, the rich text merge - * tags flag, the failed font downloads and their back-offs, and the 6.19 core font prune flag + * Remove the network options stored in sitemeta on a Multisite: license package caches and the rich text merge + * tags flag. remove_font_state() removes the font state. * * Every extension keeps its own license cache entry (see EDD_SL_Plugin_Updater::get_network_cache_key()), but all * are network-scoped rather than per-site, so one pass clears them, and the flag's exact key, without walking the @@ -241,13 +245,9 @@ public function remove_plugin_network_options() { /* phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching */ $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT site_id, meta_key FROM $wpdb->sitemeta WHERE meta_key LIKE %s OR meta_key LIKE %s OR meta_key LIKE %s OR meta_key IN ( %s, %s, %s )", + "SELECT site_id, meta_key FROM $wpdb->sitemeta WHERE meta_key LIKE %s OR meta_key = %s", $wpdb->esc_like( 'gpdf_sl_net_' ) . '%', - $wpdb->esc_like( '_site_transient_' . Model_Font_Downloader::BACK_OFF_KEY ) . '%', - $wpdb->esc_like( '_site_transient_timeout_' . Model_Font_Downloader::BACK_OFF_KEY ) . '%', - Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS, - Model_Font_Downloader::FAILURES, - Model_Font_Downloader::PRUNED + Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS ) ); diff --git a/tests/phpunit/Concerns/RecordsFontFailures.php b/tests/phpunit/Concerns/RecordsFontFailures.php index 98253f8aa..0feab3fc2 100644 --- a/tests/phpunit/Concerns/RecordsFontFailures.php +++ b/tests/phpunit/Concerns/RecordsFontFailures.php @@ -19,22 +19,23 @@ trait RecordsFontFailures { * @param int $gave_up When its retry also failed, or 0 while the retry is pending * @param int|null $retry_at When the pending retry is due, defaulting to now * - * @return array{retry_at: int, gave_up: int} + * @return array{retry_at: int, gave_up: int, dismissed: bool} */ protected function failure( int $gave_up = 0, ?int $retry_at = null ): array { return [ - 'retry_at' => $gave_up > 0 ? 0 : ( $retry_at ?? time() ), - 'gave_up' => $gave_up, + 'retry_at' => $gave_up > 0 ? 0 : ( $retry_at ?? time() ), + 'gave_up' => $gave_up, + 'dismissed' => false, ]; } /** - * Store the record where the downloader keeps it, and have the shared downloader read it afresh + * Store the record where the downloader keeps it, on the main network, and have the shared downloader read it afresh * * @param mixed $failures */ protected function set_font_failures( $failures ): void { - update_site_option( Model_Font_Downloader::FAILURES, $failures ); + update_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES, $failures ); GPDFAPI::get_mvc_class( 'Model_Font_Downloader' )->flush(); } @@ -44,7 +45,7 @@ protected function set_font_failures( $failures ): void { * @return mixed The stored record, or false */ protected function get_font_failures() { - return get_site_option( Model_Font_Downloader::FAILURES ); + return get_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ); } /** @@ -79,7 +80,7 @@ protected function get_back_off_expiry( string $file ): int { * Delete the failure record, and have the shared downloader read it afresh */ protected function delete_font_failures(): void { - delete_site_option( Model_Font_Downloader::FAILURES ); + delete_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ); GPDFAPI::get_mvc_class( 'Model_Font_Downloader' )->flush(); } } diff --git a/tests/phpunit/Concerns/StubsFontSources.php b/tests/phpunit/Concerns/StubsFontSources.php index 847764344..95d5cd2de 100644 --- a/tests/phpunit/Concerns/StubsFontSources.php +++ b/tests/phpunit/Concerns/StubsFontSources.php @@ -19,7 +19,7 @@ trait StubsFontSources { * @param mixed $languages What get_available_languages() returns */ protected function set_languages( $languages ): void { - delete_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); + delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); add_filter( 'get_available_languages', static function () use ( $languages ) { @@ -34,7 +34,7 @@ static function () use ( $languages ) { * @param mixed $locale What get_locale() returns */ protected function set_locale( $locale ): void { - delete_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); + delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); add_filter( 'locale', static function () use ( $locale ) { diff --git a/tests/phpunit/integration/Controller/Test_Controller_Actions.php b/tests/phpunit/integration/Controller/Test_Controller_Actions.php index e95396daf..9f9cf8140 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_Actions.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Actions.php @@ -40,6 +40,11 @@ public function tear_down(): void { unset( $_POST['gfpdf_action'], $_POST['gfpdf-dismiss-notice'], $_GET['page'] ); $this->delete_font_failures(); + /* A subsite leaves these behind */ + global $wp_settings_errors, $wp_rewrite; + $wp_settings_errors = []; + $wp_rewrite->init(); + parent::tear_down(); } @@ -233,9 +238,12 @@ function () { $model->dismiss_failed_core_fonts(); $this->assertStringNotContainsString( 'could not download these fonts', $this->render_notices() ); + $this->assertTrue( $this->get_font_failures()['Garuda.ttf']['dismissed'] ); - /* A font that fails later raises it again, for that font alone */ - $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() - 10 ), 'FreeSans.ttf' => $this->failure( time() + 1 ) ] ); + /* A font that gives up later raises it again, for that font alone */ + $failures = $this->get_font_failures(); + $failures['FreeSans.ttf'] = $this->failure( time() ); + $this->set_font_failures( $failures ); $this->assertSame( [ 'FreeSans.ttf' ], $model->get_undismissed_failed_core_fonts() ); /* A font that downloads leaves the notice, which goes once none are left */ @@ -276,6 +284,26 @@ function () { ); } + public function test_dismissing_the_failed_core_fonts_notice_dismisses_it_on_every_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + global $gfpdf; + + $model = $gfpdf->singleton->get_class( 'Model_Actions' ); + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() ) ] ); + $model->dismiss_failed_core_fonts(); + + switch_to_blog( self::factory()->blog->create() ); + try { + \GPDFAPI::get_mvc_class( 'Model_Font_Downloader' )->flush(); + $this->assertFalse( $model->has_failed_core_fonts() ); + } finally { + restore_current_blog(); + } + } + /** * Run $test as a new user with the role, on the admin screen given, then restore the screen and the notices */ diff --git a/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php b/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php index 8e5e71007..5027e2b27 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php @@ -70,6 +70,7 @@ public function tear_down(): void { public function test_hooks() { $this->assertInstanceOf( Model_Font_Downloader::class, $this->model ); $this->assertSame( 10, has_action( Model_Font_Downloader::SYNC_EVENT, [ $this->model, 'sync' ] ) ); + $this->assertSame( 10, has_action( Model_Font_Downloader::CLEANUP_EVENT, [ $this->model, 'cleanup' ] ) ); $this->assertSame( 10, has_action( 'upgrader_process_complete', [ $this->controller, 'maybe_sync_translations' ] ) ); $this->assertSame( 10, has_action( 'add_option_gfpdf_settings', [ $this->controller, 'maybe_sync_default_font' ] ) ); $this->assertSame( 10, has_action( 'update_option_gfpdf_settings', [ $this->controller, 'maybe_sync_default_font' ] ) ); diff --git a/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php b/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php index b1540e90a..dde76e35b 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php @@ -51,6 +51,11 @@ public function set_up(): void { public function tear_down(): void { $this->remove_font_fixtures(); + /* A subsite leaves these behind */ + global $wp_settings_errors, $wp_rewrite; + $wp_settings_errors = []; + $wp_rewrite->init(); + parent::tear_down(); } @@ -458,7 +463,7 @@ public function test_core_fonts_outside_the_required_set_are_removed_on_the_upgr $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); $this->copy_fixture( 'DejaVuSansCondensed.ttf' ); - do_action( 'gfpdf_version_changed', $old_version, $new_version ); + $this->upgrade( $old_version, $new_version ); $this->assertSame( ! $removed, is_file( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ) ); $this->assertFileExists( $this->font_path( 'DejaVuSansCondensed.ttf' ), 'The default font the upgrade saves is kept' ); @@ -473,12 +478,13 @@ public function data_core_font_cleanup(): array { ]; } - public function test_the_upgrade_removes_a_font_the_core_font_installer_downloaded() { - $this->copy_fixture( 'DejaVuSerifCondensed-v1.0.0.ttf', 'DejaVuSerifCondensed.ttf' ); + public function test_the_upgrade_leaves_the_core_font_cleanup_to_cron() { + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); - $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); } public function test_the_core_font_cleanup_keeps_the_required_fonts() { @@ -487,7 +493,7 @@ public function test_the_core_font_cleanup_keeps_the_required_fonts() { $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); $this->copy_fixture( 'DejaVuSansCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + $this->upgrade( '6.18.0', '6.19.0' ); $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSansCondensed.ttf' ) ); @@ -498,40 +504,54 @@ public function test_the_core_font_cleanup_keeps_fonts_when_downloads_are_off() add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + $this->upgrade( '6.18.0', '6.19.0' ); $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); } - public function test_the_core_font_cleanup_runs_once_per_network() { - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Multisite only' ); - } - + public function test_the_core_font_cleanup_runs_once() { $this->set_installer_manifest(); $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + $this->upgrade( '6.18.0', '6.19.0' ); $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); - $this->assertTrue( get_site_option( Model_Font_Downloader::PRUNED ) ); + $this->assertTrue( get_network_option( get_main_network_id(), Model_Font_Downloader::CLEANUP ) ); - /* Another site upgrading finds the folder already pruned */ + /* Another site upgrading, or this one again, finds the folder already cleaned */ $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + $this->upgrade( '6.18.0', '6.19.0' ); $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); } - public function test_a_single_site_cleans_its_fonts_on_each_upgrade_to_6_19() { - if ( is_multisite() ) { - $this->markTestSkipped( 'Single site only' ); + public function test_the_core_font_cleanup_keeps_every_sites_fonts() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); } $this->set_installer_manifest(); - foreach ( [ 1, 2 ] as $run ) { - $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); - $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ), "Run $run" ); - } + $this->options->update_option( 'default_font', 'arimo' ); + update_blog_option( self::factory()->blog->create(), 'gfpdf_settings', [ 'default_font' => 'dejavuserifcondensed' ] ); + + /* A site yet to upgrade, with no default font saved, will save DejaVu Sans Condensed */ + self::factory()->blog->create(); + + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + $this->copy_fixture( 'DejaVuSansCondensed.ttf' ); + + $this->upgrade( '6.18.0', '6.19.0' ); + + $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertFileExists( $this->font_path( 'DejaVuSansCondensed.ttf' ) ); + } + + /** + * Run the version change, then the cleanup it queues + */ + private function upgrade( string $old_version, string $new_version ): void { + do_action( 'gfpdf_version_changed', $old_version, $new_version ); + do_action( Model_Font_Downloader::CLEANUP_EVENT ); + } - $this->assertFalse( get_site_option( Model_Font_Downloader::PRUNED ) ); + private function font_path( string $file ): string { + return \GPDFAPI::get_data_class()->template_font_location . $file; } } diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php index 1ef433b3d..51fb7436a 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php @@ -51,6 +51,7 @@ public function set_up(): void { } public function tear_down(): void { + wp_clear_scheduled_hook( Model_Font_Downloader::CLEANUP_EVENT ); wp_delete_file( $this->font_path( self::FONT ) ); $this->remove_font_fixtures(); $this->delete_font_failures(); @@ -478,6 +479,64 @@ public function test_prune_without_a_fonts_folder_removes_nothing() { } } + public function test_prune_keeps_files_written_since_the_time_given() { + $this->copy_fixture( self::FONT ); + $this->copy_fixture( 'DejaVuSans.ttf' ); + touch( $this->font_path( 'DejaVuSans.ttf' ), time() - HOUR_IN_SECONDS ); + + $this->assertSame( [ 'DejaVuSans.ttf' ], $this->downloader->prune( [], time() - MINUTE_IN_SECONDS ) ); + } + + public function test_the_cleanup_reads_a_site_per_run_once_the_budget_is_spent_then_cleans_the_folder() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'arimo' ); + if ( is_multisite() ) { + self::factory()->blog->create_many( 2 ); + } + + $this->copy_fixture( self::FONT ); + touch( $this->font_path( self::FONT ), time() - HOUR_IN_SECONDS ); + + $downloader = $this->seamed( [ 'budget' => 0 ] ); + $sites = GPDFAPI::get_mvc_class( 'Model_Required_Fonts' )->get_site_ids(); + $downloader->schedule_cleanup( 'arimo' ); + + foreach ( array_keys( $sites ) as $i ) { + $this->assertSame( array_slice( $sites, $i ), get_network_option( get_main_network_id(), Model_Font_Downloader::CLEANUP )['sites'] ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertFileExists( $this->font_path( self::FONT ) ); + wp_clear_scheduled_hook( Model_Font_Downloader::CLEANUP_EVENT ); + + $downloader->cleanup(); + } + + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertTrue( get_network_option( get_main_network_id(), Model_Font_Downloader::CLEANUP ) ); + $this->assertFileDoesNotExist( $this->font_path( self::FONT ) ); + } + + /** + * @dataProvider provider_cleanup_states + * + * @param mixed $state + */ + public function test_the_cleanup_is_queued_once_per_install( $state ) { + $this->copy_fixture( self::FONT ); + update_network_option( get_main_network_id(), Model_Font_Downloader::CLEANUP, $state ); + + $this->downloader->schedule_cleanup( 'arimo' ); + $this->downloader->cleanup(); + + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertFileExists( $this->font_path( self::FONT ) ); + } + + public function provider_cleanup_states(): array { + return [ + 'done' => [ true ], + 'corrupt' => [ 'yes' ], + ]; + } + /** * A deliberate trade-off: a Font Manager record pointing at a core font's file loses it, and mPDF downloads it * again by name when the font is next used @@ -517,7 +576,7 @@ public function test_a_failed_schedule_is_logged() { $this->downloader->schedule_sync(); $this->assertSame( [], $this->get_scheduled_args() ); - $this->assertCount( 1, $this->log_messages( 'Font sync could not be scheduled' ) ); + $this->assertCount( 1, $this->log_messages( 'Font event could not be scheduled' ) ); } public function test_sync_downloads_the_required_set_as_it_is_when_it_runs() { @@ -798,7 +857,7 @@ public function test_a_retry_that_cannot_be_scheduled_is_logged() { $this->downloader->download( self::FONT ); - $this->assertCount( 1, $this->log_messages( 'Font sync could not be scheduled' ) ); + $this->assertCount( 1, $this->log_messages( 'Font event could not be scheduled' ) ); } /** @@ -865,36 +924,39 @@ public function test_an_expired_back_off_leaves_the_retry_state() { $this->assertSame( [ self::FONT ], array_keys( $downloader->get_failed_files() ) ); } - public function test_a_single_site_autoloads_the_failures_but_not_the_back_off() { - if ( is_multisite() ) { - $this->markTestSkipped( 'A Multisite keeps them in sitemeta' ); - } - + public function test_the_failures_are_kept_on_the_main_network() { $this->fail_font_downloads(); $this->downloader->download( self::FONT ); - $this->assertContains( $this->get_autoload( Model_Font_Downloader::FAILURES ), [ 'yes', 'on' ] ); - $this->assertContains( $this->get_autoload( '_site_transient_' . Model_Font_Downloader::back_off_key( self::FONT ) ), [ 'no', 'off' ] ); + $this->assertSame( [ self::FONT ], array_keys( get_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ) ) ); } - public function test_scheduling_a_sync_stores_an_empty_failure_record() { - $this->delete_font_failures(); + public function test_dismissing_hides_the_failed_files_until_one_fails_again() { + $this->set_font_failures( + [ + self::FONT => $this->failure( time() - 10 ), + 'Garuda.ttf' => $this->failure(), + ] + ); - $this->downloader->schedule_sync(); + $this->assertSame( [ self::FONT ], $this->downloader->get_undismissed_failed_files() ); - $this->assertSame( [], $this->get_font_failures() ); + $this->downloader->dismiss_failed_files(); + $this->assertSame( [], $this->downloader->get_undismissed_failed_files() ); + $this->assertSame( [ self::FONT ], array_keys( $this->downloader->get_failed_files() ) ); + + /* Read afresh, as another site would */ + $this->assertSame( [], $this->new_downloader()->get_undismissed_failed_files() ); + + /* A font that gives up later is listed */ + $this->set_font_failures( $this->get_font_failures() + [ 'FreeSans.ttf' => $this->failure( time() ) ] ); + $this->assertSame( [ 'FreeSans.ttf' ], $this->new_downloader()->get_undismissed_failed_files() ); } - /** - * Read an option's autoload flag straight from the database - * - * @return string|null The option's autoload value - */ - private function get_autoload( string $option ): ?string { - global $wpdb; + public function test_only_a_true_dismissal_counts() { + $this->set_font_failures( [ self::FONT => [ 'retry_at' => 0, 'gave_up' => time(), 'dismissed' => 'yes' ] ] ); - /* phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching */ - return $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM $wpdb->options WHERE option_name = %s", $option ) ); + $this->assertSame( [ self::FONT ], $this->new_downloader()->get_undismissed_failed_files() ); } /** diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php index 5e0f70112..e94418114 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php @@ -532,7 +532,7 @@ public function test_the_locale_counts_as_a_language() { $this->assertSame( [ 'unbatang' ], $this->stub_font_sources()->get_language_font_keys() ); } - public function test_language_font_keys_are_cached_for_the_locale() { + public function test_the_installed_languages_font_keys_are_cached_for_every_site() { $this->set_languages( [ 'th' ] ); $this->stub_font_sources()->get_language_font_keys(); @@ -549,7 +549,9 @@ static function () use ( &$reads ) { $this->assertSame( [ 'garuda' ], $this->stub_font_sources()->get_language_font_keys() ); $this->assertSame( 0, $reads ); - /* Another locale maps again */ + $this->assertSame( [ 'garuda' ], get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); + + /* The locale is per site, so it's mapped on top of the cache */ add_filter( 'locale', static function () { @@ -558,7 +560,8 @@ static function () { ); $this->assertSame( [ 'garuda', 'sun-exta' ], $this->stub_font_sources()->get_language_font_keys() ); - $this->assertSame( 1, $reads ); + $this->assertSame( 0, $reads ); + $this->assertSame( [ 'garuda' ], get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); } public function test_a_language_pack_install_flushes_the_language_fonts() { @@ -566,7 +569,7 @@ public function test_a_language_pack_install_flushes_the_language_fonts() { } public function test_language_font_keys_are_read_once_until_flushed() { - delete_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); + delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); $reads = 0; add_filter( 'get_available_languages', diff --git a/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php b/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php index de563421c..61f7f4b38 100644 --- a/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php +++ b/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php @@ -38,6 +38,11 @@ public function tear_down(): void { $this->remove_font_fixtures(); GPDFAPI::get_options_class()->delete_option( 'default_font' ); + /* A subsite leaves these behind */ + global $wp_settings_errors, $wp_rewrite; + $wp_settings_errors = []; + $wp_rewrite->init(); + parent::tear_down(); } @@ -418,6 +423,90 @@ public function test_a_site_gravity_forms_has_not_run_on_has_no_pdf_fonts() { $this->assertSame( [ 'freeserif' ], $this->required_with( $this->stub_font_sources() )->get_site_font_keys( get_current_blog_id() ) ); } + public function test_on_its_own_a_site_requires_the_same_files_as_across_all_sites() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavuserif' ); + $this->set_languages( [ 'th' ] ); + $this->add_form_with_pdfs( [ [ 'font' => 'freeserif' ] ] ); + + $required = $this->required_with( $this->stub_font_sources() ); + $files = $required->get_required_font_files(); + + $this->assertCount( 12, $files ); + $this->assertEqualsCanonicalizing( $files, $this->all_sites_files( 'dejavusanscondensed', $required ) ); + } + + /** + * @dataProvider provider_unset_default_fonts + * + * @param mixed $settings + */ + public function test_a_site_without_a_saved_default_font_requires_the_one_given( $settings ) { + update_option( 'gfpdf_settings', $settings ); + + $this->assertSame( [], $this->all_sites_files() ); + $this->assertSame( + [ 'DejaVuSansCondensed.ttf', 'DejaVuSansCondensed-Bold.ttf', 'DejaVuSansCondensed-Oblique.ttf', 'DejaVuSansCondensed-BoldOblique.ttf' ], + $this->all_sites_files( 'dejavusanscondensed' ) + ); + } + + public function provider_unset_default_fonts(): array { + return [ + 'none saved' => [ [ 'debug_mode' => 'No' ] ], + 'an empty font' => [ [ 'default_font' => '' ] ], + 'corrupt settings' => [ 'arimo' ], + ]; + } + + public function test_every_sites_fonts_are_required_across_all_sites() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + GPDFAPI::get_options_class()->update_option( 'default_font', 'arimo' ); + update_blog_option( self::factory()->blog->create(), 'gfpdf_settings', [ 'default_font' => 'dejavuserif' ] ); + + /* A site Gravity Forms hasn't run on has no form tables; its locale has no language pack */ + $no_forms = self::factory()->blog->create(); + update_blog_option( $no_forms, 'gfpdf_settings', [ 'default_font' => 'arimo' ] ); + /* Raw, as saving WPLANG drops a locale without its pack */ + $GLOBALS['wpdb']->replace( $GLOBALS['wpdb']->get_blog_prefix( $no_forms ) . 'options', [ 'option_name' => 'WPLANG', 'option_value' => 'th' ] ); + + switch_to_blog( self::factory()->blog->create() ); + try { + gf_upgrade()->install(); + update_option( 'gfpdf_settings', [ 'default_font' => 'arimo' ] ); + $this->add_form_with_pdfs( [ [ 'font' => 'freeserif' ] ] ); + } finally { + restore_current_blog(); + } + + $this->assertSame( [], $this->required->get_required_font_files() ); + $this->assertSame( + array_merge( + [ 'DejaVuSerif.ttf', 'DejaVuSerif-Bold.ttf', 'DejaVuSerif-Italic.ttf', 'DejaVuSerif-BoldItalic.ttf' ], + [ 'Garuda.ttf', 'Garuda-Bold.ttf', 'Garuda-Oblique.ttf', 'Garuda-BoldOblique.ttf' ], + self::$freeserif + ), + $this->all_sites_files() + ); + } + + /** + * The files every site needs, as the 6.19 cleanup gathers them + * + * @return string[] + */ + private function all_sites_files( string $unset_default = '', ?Model_Required_Fonts $required = null ): array { + $required = $required ?? $this->required; + $keys = []; + foreach ( $required->get_site_ids() as $site_id ) { + $keys = array_merge( $keys, $required->get_site_font_keys( $site_id, $unset_default ) ); + } + + return $required->get_install_font_files( $keys ); + } + /** * Each log record as its level name, message and context */ diff --git a/tests/phpunit/integration/Model/Test_Uninstaller.php b/tests/phpunit/integration/Model/Test_Uninstaller.php index 1fa3758c9..6417662ed 100644 --- a/tests/phpunit/integration/Model/Test_Uninstaller.php +++ b/tests/phpunit/integration/Model/Test_Uninstaller.php @@ -133,14 +133,20 @@ public function test_uninstall_plugin() { $this->set_font_failures( [ 'Garuda.ttf' => $this->failure() ] ); $this->set_back_off( 'Garuda.ttf' ); set_transient( Model_Font_Downloader::SYNC_REMAINING, [ 'Garuda.ttf' ], DAY_IN_SECONDS ); + set_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS, [ 'garuda' ], DAY_IN_SECONDS ); + update_site_option( Model_Font_Downloader::CLEANUP, true ); + wp_schedule_single_event( time(), Model_Font_Downloader::CLEANUP_EVENT ); /* Uninstall */ $this->model->uninstall_plugin(); $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::SYNC_EVENT ) ); + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); $this->assertFalse( $this->get_font_failures() ); $this->assertFalse( $this->get_back_off( 'Garuda.ttf' ) ); $this->assertFalse( get_transient( Model_Font_Downloader::SYNC_REMAINING ) ); + $this->assertFalse( get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); + $this->assertFalse( get_site_option( Model_Font_Downloader::CLEANUP ) ); /* Check software was uninstalled */ $this->assertDirectoryDoesNotExist( $gfpdf->data->template_location ); @@ -244,7 +250,6 @@ public function test_remove_plugin_network_options() { update_site_option( 'gpdf_sl_net_abc123', [ 'timeout' => time(), 'value' => '{}' ] ); update_site_option( Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS, true ); - update_site_option( Model_Font_Downloader::PRUNED, true ); $this->assertNotFalse( get_site_option( 'gpdf_sl_net_abc123' ) ); set_site_transient( 'gfpdf_cleanup_tmp_dir', 1 ); @@ -253,7 +258,6 @@ public function test_remove_plugin_network_options() { /* No cache flush here on purpose — the uninstall must invalidate what it deletes */ $this->assertFalse( get_site_option( 'gpdf_sl_net_abc123' ) ); $this->assertFalse( get_site_option( Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS ) ); - $this->assertFalse( get_site_option( Model_Font_Downloader::PRUNED ) ); $this->assertFalse( get_site_transient( 'gfpdf_cleanup_tmp_dir' ) ); } diff --git a/tests/playwright/core/system-status/core-fonts.spec.ts b/tests/playwright/core/system-status/core-fonts.spec.ts index 9afe54900..20db35913 100644 --- a/tests/playwright/core/system-status/core-fonts.spec.ts +++ b/tests/playwright/core/system-status/core-fonts.spec.ts @@ -56,7 +56,7 @@ test.describe('Core Fonts', () => { // Clears the dismissals stored by an earlier run (see tools/mu-plugins) test.beforeAll(() => wpCli( - 'wp option delete gfpdf_e2e_failed_fonts_dismissal --quiet || true' + 'wp option delete gfpdf_e2e_failed_fonts_dismissed --quiet || true' ) ); diff --git a/tools/mu-plugins/gravitypdf.php b/tools/mu-plugins/gravitypdf.php index e17d18625..99db96264 100644 --- a/tools/mu-plugins/gravitypdf.php +++ b/tools/mu-plugins/gravitypdf.php @@ -240,14 +240,16 @@ static function ( $value, $old_value ) { $gfpdf_e2e_own_dismissals( 'gfpdf_e2e_action_dismissal' ); } -/* A fixed set of failures, given up when the header says, and no writes over them */ +/* A fixed set of failures, given up when the header says. The dismissal flag is the only thing a request can change, + kept in an option of its own with the time it covered, as the other specs read the real record */ if ( $gfpdf_e2e_fonts_gave_up > 0 ) { add_filter( 'pre_option_gfpdf_core_font_failures', static function () use ( $gfpdf_e2e_fonts_gave_up ) { $failure = [ - 'retry_at' => 0, - 'gave_up' => $gfpdf_e2e_fonts_gave_up, + 'retry_at' => 0, + 'gave_up' => $gfpdf_e2e_fonts_gave_up, + 'dismissed' => (int) get_option( 'gfpdf_e2e_failed_fonts_dismissed', 0 ) === $gfpdf_e2e_fonts_gave_up, ]; return [ @@ -259,14 +261,16 @@ static function () use ( $gfpdf_e2e_fonts_gave_up ) { add_filter( 'pre_update_option_gfpdf_core_font_failures', - static function ( $value, $old_value ) { + static function ( $value, $old_value ) use ( $gfpdf_e2e_fonts_gave_up ) { + if ( ( $value['FreeSerif.ttf']['dismissed'] ?? false ) === true ) { + update_option( 'gfpdf_e2e_failed_fonts_dismissed', $gfpdf_e2e_fonts_gave_up ); + } + return $old_value; }, 10, 2 ); - - $gfpdf_e2e_own_dismissals( 'gfpdf_e2e_failed_fonts_dismissal' ); } /* Adds an extension's warning to a settings save, for the spec checking its colour */ From f7acf3ed140de24ce9d5894c15506fb47a0765d3 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Fri, 6 Nov 2026 10:28:35 +1100 Subject: [PATCH 3/4] Save a core font under a hashed name when another font's file holds its name download() returned true for any file at a manifest name, so a family could silently use another font's file, and merging two sources' manifests with += gave the later source the first's file for a name they use for different files. A file at a manifest name is another font's when its size matches no build the sources know (the manifest's, the legacy manifest's or a source's `legacy` hashes). The family's font is then saved as {stem}-{first 8 of the sha}{ext}, derived from the manifest with nothing stored, and mPDF looks for that name first. The file at the name is never touched. A size mismatch is a stat call, so it is also found while rendering; a file of a known size that is not the manifest's is found by the background sync, the only place a file already on disk is hashed. With downloads off, or a download failing or backing off, the file at the name stays in use, so a conflict never renders worse than before. Two sources that use one name for different files get it at merge time: the later source's manifest entry and family file references take the hashed name, so nothing downstream sees a duplicate. Every reader of a font file's path now agrees: the finder, the downloaded fonts list and delete (a hashed copy is deleted alone), the installed counts and needs-download checks, the sync's file list and retries, prune and adoption (which read the file at the name), and the unregistered fonts auto-loader (which skips hashed copies). Saving a hashed copy flushes the font metrics and removes a copy left by an older release of the font. Co-Authored-By: Claude Sonnet 5.5 --- .../Mpdf/On_Demand_Font_File_Finder.php | 48 ++- src/Model/Model_Font_Downloader.php | 143 ++++++- src/Model/Model_Font_Sources.php | 389 +++++++++++++++++- src/Model/Model_PDF.php | 4 +- src/Model/Model_Required_Fonts.php | 4 +- tests/phpunit/Concerns/CopiesFontFixtures.php | 57 ++- .../Concerns/Seamed_Font_Downloader.php | 11 + .../Mpdf/Test_On_Demand_Font_File_Finder.php | 111 ++++- .../Model/Test_Downloaded_Fonts.php | 51 +++ .../Model/Test_Model_Font_Downloader.php | 143 +++++++ .../Model/Test_Model_Font_Sources.php | 225 ++++++++++ .../Model/Test_Model_Required_Fonts.php | 29 ++ tests/phpunit/integration/Model/Test_PDF.php | 16 + .../integration/Test_Font_Rendering.php | 91 ++-- 14 files changed, 1238 insertions(+), 84 deletions(-) diff --git a/src/Helper/Mpdf/On_Demand_Font_File_Finder.php b/src/Helper/Mpdf/On_Demand_Font_File_Finder.php index a23053e36..a3eb7a724 100644 --- a/src/Helper/Mpdf/On_Demand_Font_File_Finder.php +++ b/src/Helper/Mpdf/On_Demand_Font_File_Finder.php @@ -80,25 +80,49 @@ public function findFontFile( $name ) { throw new MpdfException( esc_html( sprintf( 'Cannot find TTF TrueType font file "%s" in configured font directories.', is_scalar( $name ) ? (string) $name : gettype( $name ) ) ) ); } + $found = ''; + $error = null; + try { - return parent::findFontFile( $name ); + $found = parent::findFontFile( $name ); } catch ( MpdfException $e ) { - if ( ! $this->downloader->is_core_font_file( $name ) ) { - throw $e; - } + $error = $e; + } - /* The downloader's fonts folder, as mPDF's AddFontDirectory() replaces the search directories */ - if ( $this->downloader->download( $name ) ) { - return $this->downloader->get_font_path( $name ); - } + /* Bundled fonts and those in other directories aren't on the manifest */ + if ( $error === null && ! $this->downloader->is_in_fonts_folder( $found ) ) { + return $found; + } - $fallback = $this->fallback ? $this->get_fallback_file() : ''; - if ( $fallback === '' ) { - throw $e; + if ( ! $this->downloader->is_core_font_file( $name ) ) { + if ( $error !== null ) { + throw $error; } - return $fallback; + return $found; + } + + /* A Font Manager record's file belongs to the record. The core font of that name has a hashed name. */ + if ( $error === null && $this->downloader->is_record_file( $name ) ) { + return $found; } + + /* The downloader's fonts folder, as mPDF's AddFontDirectory() replaces the search directories. Stat calls only. */ + if ( $this->downloader->download( $name ) ) { + return $this->downloader->get_font_path( $name ); + } + + /* The file at the name stays in use if its replacement can't be saved */ + if ( $error === null ) { + return $found; + } + + $fallback = $this->fallback ? $this->get_fallback_file() : ''; + if ( $fallback === '' ) { + throw $error; + } + + return $fallback; } /** diff --git a/src/Model/Model_Font_Downloader.php b/src/Model/Model_Font_Downloader.php index 3b32c995d..459782599 100644 --- a/src/Model/Model_Font_Downloader.php +++ b/src/Model/Model_Font_Downloader.php @@ -255,11 +255,36 @@ protected function get_auto_install_constant(): ?bool { } /** - * Download a file on the manifest into the fonts folder, skipped if it's there + * Whether a Font Manager record of this site uses the file, so it isn't the core font's * * @since 6.19.0 */ - public function download( string $file ): bool { + public function is_record_file( string $file ): bool { + return $this->font_sources->is_record_file( $file ); + } + + /** + * Whether a path is in the fonts folder, as opposed to a bundled font's or one a filter added + * + * @since 6.19.0 + */ + public function is_in_fonts_folder( string $path ): bool { + return untrailingslashit( dirname( $path ) ) === untrailingslashit( $this->data->template_font_location ); + } + + /** + * Download a file on the manifest into the fonts folder, skipped if it's there. A file at its name that is another + * font's (see is_conflict()) is left alone, and the manifest's is saved under its hashed name instead. + * + * @param string $file The manifest file name + * @param bool $verify Hash a file of a known size too, to find one of another font's that is the same size + * + * @return bool Whether the font is on disk now. False when its download is off or failed, even if another font's + * file sits at its name, which then stays in use. + * + * @since 6.19.0 + */ + public function download( string $file, bool $verify = false ): bool { $manifest = $this->font_sources->get_manifest(); if ( ! isset( $manifest[ $file ] ) ) { @@ -268,8 +293,13 @@ public function download( string $file ): bool { return false; } - $font_path = $this->get_font_path( $file ); - if ( is_file( $font_path ) ) { + if ( $this->font_sources->has_hashed_copy( $file ) ) { + return true; + } + + $font_path = $this->font_sources->get_plain_path( $file ); + $conflict = is_file( $font_path ); + if ( $conflict && ! $this->is_conflict( $file, $verify ) ) { return true; } @@ -281,7 +311,8 @@ public function download( string $file ): bool { return false; } - if ( ! $this->save( $file, $font_path, $manifest[ $file ] ) ) { + $target = $conflict ? $this->font_sources->get_plain_path( $this->font_sources->get_hashed_name( $file ) ) : $font_path; + if ( ! $this->save( $file, $target, $manifest[ $file ] ) ) { $this->record_failure( $file ); return false; @@ -289,9 +320,86 @@ public function download( string $file ): bool { $this->clear_failure( $file ); + if ( $conflict ) { + $this->log->notice( 'Font saved under a new name as another font has its name', [ 'name' => $file ] ); + $this->remove_older_hashed_copies( $file, basename( $target ) ); + + /* The metrics and PDFs cached from the file that was there are stale */ + FlushCache::flush(); + } + return true; } + /** + * A new release of a font has a new SHA-1, so its hashed name changes and the copy under the old one is orphaned. + * Delete the copies of the file under other hashed names, if the name holds the first 8 of the file's own SHA-1, + * which a font someone else put there would not. + * + * @param string $file The manifest file name + * @param string $keep The hashed name to keep + * + * @since 6.19.0 + */ + protected function remove_older_hashed_copies( string $file, string $keep ): void { + $info = pathinfo( $file ); + $pattern = '/^' . preg_quote( $info['filename'], '/' ) . '-([0-9a-f]{8})' . preg_quote( isset( $info['extension'] ) ? '.' . $info['extension'] : '', '/' ) . '$/i'; + $dir = $this->data->template_font_location; + + foreach ( scandir( $dir ) ?: [] as $name ) { + if ( $name === $keep || preg_match( $pattern, $name, $match ) !== 1 ) { + continue; + } + + $size = (int) filesize( $dir . $name ); + if ( strpos( $this->hash_blob( $dir . $name, $size ), strtolower( $match[1] ) ) === 0 ) { + wp_delete_file_from_directory( $dir . $name, $dir ); + $this->log->notice( 'Font copy for an older release removed', [ 'name' => $name ] ); + } + } + } + + /** + * Whether the file at a manifest name is another font's: its size is no known build's, else with $hash its SHA-1 + * isn't either. A file that can't be read is left alone, as nothing shows it's wrong. + * + * @since 6.19.0 + */ + protected function is_conflict( string $file, bool $hash ): bool { + if ( $this->font_sources->is_size_conflict( $file ) ) { + return true; + } + + $hashed = $this->font_sources->get_hashed_name( $file ); + $path = $this->font_sources->get_plain_path( $file ); + if ( ! $hash || $hashed === '' || $hashed === $file || ! is_file( $path ) ) { + return false; + } + + $size = (int) filesize( $path ); + $sha = $this->hash_blob( $path, $size ); + + return $sha !== '' && ! in_array( $sha, $this->font_sources->get_known_builds( $file )[ $size ] ?? [], true ); + } + + /** + * Detecting another font's file is the only reason to hash one that is already on disk + * + * @since 6.19.0 + */ + protected function hash_blob( string $path, int $size ): string { + return Git_Blob::sha1( $path, $size ); + } + + /** + * Whether the file's own copy is on disk, hashing the file at its name to be sure it's the manifest's + * + * @since 6.19.0 + */ + protected function is_resolved( string $file ): bool { + return $this->font_sources->has_hashed_copy( $file ) || ( is_file( $this->font_sources->get_plain_path( $file ) ) && ! $this->is_conflict( $file, true ) ); + } + /** * Delete the unmodified files (fonts and licences) the Core Font installer downloaded that aren't kept. A file whose * download is turned off stays, as it couldn't come back. Only files of the installer's size are hashed. @@ -324,7 +432,8 @@ public function prune( ?array $keep = null, int $since = 0 ): array { continue; } - $path = $this->get_font_path( $file ); + /* The file at the legacy name, never a hashed copy, which isn't the installer's */ + $path = $this->font_sources->get_plain_path( $file ); clearstatcache( true, $path ); if ( ! is_file( $path ) || filesize( $path ) !== $entry['size'] || ( $since > 0 && filemtime( $path ) > $since ) ) { continue; @@ -418,7 +527,8 @@ static function ( array $a, array $b ): int { } /** - * Delete an unused downloaded family's files whatever their hash, keeping the licence files + * Delete an unused downloaded family's files whatever their hash, keeping the licence files. A file with a copy under + * its hashed name loses only the copy: the file at the name is another font's. * * @return array{id: string, removed: string[], bytes: int}|WP_Error 404 when nothing is on disk, 409 when in use * @@ -484,6 +594,9 @@ public function delete_downloaded_font( string $key ) { protected function get_downloaded_families(): array { $families = []; + /* A Font Manager record's file at a family's name isn't the family's: its files have hashed names */ + $collisions = $this->font_sources->get_record_collisions(); + foreach ( $this->font_sources->get_sources() as $id => $source ) { if ( empty( $source['manifest'] ) ) { continue; @@ -499,6 +612,7 @@ protected function get_downloaded_families(): array { $files = []; foreach ( [ 'R', 'B', 'I', 'BI' ] as $style ) { $file = $family[ $style ] ?? null; + $file = is_string( $file ) ? $collisions[ $file ] ?? $file : null; if ( is_string( $file ) && isset( $manifest[ $file ] ) && $this->font_sources->is_core_font_file( $file ) ) { $files[ $style ] = $file; } @@ -727,7 +841,13 @@ public function sync(): void { array_map( [ $this, 'delete_back_off' ], $due ); $this->syncing = true; - $left = $this->within_budget( array_merge( $due, $missing ), [ $this, 'download' ] ); + $left = $this->within_budget( + array_merge( $due, $missing ), + function ( $file ) { + /* The one place a file of the right size is hashed, to find another font's of that size */ + $this->download( (string) $file, true ); + } + ); $this->syncing = false; /* Includes the retries this run's first failures added */ @@ -851,7 +971,8 @@ protected function within_budget( array $items, callable $each ): array { } /** - * The files a paused run left, else the missing required files, less those that gave up + * The files a paused run left, else the required files, less those that gave up and those with a copy under their + * hashed name. A file already on disk is in the list so sync() can check it's the manifest's. * * @return string[] * @@ -866,7 +987,7 @@ protected function get_sync_files(): array { array_filter( $files, function ( $file ) use ( $gave_up ) { - return ! isset( $gave_up[ $file ] ) && ! $this->font_sources->is_downloaded( $file ); + return ! isset( $gave_up[ $file ] ) && ! $this->font_sources->has_hashed_copy( $file ); } ) ); @@ -892,7 +1013,7 @@ protected function get_retries(): array { if ( $failure['retry_at'] > time() ) { $next = $next === 0 ? $failure['retry_at'] : min( $next, $failure['retry_at'] ); - } elseif ( $this->font_sources->is_downloaded( $file ) ) { + } elseif ( $this->is_resolved( $file ) ) { $this->clear_failure( $file ); } else { $due[] = $file; diff --git a/src/Model/Model_Font_Sources.php b/src/Model/Model_Font_Sources.php index 3ba27c166..0919d5e5a 100644 --- a/src/Model/Model_Font_Sources.php +++ b/src/Model/Model_Font_Sources.php @@ -139,6 +139,20 @@ class Model_Font_Sources extends Helper_Abstract_Model { */ protected $legacy_manifests = []; + /** + * Core font files a Font Manager record of the site also uses (see get_record_collisions()), by blog ID + * + * @var array> + * @since 6.19.0 + */ + protected $record_collisions = []; + + /** + * @var array|null The file names the downloadable sources' families use, once get_family_files() reads them + * @since 6.19.0 + */ + protected $family_files; + /** * @since 6.19.0 */ @@ -157,10 +171,12 @@ public function __construct( Helper_Data $data, Model_Custom_Fonts $custom_fonts */ public function get_sources(): array { if ( $this->sources === null ) { - $this->sources = [ - 'bundled' => $this->get_bundled_source(), - 'core' => $this->get_core_source(), - ]; + $this->sources = $this->rename_colliding_files( + [ + 'bundled' => $this->get_bundled_source(), + 'core' => $this->get_core_source(), + ] + ); } return $this->sources; @@ -318,6 +334,7 @@ public function flush(): void { $this->font_data = null; $this->language_font_keys = null; $this->language_font_locales = null; + $this->record_collisions = []; delete_site_transient( self::LANGUAGE_FONT_KEYS ); } @@ -450,7 +467,8 @@ public function get_fallback_file(): string { } /** - * Downloadable files across all sources, keyed by file name, read only when a file is needed + * Downloadable files across all sources, keyed by file name, read only when a file is needed. A name two sources + * use for different files is unique here: the later source's has the hashed name (see rename_colliding_files()). * * @return array * @@ -460,11 +478,19 @@ public function get_manifest(): array { if ( $this->manifest === null ) { $this->manifest = []; foreach ( array_keys( $this->get_sources() ) as $id ) { - $this->manifest += $this->get_source_manifest( (string) $id ); + $this->manifest += $this->get_unaliased_manifest( (string) $id ); } } - return $this->manifest; + /* A record can be added at any time, so the hashed names are added to a copy */ + $manifest = $this->manifest; + foreach ( $this->get_record_collisions() as $file => $hashed ) { + if ( isset( $manifest[ $file ] ) ) { + $manifest[ $hashed ] = $manifest[ $file ]; + } + } + + return $manifest; } /** @@ -490,13 +516,125 @@ public function get_legacy_manifest( string $id ): array { * @since 6.19.0 */ public function get_source_manifest( string $id ): array { + $manifest = $this->get_unaliased_manifest( $id ); + + foreach ( $this->get_record_collisions() as $file => $hashed ) { + if ( isset( $manifest[ $file ] ) ) { + $manifest[ $hashed ] = $manifest[ $file ]; + } + } + + return $manifest; + } + + /** + * A source's manifest as read, without the hashed names of files a Font Manager record uses + * + * @return array + * + * @since 6.19.0 + */ + protected function get_unaliased_manifest( string $id ): array { + /* Building the sources reads the manifests of those that share a file name with another */ + $sources = $this->get_sources(); + if ( ! isset( $this->manifests[ $id ] ) ) { - $this->manifests[ $id ] = $this->read_manifest( $this->get_sources()[ $id ] ?? [] ); + $this->manifests[ $id ] = $this->read_manifest( $sources[ $id ] ?? [] ); } return $this->manifests[ $id ]; } + /** + * The core font files a Font Manager record of this site also uses, mapped to the hashed name their families take, so + * the record keeps the file at the name. A record whose file is a known build of the core font (an adopted copy) isn't + * one. Reads the manifest only when a record's file has the name of a family's, and never hashes. + * + * @return array + * + * @since 6.19.0 + */ + public function get_record_collisions(): array { + $blog = get_current_blog_id(); + if ( isset( $this->record_collisions[ $blog ] ) ) { + return $this->record_collisions[ $blog ]; + } + + /* Guards the manifest reads below */ + $this->record_collisions[ $blog ] = []; + + $names = $this->get_record_file_names(); + if ( count( $names ) === 0 ) { + return []; + } + + $manifest = []; + foreach ( array_keys( $this->get_sources() ) as $id ) { + $manifest += $this->get_unaliased_manifest( (string) $id ); + } + + foreach ( $names as $file ) { + $path = $this->get_plain_path( $file ); + if ( ! isset( $manifest[ $file ] ) || ! is_file( $path ) ) { + continue; + } + + if ( ! isset( $this->collect_builds( $file, $manifest[ $file ] )[ (int) filesize( $path ) ] ) ) { + $this->record_collisions[ $blog ][ $file ] = $this->build_hashed_name( $file, $manifest[ $file ]['sha'] ); + } + } + + return $this->record_collisions[ $blog ]; + } + + /** + * Whether a Font Manager record of this site uses the file, which then isn't the core font's + * + * @since 6.19.0 + */ + public function is_record_file( string $file ): bool { + return isset( $this->get_record_collisions()[ $file ] ); + } + + /** + * The files of this site's Font Manager records that a downloadable family uses too, by name + * + * @return string[] + * + * @since 6.19.0 + */ + protected function get_record_file_names(): array { + $records = $this->custom_fonts->get_custom_fonts(); + if ( count( $records ) === 0 ) { + return []; + } + + if ( $this->family_files === null ) { + $this->family_files = []; + foreach ( $this->get_sources() as $source ) { + foreach ( empty( $source['manifest'] ) ? [] : $source['families'] as $family ) { + foreach ( [ 'R', 'B', 'I', 'BI' ] as $style ) { + if ( is_array( $family ) && is_string( $family[ $style ] ?? null ) ) { + $this->family_files[ $family[ $style ] ] = true; + } + } + } + } + } + + $names = []; + foreach ( $records as $record ) { + foreach ( [ 'regular', 'italics', 'bold', 'bolditalics' ] as $style ) { + $file = is_string( $record[ $style ] ?? null ) ? basename( $record[ $style ] ) : ''; + if ( isset( $this->family_files[ $file ] ) ) { + $names[ $file ] = $file; + } + } + } + + return array_values( $names ); + } + /** * Whether a file is a font on the manifest, not one of its licence files * @@ -507,21 +645,157 @@ public function is_core_font_file( string $file ): bool { } /** - * Where a file goes in the fonts folder + * Where a manifest file goes in the fonts folder, under its own name * * @since 6.19.0 */ - public function get_font_path( string $file ): string { + public function get_plain_path( string $file ): string { return $this->data->template_font_location . $file; } /** - * Whether a file is in the fonts folder + * The path mPDF should load for a manifest file: its copy under the hashed name if there is one, else the file at + * its own name, which may be missing. Stat calls only, no hashing. + * + * @since 6.19.0 + */ + public function get_font_path( string $file ): string { + return $this->has_hashed_copy( $file ) ? $this->get_plain_path( $this->get_hashed_name( $file ) ) : $this->get_plain_path( $file ); + } + + /** + * The name a manifest file takes when another file holds its own: `{stem}-{first 8 of its SHA-1}{ext}`, e.g. + * Garuda-0abddd35.ttf. Worked out from the manifest alone, so nothing stores it. '' for a file not on the manifest. + * + * @since 6.19.0 + */ + public function get_hashed_name( string $file ): string { + $entry = $this->get_manifest()[ $file ] ?? null; + if ( $entry === null ) { + return ''; + } + + /* A name the merge already gave a hash has no second one */ + $tag = '-' . substr( $entry['sha'], 0, 8 ); + if ( substr( pathinfo( $file, PATHINFO_FILENAME ), -strlen( $tag ) ) === $tag ) { + return $file; + } + + return $this->build_hashed_name( $file, $entry['sha'] ); + } + + /** + * @since 6.19.0 + */ + protected function build_hashed_name( string $file, string $sha ): string { + $info = pathinfo( $file ); + + return $info['filename'] . '-' . substr( $sha, 0, 8 ) . ( isset( $info['extension'] ) ? '.' . $info['extension'] : '' ); + } + + /** + * Whether a file name is a manifest file's hashed name + * + * @since 6.19.0 + */ + public function is_hashed_copy( string $name ): bool { + if ( preg_match( '/^(.+)-([0-9a-f]{8})(\.[^.]+)$/i', $name, $match ) !== 1 ) { + return false; + } + + $entry = $this->get_manifest()[ $match[1] . $match[3] ] ?? null; + + return $entry !== null && strtolower( $match[2] ) === substr( $entry['sha'], 0, 8 ); + } + + /** + * Whether the file's copy under its hashed name is in the fonts folder + * + * @since 6.19.0 + */ + public function has_hashed_copy( string $file ): bool { + $hashed = $this->get_hashed_name( $file ); + + return $hashed !== '' && $hashed !== $file && is_file( $this->get_plain_path( $hashed ) ); + } + + /** + * Whether a file is in the fonts folder at its name, which a render checks without reading the manifest * * @since 6.19.0 */ public function is_downloaded( string $file ): bool { - return is_file( $this->get_font_path( $file ) ); + return is_file( $this->get_plain_path( $file ) ); + } + + /** + * Whether the file's own copy is in the fonts folder: its hashed copy, or the file at its name unless that is + * another font's by size (see is_size_conflict()) + * + * @since 6.19.0 + */ + public function is_installed( string $file ): bool { + return $this->has_hashed_copy( $file ) || ( is_file( $this->get_plain_path( $file ) ) && ! $this->is_size_conflict( $file ) ); + } + + /** + * Whether the file at a manifest name is another font's going by its size alone, which is a stat call: no build + * the sources know of the file has that size. A file of a known size can still be another font's; only hashing + * shows that, so it's left to the background sync. + * + * @since 6.19.0 + */ + public function is_size_conflict( string $file ): bool { + $hashed = $this->get_hashed_name( $file ); + $path = $this->get_plain_path( $file ); + + /* With no other name to take, the file is the one that is used */ + if ( $hashed === '' || $hashed === $file ) { + return false; + } + + clearstatcache( true, $path ); + + return is_file( $path ) && ! isset( $this->get_known_builds( $file )[ (int) filesize( $path ) ] ); + } + + /** + * The builds of a manifest file the sources know (the manifest's, the legacy manifests' and the sources' `legacy` + * hashes) as git blob SHA-1s by size + * + * @return array + * + * @since 6.19.0 + */ + public function get_known_builds( string $file ): array { + return $this->collect_builds( $file, $this->get_manifest()[ $file ] ?? null ); + } + + /** + * @param array{url: string, size: int, sha: string}|null $entry The file's manifest entry + * + * @return array + * + * @since 6.19.0 + */ + protected function collect_builds( string $file, ?array $entry ): array { + $builds = []; + if ( $entry !== null ) { + $builds[ $entry['size'] ][] = $entry['sha']; + } + + foreach ( $this->get_sources() as $id => $source ) { + $old = $this->get_legacy_manifest( (string) $id )[ $file ] ?? null; + if ( $old !== null ) { + $builds[ $old['size'] ][] = $old['sha']; + } + + foreach ( empty( $source['manifest'] ) ? [] : (array) ( $source['legacy'] ?? [] ) as $sha => $size ) { + $builds[ (int) $size ][] = (string) $sha; + } + } + + return $builds; } /** @@ -534,18 +808,42 @@ public function is_downloaded( string $file ): bool { public function register_font_data( $fonts ): array { $fonts = is_array( $fonts ) ? $fonts : []; + $collisions = $this->get_record_collisions(); + foreach ( $this->get_sources() as $source ) { foreach ( $source['families'] as $key => $family ) { if ( ! isset( $fonts[ $key ] ) ) { unset( $family['name'], $family['listed'] ); $fonts[ $key ] = $family; } + + /* mPDF's own data already has the core families */ + if ( is_array( $fonts[ $key ] ) && ! empty( $source['manifest'] ) ) { + $fonts[ $key ] = $this->use_hashed_names( $fonts[ $key ], $collisions ); + } } } return $fonts; } + /** + * A family with the files a Font Manager record uses renamed to their hashed names + * + * @param array $collisions get_record_collisions() + * + * @since 6.19.0 + */ + protected function use_hashed_names( array $family, array $collisions ): array { + foreach ( count( $collisions ) === 0 ? [] : [ 'R', 'B', 'I', 'BI' ] as $style ) { + if ( is_string( $family[ $style ] ?? null ) && isset( $collisions[ $family[ $style ] ] ) ) { + $family[ $style ] = $collisions[ $family[ $style ] ]; + } + } + + return $family; + } + /** * Add each source's `dir` to mPDF's `fontDir`, turning a single folder string into a list * @@ -747,6 +1045,69 @@ protected function get_core_source(): array { ]; } + /** + * Give a file name two sources use for different files (another SHA-1) its hashed name in the later source's manifest + * and families, so everything downstream sees a unique name. Identical files share one. The manifests are read here + * only when two sources have one; otherwise they're read lazily. + * + * @param array $sources + * + * @return array + * + * @since 6.19.0 + */ + protected function rename_colliding_files( array $sources ): array { + $downloadable = []; + foreach ( $sources as $id => $source ) { + if ( ! empty( $source['manifest'] ) ) { + $downloadable[] = $id; + } + } + + if ( count( $downloadable ) < 2 ) { + return $sources; + } + + $taken = []; + foreach ( $downloadable as $id ) { + $renames = []; + $resolved = []; + + foreach ( $this->read_manifest( $sources[ $id ] ) as $file => $entry ) { + $file = (string) $file; + $name = $file; + + if ( isset( $taken[ $file ] ) && $taken[ $file ] !== $entry['sha'] ) { + $name = $this->build_hashed_name( $file, $entry['sha'] ); + $renames[ $file ] = $name; + $this->log->notice( + 'Font file name used by two sources for different files', + [ + 'source' => $id, + 'name' => $file, + 'as' => $name, + ] + ); + } + + $taken[ $name ] = $taken[ $name ] ?? $entry['sha']; + $resolved[ $name ] = $entry; + } + + $this->manifests[ $id ] = $resolved; + + foreach ( $sources[ $id ]['families'] ?? [] as $key => $family ) { + foreach ( $renames === [] || ! is_array( $family ) ? [] : [ 'R', 'B', 'I', 'BI' ] as $style ) { + if ( is_string( $family[ $style ] ?? null ) && isset( $renames[ $family[ $style ] ] ) ) { + $sources[ $id ]['families'][ $key ][ $style ] = $renames[ $family[ $style ] ]; + } + } + } + } + + return $sources; + } + /** * A source's manifest, keyed by file name, skipping malformed entries * @@ -855,8 +1216,8 @@ protected function has_only_known_files( array $font, array $known ): bool { return false; } - /* mPDF finds a font by its file name in the fonts folder, so check the copy there */ - $path = $this->get_font_path( basename( $file ) ); + /* mPDF finds a font by its file name in the fonts folder, so check the copy there, not a hashed one */ + $path = $this->get_plain_path( basename( $file ) ); if ( ! is_file( $path ) || ! is_readable( $path ) ) { return false; } diff --git a/src/Model/Model_PDF.php b/src/Model/Model_PDF.php index e98525db1..e9ead8c19 100644 --- a/src/Model/Model_PDF.php +++ b/src/Model/Model_PDF.php @@ -2370,8 +2370,8 @@ function ( $val ) use ( &$flattened_fonts_array ) { $font_name = basename( $font ); $short_name = $this->options->get_font_short_name( substr( $font_name, 0, -4 ) ); - /* Check if it exists already, otherwise add it */ - if ( ! isset( $fonts[ $short_name ] ) && array_search( $font_name, $flattened_fonts_array, true ) === false ) { + /* Check if it exists already, otherwise add it. A core font's copy under a hashed name is found by that font */ + if ( ! isset( $fonts[ $short_name ] ) && array_search( $font_name, $flattened_fonts_array, true ) === false && ! $this->get_font_sources()->is_hashed_copy( $font_name ) ) { $fonts[ $short_name ] = [ 'R' => $font_name, ]; diff --git a/src/Model/Model_Required_Fonts.php b/src/Model/Model_Required_Fonts.php index 9648b86ae..ce7365f39 100644 --- a/src/Model/Model_Required_Fonts.php +++ b/src/Model/Model_Required_Fonts.php @@ -252,7 +252,7 @@ public function pdf_needs_download( array $pdf ): bool { */ public function needs_download( $font ): bool { foreach ( $this->get_font_files( $font ) as $file ) { - if ( ! $this->font_sources->is_downloaded( $file ) ) { + if ( ! $this->font_sources->is_installed( $file ) ) { return true; } } @@ -280,7 +280,7 @@ public function get_installed_counts(): array { $files = array_intersect( $required, array_keys( $manifest ) ); $counts[ (string) $id ] = [ - 'installed' => count( array_filter( $files, [ $this->font_sources, 'is_downloaded' ] ) ), + 'installed' => count( array_filter( $files, [ $this->font_sources, 'is_installed' ] ) ), 'total' => count( $files ), 'manifest' => count( $manifest ) > 0, ]; diff --git a/tests/phpunit/Concerns/CopiesFontFixtures.php b/tests/phpunit/Concerns/CopiesFontFixtures.php index f1bb95643..dc310defd 100644 --- a/tests/phpunit/Concerns/CopiesFontFixtures.php +++ b/tests/phpunit/Concerns/CopiesFontFixtures.php @@ -49,6 +49,11 @@ trait CopiesFontFixtures { */ protected $served_as = []; + /** + * @var bool Whether a test changed the manifest + */ + protected $manifest_changed = false; + protected function font_fixture( string $file ): string { return PDF_PLUGIN_DIR . 'tools/phpunit/data/fonts/' . $file; } @@ -168,6 +173,9 @@ function ( $preempt, $args, $url ) use ( $response, $change ) { if ( is_file( $this->font_fixture( $fixture ) ) ) { copy( $this->font_fixture( $fixture ), $args['filename'] ); $this->copied_fonts[] = $this->font_path( $file ); + + /* A file saved beside another font's has its hashed name */ + $this->copied_fonts[] = $this->font_path( GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( $file ) ); } if ( $change !== null ) { @@ -183,16 +191,57 @@ function ( $preempt, $args, $url ) use ( $response, $change ) { ); } + /** + * Put another font's file at a manifest name, one of a different size unless $same_size, then a copy of the fixture + * with a byte changed to be the same size as the fixture but not its hash + * + * @param string $name The manifest file name + * @param string $fixture The fixture the manifest lists for it + * @param bool $same_size + */ + protected function put_other_font_at( string $name, string $fixture, bool $same_size = false ): void { + if ( ! $same_size ) { + $this->copy_fixture( 'Chewy.ttf', $name ); + + return; + } + + $bytes = (string) file_get_contents( $this->font_fixture( $fixture ) ); + $last = strlen( $bytes ) - 1; + + $bytes[ $last ] = $bytes[ $last ] === 'x' ? 'y' : 'x'; + file_put_contents( $this->font_path( $name ), $bytes ); + $this->copied_fonts[] = $this->font_path( $name ); + } + + /** + * Put a renderable copy of the fixture at a manifest name that is one byte longer, so a size check finds it isn't + * the manifest's + */ + protected function put_longer_font_at( string $name, string $fixture ): void { + file_put_contents( $this->font_path( $name ), (string) file_get_contents( $this->font_fixture( $fixture ) ) . "\0" ); + $this->copied_fonts[] = $this->font_path( $name ); + } + /** * Serve a fixture as a manifest file, with a manifest entry matching the fixture so the plugin's downloader verifies it */ protected function serve_fixture_as( string $fixture, string $file ): void { + $this->list_fixture_as( $fixture, $file ); + $this->served_as[ $file ] = $fixture; + } + + /** + * List the fixture as the manifest file's build, so a copy of it at that name is the font's own and not another + * font's, which the plugin would save the real one beside + */ + protected function list_fixture_as( string $fixture, string $file ): void { $manifest = GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_manifest(); $manifest[ $file ] = $this->manifest_entry( $fixture, $manifest[ $file ]['url'] ?? 'https://example.com/' . $file ); $this->set_shared_manifest( $manifest ); - $this->served_as[ $file ] = $fixture; + $this->manifest_changed = true; } /** @@ -254,7 +303,7 @@ protected function remove_font_fixtures(): void { FlushCache::flush(); } - if ( $this->served_as !== [] ) { + if ( $this->manifest_changed ) { $this->set_shared_manifest( null ); } @@ -263,7 +312,8 @@ protected function remove_font_fixtures(): void { $this->copied_fonts = []; $this->recorded_fonts = []; $this->requested = []; - $this->served_as = []; + $this->served_as = []; + $this->manifest_changed = false; } /** @@ -272,6 +322,7 @@ protected function remove_font_fixtures(): void { protected function put_freeserif_on_disk(): void { foreach ( static::$freeserif as $file ) { $this->copy_fixture( 'Chewy.ttf', $file ); + $this->list_fixture_as( 'Chewy.ttf', $file ); } } } diff --git a/tests/phpunit/Concerns/Seamed_Font_Downloader.php b/tests/phpunit/Concerns/Seamed_Font_Downloader.php index 5498b9873..86e12fb1b 100644 --- a/tests/phpunit/Concerns/Seamed_Font_Downloader.php +++ b/tests/phpunit/Concerns/Seamed_Font_Downloader.php @@ -15,6 +15,11 @@ */ class Seamed_Font_Downloader extends Model_Font_Downloader { + /** + * @var string[] The files hashed to find another font's, by name + */ + public $hashed = []; + /** * Build the downloader with the real dependencies, overriding the seams given * @@ -47,4 +52,10 @@ public function get_required_font_files(): array { public function set_required( array $files ): void { $this->required_fonts->files = $files; } + + protected function hash_blob( string $path, int $size ): string { + $this->hashed[] = basename( $path ); + + return parent::hash_blob( $path, $size ); + } } diff --git a/tests/phpunit/integration/Helper/Mpdf/Test_On_Demand_Font_File_Finder.php b/tests/phpunit/integration/Helper/Mpdf/Test_On_Demand_Font_File_Finder.php index 694bd8b6d..b4847f78e 100644 --- a/tests/phpunit/integration/Helper/Mpdf/Test_On_Demand_Font_File_Finder.php +++ b/tests/phpunit/integration/Helper/Mpdf/Test_On_Demand_Font_File_Finder.php @@ -67,7 +67,6 @@ public function test_a_font_on_disk_is_found_without_a_request() { $this->assertFileEquals( $this->font_fixture( self::FONT ), $this->finder()->findFontFile( self::FONT ) ); $this->assertSame( [], $this->requested ); - $this->assertNull( $this->get_private( $this->sources, 'manifest' ) ); } public function test_a_missing_core_font_is_downloaded() { @@ -191,6 +190,116 @@ public function test_debug_mode_builds_a_finder_without_a_fallback() { $this->assertFalse( $this->get_private( $this->downloader->get_font_file_finder( [] ), 'fallback' ) ); } + public function test_a_file_at_the_name_the_manifest_does_not_own_is_left_and_the_font_saved_under_its_hashed_name() { + $this->put_other_font_at( self::FONT, self::FONT ); + $this->serve_font_downloads(); + $hashed = $this->sources->get_hashed_name( self::FONT ); + + $this->assertMatchesRegularExpression( '/^DejaVuSerifCondensed-[0-9a-f]{8}\.ttf$/', $hashed ); + $this->assertSame( $this->font_dir . $hashed, $this->finder()->findFontFile( self::FONT ) ); + $this->assertFileEquals( $this->font_fixture( self::FONT ), $this->font_dir . $hashed ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_dir . self::FONT ); + $this->assertCount( 1, $this->requested ); + } + + public function test_a_font_manager_records_file_at_a_core_fonts_name_is_not_replaced_by_the_core_font() { + $this->put_other_font_at( self::FONT, self::FONT ); + $this->add_record( 'mine', [ 'regular' => self::FONT ] ); + $this->serve_font_downloads(); + + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->finder()->findFontFile( self::FONT ) ); + $this->assertSame( [], $this->requested ); + $this->assertFileDoesNotExist( $this->font_dir . $this->sources->get_hashed_name( self::FONT ) ); + } + + public function test_the_hashed_copy_is_found_before_the_file_at_the_name_without_a_request_or_hashing() { + $this->put_other_font_at( self::FONT, self::FONT ); + $this->copy_fixture( self::FONT, $this->sources->get_hashed_name( self::FONT ) ); + $this->serve_font_downloads(); + $downloader = $this->counting_downloader(); + + $finder = new On_Demand_Font_File_Finder( [ $this->font_dir ], $downloader, self::DEFAULT_FONT ); + + $this->assertSame( $this->font_dir . $this->sources->get_hashed_name( self::FONT ), $finder->findFontFile( self::FONT ) ); + $this->assertSame( [], $this->requested ); + $this->assertSame( 0, $downloader->hashed ); + } + + public function test_a_file_of_a_known_size_is_not_hashed_while_rendering() { + copy( $this->font_fixture( self::FONT ), $this->font_dir . self::FONT ); + $this->put_other_font_at( self::DEFAULT_FONT, self::DEFAULT_FONT, true ); + $this->serve_font_downloads(); + $downloader = $this->counting_downloader(); + + $finder = new On_Demand_Font_File_Finder( [ $this->font_dir ], $downloader, self::DEFAULT_FONT ); + + $this->assertSame( $this->font_dir . self::FONT, $finder->findFontFile( self::FONT ) ); + $this->assertSame( $this->font_dir . self::DEFAULT_FONT, $finder->findFontFile( self::DEFAULT_FONT ) ); + $this->assertSame( [], $this->requested ); + $this->assertSame( 0, $downloader->hashed ); + } + + public function test_a_file_from_an_older_release_is_not_another_fonts() { + copy( $this->font_fixture( 'DejaVuSerifCondensed-v1.0.0.ttf' ), $this->font_dir . self::FONT ); + $this->serve_font_downloads(); + + $this->assertSame( $this->font_dir . self::FONT, $this->finder()->findFontFile( self::FONT ) ); + $this->assertSame( [], $this->requested ); + $this->assertFileDoesNotExist( $this->font_dir . $this->sources->get_hashed_name( self::FONT ) ); + } + + public function test_a_file_at_the_name_stays_in_use_when_downloads_are_off() { + $this->put_other_font_at( self::FONT, self::FONT ); + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + $this->serve_font_downloads(); + + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->finder()->findFontFile( self::FONT ) ); + $this->assertSame( [], $this->requested ); + $this->assertEmpty( $this->get_font_failures() ); + } + + public function test_a_file_at_the_name_stays_in_use_when_the_download_fails_and_backs_off() { + $this->put_other_font_at( self::FONT, self::FONT ); + $this->fail_font_downloads(); + + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->finder()->findFontFile( self::FONT ) ); + $this->assertSame( [ self::FONT ], array_keys( $this->get_font_failures() ) ); + $this->assertCount( 1, $this->requested ); + + /* Backing off: no second request, and the same file */ + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->finder()->findFontFile( self::FONT ) ); + $this->assertCount( 1, $this->requested ); + $this->assertFileDoesNotExist( $this->font_dir . $this->sources->get_hashed_name( self::FONT ) ); + } + + public function test_a_file_at_the_name_stays_in_use_in_debug_mode_when_the_download_fails() { + $this->put_other_font_at( self::FONT, self::FONT ); + $this->fail_font_downloads(); + + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->finder( self::DEFAULT_FONT, false )->findFontFile( self::FONT ) ); + } + + public function test_a_font_in_another_directory_is_found_without_loading_the_manifest() { + $this->serve_font_downloads(); + + $this->assertSame( PDF_PLUGIN_DIR . 'fonts/gfpdf-Arimo-Regular.ttf', ( new On_Demand_Font_File_Finder( [ PDF_PLUGIN_DIR . 'fonts' ], $this->downloader, '' ) )->findFontFile( 'gfpdf-Arimo-Regular.ttf' ) ); + $this->assertNull( $this->get_private( $this->sources, 'manifest' ) ); + $this->assertSame( [], $this->requested ); + } + + private function counting_downloader(): Model_Font_Downloader { + return new class( $this->sources, GPDFAPI::get_data_class(), GPDFAPI::get_options_class(), GPDFAPI::get_misc_class(), new NullLogger(), GPDFAPI::get_mvc_class( 'Model_Required_Fonts' ) ) extends Model_Font_Downloader { + /** @var int */ + public $hashed = 0; + + protected function hash_blob( string $path, int $size ): string { + $this->hashed++; + + return parent::hash_blob( $path, $size ); + } + }; + } + /** * A finder searching the fonts folder */ diff --git a/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php b/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php index 70b99d2d7..9a7635658 100644 --- a/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php +++ b/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php @@ -165,6 +165,57 @@ public function test_delete_removes_the_family_whatever_its_hash_and_keeps_the_l $this->assertCount( 1, $this->log_messages( 'Downloaded font deleted' ) ); } + public function test_delete_removes_only_the_hashed_copy_and_never_the_file_at_the_name() { + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + $hashed = $sources->get_hashed_name( 'DejaVuSerifCondensed.ttf' ); + + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSerifCondensed.ttf' ); + $this->copy_fixture( 'DejaVuSerifCondensed.ttf', $hashed ); + + $fonts = $this->downloader()->get_downloaded_fonts(); + $this->assertSame( filesize( $this->font_fixture( 'DejaVuSerifCondensed.ttf' ) ), $fonts[0]['size'], 'The hashed copy is the family\'s file' ); + + $result = $this->downloader()->delete_downloaded_font( 'dejavuserifcondensed' ); + + $this->assertSame( [ 'DejaVuSerifCondensed.ttf' ], $result['removed'] ); + $this->assertSame( filesize( $this->font_fixture( 'DejaVuSerifCondensed.ttf' ) ), $result['bytes'] ); + $this->assertFileDoesNotExist( $this->font_path( $hashed ) ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + } + + public function test_a_family_sharing_a_name_with_a_record_lists_and_deletes_only_its_hashed_copy() { + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + $hashed = $sources->get_hashed_name( 'DejaVuSerifCondensed.ttf' ); + + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSerifCondensed.ttf' ); + $this->add_record( 'mine', [ 'regular' => 'DejaVuSerifCondensed.ttf' ] ); + + $this->assertSame( [], $this->downloader()->get_downloaded_fonts(), 'The record\'s file is not the family\'s' ); + + $this->copy_fixture( 'DejaVuSerifCondensed.ttf', $hashed ); + $fonts = $this->downloader()->get_downloaded_fonts(); + + $this->assertSame( $hashed, $fonts[0]['files'][0]['file'] ); + + $result = $this->downloader()->delete_downloaded_font( 'dejavuserifcondensed' ); + + $this->assertSame( [ $hashed ], $result['removed'] ); + $this->assertFileDoesNotExist( $this->font_path( $hashed ) ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + } + + public function test_deleting_a_record_leaves_the_hashed_copy() { + $hashed = GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( 'DejaVuSerifCondensed.ttf' ); + + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSerifCondensed.ttf' ); + $this->copy_fixture( 'DejaVuSerifCondensed.ttf', $hashed ); + $this->add_record( 'mine', [ 'regular' => 'DejaVuSerifCondensed.ttf' ] ); + + $this->assertTrue( GPDFAPI::delete_pdf_font( 'mine' ) ); + $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertFileExists( $this->font_path( $hashed ) ); + } + public function test_delete_clears_the_familys_failures_and_metrics() { $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); $this->set_font_failures( diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php index 51fb7436a..0b2ed2c57 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php @@ -363,6 +363,148 @@ public function test_the_kill_switch_constant_turns_downloads_off() { $this->assertSame( [], $this->requested ); } + public function test_download_saves_the_font_under_its_hashed_name_when_another_font_holds_the_name() { + $this->put_other_font_at( self::FONT, self::FONT ); + $this->serve_font_downloads(); + $hashed = GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ); + + $this->assertTrue( $this->downloader->download( self::FONT ) ); + $this->assertFileEquals( $this->font_fixture( self::FONT ), $this->font_path( $hashed ) ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_path( self::FONT ), 'The other font is left alone' ); + $this->assertSame( $this->font_path( $hashed ), $this->downloader->get_font_path( self::FONT ) ); + $this->assertCount( 1, $this->log_messages( 'Font saved under a new name as another font has its name' ) ); + $this->assertNoStrayTmpFiles(); + + /* The next request finds the copy and asks for nothing */ + $this->requested = []; + $this->assertTrue( $this->new_downloader()->download( self::FONT ) ); + $this->assertSame( [], $this->requested ); + } + + public function test_saving_a_hashed_copy_removes_the_copy_of_an_older_release_but_not_a_lookalike() { + $old = 'DejaVuSerifCondensed-' . substr( (string) key( $this->fixture_blob( 'DejaVuSans.ttf' ) ), 0, 8 ) . '.ttf'; + + $this->put_other_font_at( self::FONT, self::FONT ); + $this->copy_fixture( 'DejaVuSans.ttf', $old ); + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSerifCondensed-deadbeef.ttf' ); + $this->serve_font_downloads(); + + $this->assertTrue( $this->downloader->download( self::FONT ) ); + $this->assertFileDoesNotExist( $this->font_path( $old ) ); + $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed-deadbeef.ttf' ), 'Its name is not its own hash' ); + $this->assertFileExists( $this->font_path( GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ) ) ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_path( self::FONT ) ); + } + + public function test_the_hashed_name_is_the_stem_the_first_eight_of_the_sha_and_the_extension() { + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + $sha = $sources->get_manifest()[ self::FONT ]['sha']; + + $this->assertSame( 'DejaVuSerifCondensed-' . substr( $sha, 0, 8 ) . '.ttf', $sources->get_hashed_name( self::FONT ) ); + $this->assertSame( '', $sources->get_hashed_name( 'NotAFont.ttf' ) ); + $this->assertTrue( $sources->is_hashed_copy( $sources->get_hashed_name( self::FONT ) ) ); + $this->assertFalse( $sources->is_hashed_copy( self::FONT ) ); + $this->assertFalse( $sources->is_hashed_copy( 'DejaVuSerifCondensed-00000000.ttf' ) ); + } + + public function test_another_fonts_file_stays_when_downloads_are_off() { + $this->put_other_font_at( self::FONT, self::FONT ); + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + $this->serve_font_downloads(); + + $this->assertFalse( $this->downloader->download( self::FONT ) ); + $this->assertSame( [], $this->requested ); + $this->assertSame( $this->font_path( self::FONT ), $this->downloader->get_font_path( self::FONT ) ); + $this->assertTrue( GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->is_downloaded( self::FONT ), 'mPDF can still load it' ); + } + + public function test_a_failed_download_beside_another_font_is_recorded_under_the_manifest_name() { + $this->put_other_font_at( self::FONT, self::FONT ); + $this->fail_font_downloads(); + + $this->assertFalse( $this->downloader->download( self::FONT ) ); + $this->assertSame( [ self::FONT ], array_keys( $this->get_font_failures() ) ); + $this->assertIsInt( $this->get_back_off( self::FONT ) ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_path( self::FONT ) ); + } + + public function test_a_file_of_a_known_size_is_not_hashed_unless_verifying() { + $this->put_other_font_at( self::FONT, self::FONT, true ); + $this->serve_font_downloads(); + $downloader = $this->seamed( [] ); + + $this->assertTrue( $downloader->download( self::FONT ) ); + $this->assertSame( [], $downloader->hashed ); + $this->assertSame( [], $this->requested ); + + /* Verifying finds that it isn't the manifest's */ + $this->assertTrue( $downloader->download( self::FONT, true ) ); + $this->assertSame( [ self::FONT ], $downloader->hashed ); + $this->assertFileEquals( $this->font_fixture( self::FONT ), $this->font_path( GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ) ) ); + } + + public function test_a_file_from_an_older_release_is_kept_when_verifying() { + copy( $this->font_fixture( 'DejaVuSerifCondensed-v1.0.0.ttf' ), $this->font_path( self::FONT ) ); + $this->copied_fonts[] = $this->font_path( self::FONT ); + $this->serve_font_downloads(); + + /* The real list of what the installer downloaded, not the fixtures set_up() stands in for it */ + $this->set_private( GPDFAPI::get_mvc_class( 'Model_Font_Sources' ), 'legacy_manifests', [] ); + + $this->assertTrue( $this->downloader->download( self::FONT, true ) ); + $this->assertSame( [], $this->requested ); + } + + public function test_sync_hashes_each_required_file_on_disk_once_and_saves_the_other_fonts_under_new_names() { + $this->copy_fixture( self::FONT ); + $this->put_other_font_at( 'DejaVuSansCondensed.ttf', 'DejaVuSansCondensed.ttf', true ); + $this->serve_font_downloads(); + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + + $downloader = $this->seamed_sync( [ self::FONT, 'DejaVuSansCondensed.ttf' ] ); + $downloader->sync(); + + $this->assertEqualsCanonicalizing( [ self::FONT, 'DejaVuSansCondensed.ttf' ], $downloader->hashed ); + $this->assertCount( 1, $this->requested ); + $this->assertFileExists( $this->font_path( $sources->get_hashed_name( 'DejaVuSansCondensed.ttf' ) ) ); + $this->assertFileDoesNotExist( $this->font_path( $sources->get_hashed_name( self::FONT ) ) ); + + /* A file with a copy under its hashed name is not hashed again */ + $next = $this->seamed_sync( [ self::FONT, 'DejaVuSansCondensed.ttf' ] ); + $next->sync(); + + $this->assertSame( [ self::FONT ], $next->hashed ); + $this->assertCount( 1, $this->requested ); + } + + public function test_a_retry_for_a_file_beside_another_font_is_not_cleared() { + $this->put_other_font_at( self::FONT, self::FONT ); + $this->set_font_failures( [ self::FONT => $this->failure() ] ); + $this->set_back_off( self::FONT ); + $this->serve_font_downloads(); + + $this->seamed_sync( [] )->sync(); + + $this->assertFileExists( $this->font_path( GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ) ) ); + $this->assertEmpty( $this->get_font_failures() ); + } + + public function test_prune_never_removes_a_hashed_copy_or_a_replaced_file() { + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + $hashed = $sources->get_hashed_name( self::FONT ); + + /* The unmodified installer file goes, beside the manifest's copy of it */ + $this->copy_fixture( self::FONT ); + $this->copy_fixture( self::FONT, $hashed ); + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSans.ttf' ); + $this->copy_fixture( 'DejaVuSans.ttf', $sources->get_hashed_name( 'DejaVuSans.ttf' ) ); + + $this->assertSame( [ self::FONT ], $this->downloader->prune( [] ) ); + $this->assertFileExists( $this->font_path( $hashed ) ); + $this->assertFileExists( $this->font_path( 'DejaVuSans.ttf' ), 'A replacement' ); + $this->assertFileExists( $this->font_path( $sources->get_hashed_name( 'DejaVuSans.ttf' ) ) ); + } + public function test_prune_removes_unmodified_core_fonts_and_licences() { $this->set_installer_manifest( array_combine( static::$core_fixtures, static::$core_fixtures ) + [ 'DejaVuinfo.txt' => 'chewy-LICENSE.txt' ] ); $this->copy_fixture( 'chewy-LICENSE.txt', 'DejaVuinfo.txt' ); @@ -594,6 +736,7 @@ public function test_sync_downloads_the_required_set_as_it_is_when_it_runs() { public function test_sync_skips_files_already_on_disk() { $this->copy_fixture( 'Chewy.ttf', 'Garuda.ttf' ); + $this->list_fixture_as( 'Chewy.ttf', 'Garuda.ttf' ); $this->serve_font_downloads(); $this->seamed_sync( [ 'Garuda.ttf', self::FONT ] )->sync(); diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php index e94418114..c2949e8ac 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php @@ -6,6 +6,7 @@ use GFPDF\Exceptions\GravityPdfIdException; use GFPDF\Tests\Concerns\CopiesFontFixtures; +use GFPDF\Tests\Concerns\Seamed_Font_Downloader; use GFPDF\Tests\Concerns\UnregistersMvcClasses; use GFPDF\Tests\Concerns\StubsFontSources; use GFPDF\Tests\Integration\TestCase; @@ -963,6 +964,176 @@ protected function assert_record_kept( array $adopted ): void { $this->assertTrue( $this->log->hasRecordThatContains( 'Could not remove a custom font record replaced by a font source', Logger::ERROR ) ); } + public function test_two_sources_using_one_name_for_different_files_give_the_later_one_the_hashed_name() { + $first = [ 'Shared.ttf' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Shared.ttf' ) ]; + $second = [ + 'Shared.ttf' => $this->manifest_entry( 'DejaVuSans.ttf', 'https://example.com/DejaVuSans.ttf' ), + 'Only.ttf' => $this->manifest_entry( 'DejaVuSans.ttf', 'https://example.com/Only.ttf' ), + ]; + $model = $this->get_model_with_two_sources( $first, $second ); + $hashed = 'Shared-' . substr( $second['Shared.ttf']['sha'], 0, 8 ) . '.ttf'; + + $sources = $model->get_sources(); + + $this->assertSame( [ 'Shared.ttf' ], array_keys( $model->get_source_manifest( 'bundled' ) ) ); + $this->assertSame( [ $hashed, 'Only.ttf' ], array_keys( $model->get_source_manifest( 'core' ) ) ); + $this->assertSame( [ 'Shared.ttf', $hashed, 'Only.ttf' ], array_keys( $model->get_manifest() ) ); + $this->assertSame( $first['Shared.ttf'], $model->get_manifest()['Shared.ttf'] ); + $this->assertSame( $second['Shared.ttf'], $model->get_manifest()[ $hashed ] ); + + /* Only the later source's family files are rewritten, and only the ones renamed */ + $this->assertSame( 'Shared.ttf', $sources['bundled']['families']['first']['R'] ); + $this->assertSame( $hashed, $sources['core']['families']['second']['R'] ); + $this->assertSame( 'Only.ttf', $sources['core']['families']['second']['B'] ); + $this->assertSame( $hashed, $model->register_font_data( [] )['second']['R'] ); + + $this->assertTrue( $this->log->hasRecordThatContains( 'Font file name used by two sources for different files', Logger::NOTICE ) ); + } + + public function test_a_family_uses_the_hashed_name_of_a_file_a_font_manager_record_uses() { + $this->put_longer_font_at( 'DejaVuSans.ttf', 'DejaVuSans.ttf' ); + $this->add_record( 'mine', [ 'regular' => 'DejaVuSans.ttf' ] ); + $hashed = $this->model->get_hashed_name( 'DejaVuSans.ttf' ); + + $fonts = $this->model->register_font_data( [] ); + + $this->assertSame( $hashed, $fonts['dejavusans']['R'] ); + $this->assertSame( 'DejaVuSans-Bold.ttf', $fonts['dejavusans']['B'], 'Only the shared file is renamed' ); + $this->assertSame( [ 'DejaVuSans.ttf' => $hashed ], $this->model->get_record_collisions() ); + $this->assertTrue( $this->model->is_record_file( 'DejaVuSans.ttf' ) ); + $this->assertArrayHasKey( $hashed, $this->model->get_manifest() ); + $this->assertArrayHasKey( $hashed, $this->model->get_source_manifest( 'core' ) ); + $this->assertTrue( $this->model->is_core_font_file( $hashed ) ); + } + + public function test_a_record_holding_a_known_build_of_the_core_font_is_not_custom() { + $this->copy_fixture( 'DejaVuSans.ttf' ); + $this->add_record( 'mine', [ 'regular' => 'DejaVuSans.ttf' ] ); + + $this->assertSame( [], $this->model->get_record_collisions() ); + $this->assertSame( 'DejaVuSans.ttf', $this->model->register_font_data( [] )['dejavusans']['R'] ); + } + + public function test_a_record_on_another_site_is_only_a_collision_on_that_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $this->put_longer_font_at( 'DejaVuSans.ttf', 'DejaVuSans.ttf' ); + $blog = self::factory()->blog->create(); + + switch_to_blog( $blog ); + try { + $this->add_record( 'mine', [ 'regular' => 'DejaVuSans.ttf' ] ); + $this->assertSame( [ 'DejaVuSans.ttf' ], array_keys( $this->model->get_record_collisions() ) ); + GPDFAPI::delete_plugin_option( 'custom_fonts' ); + } finally { + restore_current_blog(); + } + + $this->model->flush(); + $this->assertSame( [], $this->model->get_record_collisions() ); + } + + public function test_a_record_whose_file_is_not_a_core_name_reads_no_manifest() { + $this->copy_fixture( 'Chewy.ttf' ); + $this->add_record( 'chewy', [ 'regular' => 'Chewy.ttf' ] ); + $this->set_private( $this->model, 'manifests', [] ); + + $this->assertSame( [], $this->model->get_record_collisions() ); + $this->assertSame( [], $this->get_private( $this->model, 'manifests' ) ); + } + + public function test_a_renamed_file_is_not_renamed_again() { + $model = $this->get_model_with_two_sources( + [ 'Shared.ttf' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Shared.ttf' ) ], + [ 'Shared.ttf' => $this->manifest_entry( 'DejaVuSans.ttf', 'https://example.com/DejaVuSans.ttf' ) ] + ); + + $renamed = array_keys( $model->get_source_manifest( 'core' ) )[0]; + + $this->assertSame( $renamed, $model->get_hashed_name( $renamed ) ); + $this->assertFalse( $model->is_size_conflict( $renamed ) ); + } + + public function test_two_sources_using_one_name_for_the_same_file_share_it() { + $entry = $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Shared.ttf' ); + $model = $this->get_model_with_two_sources( [ 'Shared.ttf' => $entry ], [ 'Shared.ttf' => $entry ] ); + + $sources = $model->get_sources(); + + $this->assertSame( [ 'Shared.ttf' ], array_keys( $model->get_manifest() ) ); + $this->assertSame( 'Shared.ttf', $sources['core']['families']['second']['R'] ); + $this->assertFalse( $this->log->hasRecordThatContains( 'Font file name used by two sources for different files', Logger::NOTICE ) ); + } + + public function test_a_single_downloadable_source_is_not_read_when_the_sources_are_built() { + $model = new Model_Font_Sources( GPDFAPI::get_data_class(), $this->custom_fonts, GPDFAPI::get_log_class() ); + $model->get_sources(); + + $this->assertSame( [], $this->get_private( $model, 'manifests' ) ); + $this->assertNull( $this->get_private( $model, 'manifest' ) ); + } + + public function test_the_renamed_source_downloads_to_its_hashed_name() { + $model = $this->get_model_with_two_sources( [ 'DejaVuSerifCondensed.ttf' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Chewy.ttf' ) ], [] ); + $core = $model->get_source_manifest( 'core' ); + + /* The later source is the real core manifest, so its file takes the hashed name */ + $name = 'DejaVuSerifCondensed-' . substr( $this->manifest_entry( 'DejaVuSerifCondensed.ttf', '' )['sha'], 0, 8 ) . '.ttf'; + $this->assertArrayHasKey( $name, $core ); + $this->assertSame( $name, $model->get_sources()['core']['families']['dejavuserifcondensed']['R'] ); + + $this->serve_font_downloads(); + $this->copied_fonts[] = $this->font_dir . $name; + $downloader = new Seamed_Font_Downloader( GPDFAPI::get_log_class(), [ 'sources' => $model ] ); + + $this->assertTrue( $downloader->download( $name ) ); + $this->assertFileEquals( $this->font_fixture( 'DejaVuSerifCondensed.ttf' ), $this->font_dir . $name ); + $this->assertFileDoesNotExist( $this->font_dir . 'DejaVuSerifCondensed.ttf' ); + $this->assertSame( [ $core[ $name ]['url'] ], $this->requested ); + } + + public function test_a_file_is_another_fonts_when_its_size_is_no_known_build() { + $model = $this->get_model_with_manifest( [ 'Mine.ttf' => $this->manifest_entry( 'DejaVuSans.ttf', 'https://example.com/Mine.ttf' ) ] ); + + $this->assertFalse( $model->is_size_conflict( 'Mine.ttf' ), 'Missing' ); + $this->assertFalse( $model->is_installed( 'Mine.ttf' ) ); + + $this->copy_fixture( 'DejaVuSans.ttf', 'Mine.ttf' ); + $this->assertFalse( $model->is_size_conflict( 'Mine.ttf' ) ); + $this->assertTrue( $model->is_installed( 'Mine.ttf' ) ); + + $this->copy_fixture( 'Chewy.ttf', 'Mine.ttf' ); + $this->assertTrue( $model->is_size_conflict( 'Mine.ttf' ) ); + $this->assertFalse( $model->is_installed( 'Mine.ttf' ) ); + $this->assertTrue( $model->is_downloaded( 'Mine.ttf' ), 'The file at its name' ); + + $this->copy_fixture( 'DejaVuSans.ttf', $model->get_hashed_name( 'Mine.ttf' ) ); + $this->assertTrue( $model->is_installed( 'Mine.ttf' ) ); + $this->assertSame( $this->font_dir . $model->get_hashed_name( 'Mine.ttf' ), $model->get_font_path( 'Mine.ttf' ) ); + } + + public function test_an_older_build_of_a_file_has_a_known_size() { + $model = $this->get_model_with_manifest( [ 'Mine.ttf' => $this->manifest_entry( 'DejaVuSans.ttf', 'https://example.com/Mine.ttf' ) ] ); + $this->set_private( $model, 'legacy_manifests', [ 'core' => [ 'Mine.ttf' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Mine.ttf' ) ] ] ); + + $this->copy_fixture( 'Chewy.ttf', 'Mine.ttf' ); + + $this->assertFalse( $model->is_size_conflict( 'Mine.ttf' ) ); + $this->assertTrue( $model->is_installed( 'Mine.ttf' ) ); + $this->assertCount( 2, $model->get_known_builds( 'Mine.ttf' ) ); + } + + public function test_adoption_checks_the_records_file_not_a_hashed_copy() { + $model = $this->get_model_with_legacy( 'Chewy.ttf' ); + + $this->copy_fixture( 'Chewy.ttf', 'Arimo-Regular.ttf' ); + $this->add_record( 'arimo', [ 'regular' => 'Arimo-Regular.ttf' ] ); + + $this->assertSame( [ 'arimo' ], $model->adopt_records( 'bundled' ) ); + } + /** * A model whose core source reads a test manifest * @@ -992,4 +1163,58 @@ protected function get_core_source(): array { protected function core_fontdata(): array { return ( new FontVariables() )->getDefaults()['fontdata']; } + + /** + * A model whose first source (`bundled`) and later source (`core`) each have a manifest of the entries given, and a + * family using each file. With no entries for the second, `core` is the real one. + * + * @param array $first + * @param array $second + */ + protected function get_model_with_two_sources( array $first, array $second ): Model_Font_Sources { + $write = function ( string $suffix, array $entries ): string { + $path = $this->manifest_path . $suffix . '.php'; + wp_delete_file( $path ); + file_put_contents( $path, 'copied_fonts[] = $path; + + return $path; + }; + + $model = new class( GPDFAPI::get_data_class(), $this->custom_fonts, GPDFAPI::get_log_class() ) extends Model_Font_Sources { + /** @var string[] The first source's and second source's manifests */ + public $manifests_at = []; + + /** @var bool Whether the second source is the real core one */ + public $real_core = false; + + protected function get_bundled_source(): array { + return [ + 'families' => [ 'first' => [ 'R' => 'Shared.ttf' ] ], + 'manifest' => $this->manifests_at[0], + ]; + } + + protected function get_core_source(): array { + if ( $this->real_core ) { + return parent::get_core_source(); + } + + return [ + 'families' => [ + 'second' => [ + 'R' => 'Shared.ttf', + 'B' => 'Only.ttf', + ], + ], + 'manifest' => $this->manifests_at[1], + ]; + } + }; + + $model->manifests_at = [ $write( '-first', $first ), $second === [] ? '' : $write( '-second', $second ) ]; + $model->real_core = $second === []; + + return $model; + } } diff --git a/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php b/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php index 61f7f4b38..2a4ef0392 100644 --- a/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php +++ b/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php @@ -271,6 +271,35 @@ public function test_installed_counts_are_the_required_files_per_downloadable_so $this->assertSame( 1, $this->required->get_installed_counts()['core']['installed'] ); } + public function test_another_fonts_file_at_a_name_is_not_an_installed_file() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavuserifcondensed' ); + $this->put_other_font_at( self::FONT, self::FONT ); + + $this->assertSame( 0, $this->required->get_installed_counts()['core']['installed'] ); + $this->assertTrue( $this->required->needs_download( 'dejavuserifcondensed' ) ); + $this->assertTrue( $this->required->pdf_needs_download( [ 'font' => 'dejavuserifcondensed' ] ) ); + + /* Its copy under the hashed name is */ + $this->copy_fixture( self::FONT, GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ) ); + + $this->assertSame( 1, $this->required->get_installed_counts()['core']['installed'] ); + } + + public function test_a_family_sharing_a_name_with_a_record_requires_its_hashed_file() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavuserifcondensed' ); + $this->put_other_font_at( self::FONT, self::FONT ); + $this->add_record( 'mine', [ 'regular' => self::FONT ] ); + $hashed = GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ); + + $this->assertContains( $hashed, $this->required->get_required_font_files() ); + $this->assertNotContains( self::FONT, $this->required->get_required_font_files() ); + $this->assertSame( [ 'installed' => 0, 'total' => 4, 'manifest' => true ], $this->required->get_installed_counts()['core'] ); + + $this->copy_fixture( self::FONT, $hashed ); + + $this->assertSame( 1, $this->required->get_installed_counts()['core']['installed'] ); + } + public function test_a_source_with_a_missing_manifest_is_flagged() { $this->assertSame( [ diff --git a/tests/phpunit/integration/Model/Test_PDF.php b/tests/phpunit/integration/Model/Test_PDF.php index 873bbbf37..1a160edc3 100644 --- a/tests/phpunit/integration/Model/Test_PDF.php +++ b/tests/phpunit/integration/Model/Test_PDF.php @@ -1798,6 +1798,22 @@ public function test_add_unregistered_fonts_to_mPDF() { $this->assertArrayNotHasKey( 'aladin', $fonts ); } + public function test_a_core_fonts_hashed_copy_is_not_autoloaded_as_a_font() { + global $gfpdf; + + $hashed = GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( 'DejaVuSans.ttf' ); + $path = $gfpdf->data->template_font_location . $hashed; + touch( $path ); + + try { + $fonts = $this->model->add_unregistered_fonts_to_mPDF( [] ); + } finally { + wp_delete_file( $path ); + } + + $this->assertNotContains( $hashed, array_column( $fonts, 'R' ) ); + } + /** * Test that our field exists * diff --git a/tests/phpunit/integration/Test_Font_Rendering.php b/tests/phpunit/integration/Test_Font_Rendering.php index bc7db7fc1..9197725c0 100644 --- a/tests/phpunit/integration/Test_Font_Rendering.php +++ b/tests/phpunit/integration/Test_Font_Rendering.php @@ -123,6 +123,7 @@ public function provider_substitutes(): array { public function test_sun_exta_on_disk_is_substituted_from_without_a_download(): void { /* Sun-ExtA's font data needs no OpenType tables, so any fixture stands in; Chewy has no CJK */ $this->copy_fixture( 'Chewy.ttf', 'Sun-ExtA.ttf' ); + $this->list_fixture_as( 'Chewy.ttf', 'Sun-ExtA.ttf' ); FlushCache::flush(); $this->fail_font_downloads(); @@ -150,6 +151,7 @@ public function test_a_cjk_extension_b_character_does_not_download_sun_extb(): v public function test_sun_extb_on_disk_is_the_cjk_extension_b_fallback_without_a_download(): void { /* Sun-ExtB's font data needs no OpenType tables, so any fixture stands in */ $this->copy_fixture( 'Chewy.ttf', 'Sun-ExtB.ttf' ); + $this->list_fixture_as( 'Chewy.ttf', 'Sun-ExtB.ttf' ); FlushCache::flush(); $this->fail_font_downloads(); @@ -192,67 +194,78 @@ public function test_an_adopted_template_arimo_record_renders_the_bundled_faces( $this->assertRenderMatchesSnapshot( 'adopted-arimo', '

Adopted

' ); } - public function test_a_render_with_its_fonts_on_disk_does_not_load_the_manifest(): void { - $this->set_private( GPDFAPI::get_mvc_class( 'Model_Font_Sources' ), 'manifest', null ); + public function test_a_render_with_its_fonts_on_disk_makes_no_request(): void { + $this->serve_font_downloads(); $this->render( '

Regular Bold

' ); + $this->assertSame( [], $this->requested ); + } + + public function test_a_render_with_bundled_fonts_does_not_load_the_manifest(): void { + $this->set_private( GPDFAPI::get_mvc_class( 'Model_Font_Sources' ), 'manifest', null ); + + $this->render( '

Regular Bold

', [ 'font' => 'arimo' ] ); + $this->assertNull( $this->get_private( GPDFAPI::get_mvc_class( 'Model_Font_Sources' ), 'manifest' ) ); } - public function test_a_missing_core_font_downloads_while_rendering(): void { - static::remove_test_fonts(); + public function test_a_core_font_another_font_holds_the_name_of_renders_from_its_hashed_copy(): void { + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + $hashed = $sources->get_hashed_name( 'DejaVuSans.ttf' ); - /* Cached metrics change which substitutes mPDF loads */ + /* Another font's file at the name, and the manifest's own beside it */ + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSans.ttf' ); + $this->copy_fixture( 'DejaVuSans.ttf', $hashed ); FlushCache::flush(); - $this->serve_fixture_as( 'DejaVuSerifCondensed.ttf', 'Garuda.ttf' ); $this->serve_font_downloads(); - /* DejaVu Serif Condensed has the OpenType tables Garuda's font data needs, but no Thai */ - $this->assertRenderMatchesSnapshot( 'downloaded-thai-font', '

สวัสดี

', [ 'font' => 'arimo' ] ); + $render = $this->render( '

Regular

', [ 'font' => 'dejavusans' ] ); - /* Only the Thai font downloads: no substitute has Thai, so none is fetched for the glyphs the stand-in lacks */ - $this->assertSame( [ 'https://raw.githubusercontent.com/GravityPDF/mpdf-core-fonts/v1.1.1/Garuda.ttf' ], $this->requested ); + $this->assertSame( $hashed, basename( $render['mpdf']->fonts['dejavusans']['ttffile'] ) ); + $this->assertSame( [], $this->requested ); } - public function test_a_font_that_cannot_download_renders_in_arimo_when_the_default_font_is_missing(): void { - GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavusanscondensed' ); - static::remove_test_fonts(); - $this->fail_font_downloads(); + public function test_a_core_font_another_font_holds_the_name_of_downloads_beside_it_and_renders_from_the_download(): void { + $hashed = GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( 'DejaVuSans.ttf' ); - $this->assertRenderMatchesSnapshot( 'fallback-arimo', '

Thai สวัสดี

', [ 'font' => 'arimo' ] ); + $this->put_longer_font_at( 'DejaVuSans.ttf', 'DejaVuSans.ttf' ); + FlushCache::flush(); + $this->serve_font_downloads(); + + $render = $this->render( '

Regular

', [ 'font' => 'dejavusans' ] ); + + $this->assertSame( $hashed, basename( $render['mpdf']->fonts['dejavusans']['ttffile'] ) ); + $this->assertSame( [ 'https://raw.githubusercontent.com/GravityPDF/mpdf-core-fonts/v1.1.1/DejaVuSans.ttf' ], $this->requested ); + $this->assertFileEquals( $this->font_fixture( 'DejaVuSans.ttf' ), $this->font_path( $hashed ) ); + $this->assertSame( strlen( (string) file_get_contents( $this->font_fixture( 'DejaVuSans.ttf' ) ) ) + 1, filesize( $this->font_path( 'DejaVuSans.ttf' ) ), 'The other font is left alone' ); } - public function test_a_font_that_cannot_download_renders_in_the_pdfs_font(): void { - GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavusanscondensed' ); - $this->fail_font_downloads(); + public function test_a_font_manager_font_and_the_core_font_with_the_same_file_name_each_render_their_own(): void { + $hashed = GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( 'DejaVuSans.ttf' ); - $this->assertRenderMatchesSnapshot( 'fallback-pdf-font', '

Fallback

' ); + $this->put_longer_font_at( 'DejaVuSans.ttf', 'DejaVuSans.ttf' ); + $this->add_record( 'mine', [ 'regular' => 'DejaVuSans.ttf' ] ); + FlushCache::flush(); + $this->serve_font_downloads(); + + $custom = $this->render( '

Custom

', [ 'font' => 'mine' ] ); + $core = $this->render( '

Core

', [ 'font' => 'dejavusans' ] ); + + $this->assertSame( 'DejaVuSans.ttf', basename( $custom['mpdf']->fonts['mine']['ttffile'] ) ); + $this->assertSame( $hashed, basename( $core['mpdf']->fonts['dejavusans']['ttffile'] ) ); + $this->assertCount( 1, $this->requested ); } - public function test_a_font_that_cannot_download_fails_the_pdf_in_debug_mode(): void { - GPDFAPI::get_options_class()->update_option( 'debug_mode', 'Yes' ); - static::remove_test_fonts(); + public function test_a_core_font_another_font_holds_the_name_of_renders_from_that_font_when_it_cannot_download(): void { + $this->put_longer_font_at( 'DejaVuSans.ttf', 'DejaVuSans.ttf' ); + FlushCache::flush(); $this->fail_font_downloads(); - $log = new TestHandler(); - GPDFAPI::get_log_class()->pushHandler( $log ); - try { - [ $result, $render ] = $this->generate( '

สวัสดี

', [ 'font' => 'arimo' ] ); - } finally { - GPDFAPI::get_log_class()->popHandler(); - } + $render = $this->render( '

Regular

', [ 'font' => 'dejavusans' ] ); - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertNull( $render ); - $this->assertTrue( - $log->hasRecordThatPasses( - static function ( $record ) { - return strpos( $record['context']['exception'] ?? '', 'Cannot find TTF TrueType font file' ) !== false; - }, - Logger::ERROR - ) - ); + $this->assertSame( 'DejaVuSans.ttf', basename( $render['mpdf']->fonts['dejavusans']['ttffile'] ) ); + $this->assertCount( 1, $this->requested ); } /** From bbc0ca5886e802543808a662168b41c584347c07 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Thu, 5 Nov 2026 14:54:42 +1100 Subject: [PATCH 4/4] Let plugins add font sources through the gfpdf_font_sources filter A plugin (Gravity PDF Pro's template fonts, for one) can now register a font source: mPDF families whose files download from the source's own manifest through the same verified downloader as the core fonts, optionally listed in their own font list group and, when `required`, kept on disk by the background sync. - Each added source is validated once per request: a lowercase key ID that isn't `bundled` or `core`, families with a Regular file and plain file names, a readable manifest, and a label group, scalar version and SHA-1 => size legacy if set. An invalid one is logged and dropped; only the known keys are kept, so an added source can't set a `dir`. - Building the sources reads each downloadable source's manifest, since two sources using one file name for different files must be told apart (the later source's file takes its hashed name, as for the core fonts). Only valid sources get that far, so a malformed source can't break the rename. An added source's manifest is all or nothing: one malformed entry (e.g. an http:// URL) or a family file missing from it rejects the whole file, logged, so a bad build never half-installs. Its families stay registered and render through the on-demand finder's fallback; nothing of it downloads. Dropping the source instead would have made the sources depend on a manifest's contents. - On admin_init, each added source that's new or has a new `version` (option `gfpdf_font_sources`, per site, since custom font records are per site) adopts its records, then `gfpdf_font_sources_changed` fires and the downloader schedules a sync. This covers activation by any route (UI, network, WP-CLI, file copy) and an update shipping new fonts. A removed source is forgotten, so adding it back adopts again; one with a rejected manifest stays unrecorded until fixed. The option is stored even when empty so later reads come from the autoloaded options, and the check returns early when nothing hooks the filter and nothing is stored. - `GPDFAPI::add_pdf_font()` and the required set already loop over the registered sources, so they cover added sources unchanged; adoption now reuses the cached source manifest instead of reading the file again. - The option is deleted on uninstall. Co-Authored-By: Claude Opus 5.5 --- src/Controller/Controller_Font_Downloader.php | 1 + src/Controller/Controller_Font_Sources.php | 3 + src/Model/Model_Font_Sources.php | 336 ++++++++++- src/Model/Model_Uninstall.php | 1 + tests/phpunit/Concerns/CopiesFontFixtures.php | 15 + .../Model/Test_Added_Font_Sources.php | 558 ++++++++++++++++++ .../Model/Test_Model_Font_Sources.php | 27 +- tests/phpunit/snapshots/hooks.txt | 2 + 8 files changed, 895 insertions(+), 48 deletions(-) create mode 100644 tests/phpunit/integration/Model/Test_Added_Font_Sources.php diff --git a/src/Controller/Controller_Font_Downloader.php b/src/Controller/Controller_Font_Downloader.php index 9e1a614ca..655429850 100644 --- a/src/Controller/Controller_Font_Downloader.php +++ b/src/Controller/Controller_Font_Downloader.php @@ -154,6 +154,7 @@ public function add_actions() { add_action( 'gfpdf_plugin_installed', [ $this->model, 'schedule_sync' ] ); add_action( 'gfpdf_version_changed', [ $this->model, 'schedule_sync' ] ); + add_action( 'gfpdf_font_sources_changed', [ $this->model, 'schedule_sync' ] ); add_action( 'upgrader_process_complete', [ $this, 'maybe_sync_translations' ], 10, 2 ); /* Both hooks pass the saved settings second */ diff --git a/src/Controller/Controller_Font_Sources.php b/src/Controller/Controller_Font_Sources.php index b6b33e34c..acc54bb4d 100644 --- a/src/Controller/Controller_Font_Sources.php +++ b/src/Controller/Controller_Font_Sources.php @@ -56,6 +56,9 @@ public function init(): void { public function add_actions(): void { /* A language pack may change the language fonts */ add_action( 'upgrader_process_complete', [ $this->model, 'flush' ], 10, 0 ); + + /* Any admin request, so a plugin activated or updated by any route adds its source */ + add_action( 'admin_init', [ $this->model, 'check_added_sources' ] ); } /** diff --git a/src/Model/Model_Font_Sources.php b/src/Model/Model_Font_Sources.php index 0919d5e5a..c4cd600a0 100644 --- a/src/Model/Model_Font_Sources.php +++ b/src/Model/Model_Font_Sources.php @@ -27,11 +27,12 @@ } /** - * The fonts Gravity PDF provides: the bundled Arimo and DejaVu symbol faces, and mPDF's core fonts + * The fonts Gravity PDF provides (bundled Arimo and DejaVu symbols, mPDF's core fonts) and those `gfpdf_font_sources` adds * * A source has `families` (mPDF font data, plus an optional `name` and `listed`), and optionally a bundled `dir`, a * `manifest` of its downloadable files, a font list `group`, and `legacy` (git blob SHA-1 => size) or a - * `legacy_manifest` of other copies of its files, which adopt_records() accepts. + * `legacy_manifest` of other copies of its files, which adopt_records() accepts. A `required` source's files join the + * background sync. * * @since 6.19.0 */ @@ -67,6 +68,27 @@ class Model_Font_Sources extends Helper_Abstract_Model { */ public const LANGUAGE_FONT_KEYS = 'gfpdf_language_font_keys'; + /** + * The option holding each added source's version, as of the last admin request + * + * @since 6.19.0 + */ + public const SOURCE_VERSIONS = 'gfpdf_font_sources'; + + /** + * Source IDs a plugin can't add or replace + * + * @since 6.19.0 + */ + public const RESERVED_SOURCES = [ 'bundled', 'core' ]; + + /** + * The keys an added source may set + * + * @since 6.19.0 + */ + protected const ADDED_SOURCE_KEYS = [ 'families', 'manifest', 'group', 'required', 'legacy', 'version' ]; + /** * @var Helper_Data * @since 6.19.0 @@ -163,7 +185,7 @@ public function __construct( Helper_Data $data, Model_Custom_Fonts $custom_fonts } /** - * The font sources, keyed by ID (`bundled`, `core`), built once + * The font sources, keyed by ID (`bundled`, `core`, then those plugins add), built once * * @return array * @@ -171,17 +193,247 @@ public function __construct( Helper_Data $data, Model_Custom_Fonts $custom_fonts */ public function get_sources(): array { if ( $this->sources === null ) { - $this->sources = $this->rename_colliding_files( - [ - 'bundled' => $this->get_bundled_source(), - 'core' => $this->get_core_source(), - ] - ); + $core = [ + 'bundled' => $this->get_bundled_source(), + 'core' => $this->get_core_source(), + ]; + + /* A filter callback asking for the sources gets the core ones, rather than recursing */ + $this->sources = $core; + + /** + * Add font sources, whose files download from their manifest when needed + * + * @param array $sources The sources keyed by ID; `bundled` and `core` are reserved + * + * @since 6.19.0 + */ + $sources = apply_filters( 'gfpdf_font_sources', $core ); + + /* Only valid sources reach rename_colliding_files(), which reads their manifests */ + $this->sources = $this->rename_colliding_files( $core + $this->get_added_sources( $sources, $core ) ); + + /* A callback may have cached what was built from the core sources alone */ + $this->manifest = null; + $this->family_files = null; + $this->record_collisions = []; } return $this->sources; } + /** + * The valid sources a plugin added, logging and dropping the rest + * + * @param mixed $sources What the filter returned + * + * @return array + * + * @since 6.19.0 + */ + protected function get_added_sources( $sources, array $core ): array { + $added = []; + + if ( ! is_array( $sources ) ) { + return $added; + } + + foreach ( $sources as $id => $source ) { + if ( isset( $core[ $id ] ) ) { + if ( $source !== $core[ $id ] ) { + $this->log->error( 'Font source dropped: its ID is reserved', [ 'source' => $id ] ); + } + + continue; + } + + $error = $this->get_source_error( $id, $source ); + if ( $error !== '' ) { + $this->log->error( 'Font source dropped: ' . $error, [ 'source' => (string) $id ] ); + + continue; + } + + $source = array_intersect_key( $source, array_flip( self::ADDED_SOURCE_KEYS ) ); + $source['required'] = ! empty( $source['required'] ); + $source['version'] = (string) ( $source['version'] ?? '' ); + + $added[ $id ] = $source; + } + + return $added; + } + + /** + * Why an added source is invalid, or '' if it isn't; its manifest's entries are checked when first read + * + * @param mixed $id + * @param mixed $source + * + * @since 6.19.0 + */ + protected function get_source_error( $id, $source ): string { + if ( ! is_string( $id ) || $id === '' || sanitize_key( $id ) !== $id ) { + return 'its ID is not a lowercase key'; + } + + if ( ! is_array( $source ) ) { + return 'it is not an array'; + } + + if ( ! is_array( $source['families'] ?? null ) || count( $source['families'] ) === 0 ) { + return 'it has no families'; + } + + foreach ( $source['families'] as $key => $family ) { + if ( ! $this->is_family( $key, $family ) ) { + return 'a family is invalid'; + } + } + + if ( ! is_string( $source['manifest'] ?? null ) || ! is_readable( $source['manifest'] ) ) { + return 'its manifest is missing'; + } + + if ( isset( $source['group'] ) && ( ! is_string( $source['group'] ) || $source['group'] === '' ) ) { + return 'its group is not a label'; + } + + if ( isset( $source['version'] ) && ! is_scalar( $source['version'] ) ) { + return 'its version is not a string'; + } + + if ( isset( $source['legacy'] ) && ! $this->is_legacy( $source['legacy'] ) ) { + return 'its legacy hashes are invalid'; + } + + return ''; + } + + /** + * A lowercase font key with a Regular file, every R/B/I/BI file a plain file name + * + * @param mixed $key + * @param mixed $family + * + * @since 6.19.0 + */ + protected function is_family( $key, $family ): bool { + if ( ! is_string( $key ) || $key === '' || strtolower( $key ) !== $key || ! is_array( $family ) || ! isset( $family['R'] ) ) { + return false; + } + + foreach ( $this->get_family_files( $family ) as $file ) { + if ( ! $this->is_file_name( $file ) ) { + return false; + } + } + + return true; + } + + /** + * A family's R/B/I/BI values + * + * @return mixed[] + * + * @since 6.19.0 + */ + protected function get_family_files( array $family ): array { + return array_values( array_intersect_key( $family, array_flip( [ 'R', 'B', 'I', 'BI' ] ) ) ); + } + + /** + * A file name with no path + * + * @param mixed $file + * + * @since 6.19.0 + */ + protected function is_file_name( $file ): bool { + return is_string( $file ) && $file !== '' && basename( $file ) === $file; + } + + /** + * A git blob SHA-1 and a positive size + * + * @param mixed $sha + * @param mixed $size + * + * @since 6.19.0 + */ + protected function is_blob( $sha, $size ): bool { + return is_string( $sha ) && preg_match( '/^[0-9a-f]{40}$/', $sha ) === 1 && is_int( $size ) && $size > 0; + } + + /** + * Whether every legacy entry is a git blob SHA-1 => a positive size + * + * @param mixed $legacy + * + * @since 6.19.0 + */ + protected function is_legacy( $legacy ): bool { + if ( ! is_array( $legacy ) ) { + return false; + } + + foreach ( $legacy as $sha => $size ) { + if ( ! $this->is_blob( $sha, $size ) ) { + return false; + } + } + + return true; + } + + /** + * Adopt the records of added sources that are new or re-versioned, then fire `gfpdf_font_sources_changed` + * + * @since 6.19.0 + */ + public function check_added_sources(): void { + /* No default: the first check stores [], so later reads hit the autoloaded options */ + $stored = get_option( self::SOURCE_VERSIONS ); + if ( $stored === [] && ! has_filter( 'gfpdf_font_sources' ) ) { + return; + } + + $versions = is_array( $stored ) ? $stored : []; + $current = []; + $changed = []; + + foreach ( array_diff_key( $this->get_sources(), array_flip( self::RESERVED_SOURCES ) ) as $id => $source ) { + $id = (string) $id; + if ( ( $versions[ $id ] ?? null ) !== $source['version'] ) { + /* Unrecorded, so it's checked again once its manifest is fixed */ + if ( count( $this->get_source_manifest( $id ) ) === 0 ) { + continue; + } + + $this->adopt_records( $id ); + $changed[] = $id; + } + + $current[ $id ] = $source['version']; + } + + if ( $current !== $stored ) { + update_option( self::SOURCE_VERSIONS, $current, true ); + } + + if ( count( $changed ) > 0 ) { + /** + * Fires when font sources were added or re-versioned, after their custom font records were adopted + * + * @param string[] $changed The source IDs + * + * @since 6.19.0 + */ + do_action( 'gfpdf_font_sources_changed', $changed ); + } + } + /** * Whether a font source provides the mPDF font key * @@ -475,9 +727,12 @@ public function get_fallback_file(): string { * @since 6.19.0 */ public function get_manifest(): array { + /* Building the sources resets what was cached from them, so it comes first */ + $sources = $this->get_sources(); + if ( $this->manifest === null ) { $this->manifest = []; - foreach ( array_keys( $this->get_sources() ) as $id ) { + foreach ( array_keys( $sources ) as $id ) { $this->manifest += $this->get_unaliased_manifest( (string) $id ); } } @@ -539,7 +794,7 @@ protected function get_unaliased_manifest( string $id ): array { $sources = $this->get_sources(); if ( ! isset( $this->manifests[ $id ] ) ) { - $this->manifests[ $id ] = $this->read_manifest( $sources[ $id ] ?? [] ); + $this->manifests[ $id ] = $this->read_source_manifest( $id, $sources[ $id ] ?? [] ); } return $this->manifests[ $id ]; @@ -555,6 +810,8 @@ protected function get_unaliased_manifest( string $id ): array { * @since 6.19.0 */ public function get_record_collisions(): array { + $this->get_sources(); + $blog = get_current_blog_id(); if ( isset( $this->record_collisions[ $blog ] ) ) { return $this->record_collisions[ $blog ]; @@ -609,9 +866,11 @@ protected function get_record_file_names(): array { return []; } + $sources = $this->get_sources(); + if ( $this->family_files === null ) { $this->family_files = []; - foreach ( $this->get_sources() as $source ) { + foreach ( $sources as $source ) { foreach ( empty( $source['manifest'] ) ? [] : $source['families'] as $family ) { foreach ( [ 'R', 'B', 'I', 'BI' ] as $style ) { if ( is_array( $family ) && is_string( $family[ $style ] ?? null ) ) { @@ -635,6 +894,21 @@ protected function get_record_file_names(): array { return array_values( $names ); } + /** + * Whether every family's files are on the manifest + * + * @since 6.19.0 + */ + protected function has_family_files( array $families, array $manifest ): bool { + foreach ( $families as $family ) { + if ( count( array_diff( $this->get_family_files( $family ), array_keys( $manifest ) ) ) > 0 ) { + return false; + } + } + + return true; + } + /** * Whether a file is a font on the manifest, not one of its licence files * @@ -917,7 +1191,7 @@ public function adopt_records( string $source_id ): array { return []; } - $known = $this->get_known_files( $source, $this->get_legacy_manifest( $source_id ) ); + $known = $this->get_known_files( $source, $this->get_source_manifest( $source_id ), $this->get_legacy_manifest( $source_id ) ); $records = array_intersect_key( $this->custom_fonts->get_custom_fonts(), $source['families'] ); /* A source with no known copies can't vouch for any file */ @@ -1073,7 +1347,7 @@ protected function rename_colliding_files( array $sources ): array { $renames = []; $resolved = []; - foreach ( $this->read_manifest( $sources[ $id ] ) as $file => $entry ) { + foreach ( $this->read_source_manifest( (string) $id, $sources[ $id ] ) as $file => $entry ) { $file = (string) $file; $name = $file; @@ -1109,26 +1383,37 @@ protected function rename_colliding_files( array $sources ): array { } /** - * A source's manifest, keyed by file name, skipping malformed entries + * A source's manifest, keyed by file name. An added source's is all or nothing, so a bad build never half-installs: it + * is rejected whole if any entry is malformed or a family file is not on it. * * @return array * * @since 6.19.0 */ - protected function read_manifest( array $source ): array { - return $this->read_manifest_file( $source['manifest'] ?? '' ); + protected function read_source_manifest( string $id, array $source ): array { + $strict = $source !== [] && ! in_array( $id, self::RESERVED_SOURCES, true ); + $manifest = $this->read_manifest_file( $source['manifest'] ?? '', $strict ); + + if ( $strict && count( $manifest ) > 0 && ! $this->has_family_files( $source['families'], $manifest ) ) { + $this->log->error( 'Font manifest rejected: a family file is not on it', [ 'manifest' => $source['manifest'] ] ); + + return []; + } + + return $manifest; } /** * A manifest file's entries, keyed by file name, skipping malformed ones * * @param mixed $path + * @param bool $strict Read none if any entry is malformed * * @return array * * @since 6.19.0 */ - protected function read_manifest_file( $path ): array { + protected function read_manifest_file( $path, bool $strict = false ): array { if ( ! is_string( $path ) || $path === '' ) { return []; } @@ -1147,6 +1432,12 @@ protected function read_manifest_file( $path ): array { $manifest = array_filter( $entries, [ $this, 'is_manifest_entry' ], ARRAY_FILTER_USE_BOTH ); + if ( $strict && $manifest !== $entries ) { + $this->log->error( 'Font manifest rejected: an entry is invalid', [ 'manifest' => $path ] ); + + return []; + } + if ( count( $manifest ) !== count( $entries ) ) { $this->log->warning( 'Font manifest entries skipped as invalid', @@ -1169,11 +1460,10 @@ protected function read_manifest_file( $path ): array { * @since 6.19.0 */ protected function is_manifest_entry( $entry, $file ): bool { - return is_string( $file ) && $file !== '' && basename( $file ) === $file + return $this->is_file_name( $file ) && is_array( $entry ) && is_string( $entry['url'] ?? null ) && strpos( $entry['url'], 'https://' ) === 0 - && is_string( $entry['sha'] ?? null ) && preg_match( '/^[0-9a-f]{40}$/', $entry['sha'] ) === 1 - && is_int( $entry['size'] ?? null ) && $entry['size'] > 0; + && $this->is_blob( $entry['sha'] ?? null, $entry['size'] ?? null ); } /** @@ -1183,9 +1473,9 @@ protected function is_manifest_entry( $entry, $file ): bool { * * @since 6.19.0 */ - protected function get_known_files( array $source, array $legacy_manifest ): array { + protected function get_known_files( array $source, array $source_manifest, array $legacy_manifest ): array { $known = []; - foreach ( [ $this->read_manifest( $source ), $legacy_manifest ] as $manifest ) { + foreach ( [ $source_manifest, $legacy_manifest ] as $manifest ) { foreach ( $manifest as $file ) { $known[ $file['size'] ][] = $file['sha']; } diff --git a/src/Model/Model_Uninstall.php b/src/Model/Model_Uninstall.php index e997b0d78..f215e6648 100644 --- a/src/Model/Model_Uninstall.php +++ b/src/Model/Model_Uninstall.php @@ -210,6 +210,7 @@ public function remove_plugin_options() { delete_option( Model_Pdf_Cache::SWEEP_STATE_OPTION ); delete_option( Model_Pdf_Cache::PURGE_REQUEST_OPTION ); delete_option( Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS ); /* A Multisite keeps it in sitemeta */ + delete_option( Model_Font_Sources::SOURCE_VERSIONS ); Deprecation::delete_stored_data(); /* Remove license API data. Deleting one by one, not with a raw DELETE, lets WordPress drop its cached copies */ diff --git a/tests/phpunit/Concerns/CopiesFontFixtures.php b/tests/phpunit/Concerns/CopiesFontFixtures.php index dc310defd..bb363a69d 100644 --- a/tests/phpunit/Concerns/CopiesFontFixtures.php +++ b/tests/phpunit/Concerns/CopiesFontFixtures.php @@ -150,6 +150,21 @@ protected function manifest_entry( string $fixture, string $url ): array { ]; } + /** + * Write a manifest under a unique name, so opcache never serves an earlier test's copy + * + * @param array|string $manifest Entries, or the file's raw contents + */ + protected function write_manifest_file( $manifest ): string { + $path = get_temp_dir() . 'gfpdf-test-manifest-' . wp_generate_password( 8, false ) . '.php'; + + $contents = is_array( $manifest ) ? 'copied_fonts[] = $path; + + return $path; + } + /** * Answer every font download with the response. A 200 streams the URL's fixture font (changed by $change) into it. * diff --git a/tests/phpunit/integration/Model/Test_Added_Font_Sources.php b/tests/phpunit/integration/Model/Test_Added_Font_Sources.php new file mode 100644 index 000000000..e3bf4cee3 --- /dev/null +++ b/tests/phpunit/integration/Model/Test_Added_Font_Sources.php @@ -0,0 +1,558 @@ +model = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + wp_mkdir_p( GPDFAPI::get_data_class()->template_font_location ); + + $this->log = new TestHandler(); + GPDFAPI::get_log_class()->pushHandler( $this->log ); + + add_action( + 'gfpdf_font_sources_changed', + function () { + $this->changes++; + } + ); + } + + public function tear_down(): void { + GPDFAPI::get_log_class()->popHandler(); + $this->remove_font_fixtures(); + delete_option( Model_Font_Sources::SOURCE_VERSIONS ); + GPDFAPI::get_options_class()->delete_option( 'default_font' ); + wp_clear_scheduled_hook( Model_Font_Downloader::SYNC_EVENT ); + + parent::tear_down(); + + /* The filter is gone now, so rebuild the sources without the fixture */ + $this->rebuild_font_sources(); + } + + public function test_an_added_source_is_registered() { + $this->add_fixture_source( [ 'dir' => PDF_PLUGIN_DIR . 'src/' ] ); + + $sources = $this->model->get_sources(); + + $this->assertSame( [ 'bundled', 'core', 'fixture' ], array_keys( $sources ) ); + $this->assertSame( [ 'version', 'families', 'manifest', 'group', 'required' ], array_keys( $sources['fixture'] ) ); + $this->assertTrue( $this->model->is_family_key( 'fixturesans' ) ); + $this->assertTrue( $this->model->is_core_font_file( 'Chewy.ttf' ) ); + $this->assertFalse( $this->log->hasRecords( Logger::ERROR ) ); + + /* Only the bundled source has a dir */ + $font_dir = GPDFAPI::get_data_class()->template_font_location; + $this->assertSame( [ $font_dir, PDF_PLUGIN_DIR . 'fonts/' ], $this->model->register_font_directories( [ 'fontDir' => [ $font_dir ] ] )['fontDir'] ); + } + + public function test_an_added_source_registers_its_families_before_custom_fonts() { + $this->add_fixture_source(); + + $fonts = apply_filters( 'mpdf_font_data', [] ); + $this->assertSame( [ 'R' => 'Chewy.ttf' ], $fonts['fixturesans'] ); + + $this->add_record( 'fixturesans', [ 'regular' => 'Override.ttf' ] ); + $fonts = apply_filters( 'mpdf_font_data', [] ); + $this->assertSame( 'Override.ttf', $fonts['fixturesans']['R'] ); + } + + public function test_an_added_source_is_listed_only_in_its_own_group() { + $this->add_fixture_source(); + + $fonts = GPDFAPI::get_pdf_fonts(); + + $this->assertSame( [ 'fixturesans' => 'Fixture Sans' ], $fonts['Fixture Fonts'] ); + $this->assertSame( 1, $this->count_listings( $fonts, 'fixturesans' ) ); + } + + public function test_an_added_source_without_a_group_is_unlisted() { + $this->add_fixture_source( [ 'group' => null ] ); + + $this->assertTrue( $this->model->is_family_key( 'fixturesans' ) ); + $this->assertSame( 0, $this->count_listings( GPDFAPI::get_pdf_fonts(), 'fixturesans' ) ); + } + + /** + * @dataProvider provider_invalid_sources + * + * @param mixed $id + * @param mixed $changes Keys to change in the fixture source, or a whole non-array source + */ + public function test_an_invalid_source_is_dropped_and_logged( $id, array $changes, string $reason ) { + $this->add_fixture_source( $changes, null, $id ); + + $this->assert_dropped( $reason ); + } + + public function test_a_source_that_is_not_an_array_is_dropped() { + add_filter( + 'gfpdf_font_sources', + static function ( $sources ) { + return $sources + [ 'fixture' => 'fixture' ]; + } + ); + $this->rebuild_font_sources(); + + $this->assert_dropped( 'it is not an array' ); + } + + public function provider_invalid_sources(): array { + return [ + 'missing manifest' => [ 'fixture', [ 'manifest' => '/missing/manifest.php' ], 'its manifest is missing' ], + 'no manifest' => [ 'fixture', [ 'manifest' => null ], 'its manifest is missing' ], + 'no families' => [ 'fixture', [ 'families' => [] ], 'it has no families' ], + 'a family with no R' => [ 'fixture', [ 'families' => [ 'fixturesans' => [ 'B' => 'Chewy.ttf' ] ] ], 'a family is invalid' ], + 'a family file path' => [ 'fixture', [ 'families' => [ 'fixturesans' => [ 'R' => '../Chewy.ttf' ] ] ], 'a family is invalid' ], + 'an upper case key' => [ 'fixture', [ 'families' => [ 'FixtureSans' => [ 'R' => 'Chewy.ttf' ] ] ], 'a family is invalid' ], + 'an empty group' => [ 'fixture', [ 'group' => '' ], 'its group is not a label' ], + 'an array version' => [ 'fixture', [ 'version' => [ 1 ] ], 'its version is not a string' ], + 'bad legacy hashes' => [ 'fixture', [ 'legacy' => [ 'abc' => 100 ] ], 'its legacy hashes are invalid' ], + 'a numeric ID' => [ 0, [], 'its ID is not a lowercase key' ], + 'an ID with spaces' => [ 'Fixture Fonts', [], 'its ID is not a lowercase key' ], + 'the reserved core ID' => [ 'core', [], 'its ID is reserved' ], + 'the reserved bundle ID' => [ 'bundled', [], 'its ID is reserved' ], + ]; + } + + public function test_a_reserved_source_keeps_its_own_definition() { + $core = $this->model->get_sources()['core']; + + $this->add_fixture_source( [], null, 'core' ); + + $this->assertSame( $core, $this->model->get_sources()['core'] ); + $this->assertFalse( $this->model->is_family_key( 'fixturesans' ) ); + } + + public function test_a_filter_that_removes_the_core_sources_does_not_unregister_them() { + add_filter( 'gfpdf_font_sources', '__return_empty_array' ); + $this->rebuild_font_sources(); + + $this->assertSame( [ 'bundled', 'core' ], array_keys( $this->model->get_sources() ) ); + $this->assertFalse( $this->log->hasRecords( Logger::ERROR ) ); + } + + /** + * @dataProvider provider_invalid_manifests + * + * @param mixed $manifest + */ + public function test_an_invalid_manifest_is_rejected_whole_when_first_read( $manifest, string $message ) { + $this->add_fixture_source( [ 'required' => true ], $manifest ); + $this->assertFalse( $this->log->hasRecords( Logger::ERROR ) ); + + $this->assertSame( [], $this->model->get_source_manifest( 'fixture' ) ); + $this->assertFalse( $this->model->is_core_font_file( 'Chewy.ttf' ) ); + $this->assertArrayNotHasKey( 'OFL-Fixture.txt', $this->model->get_manifest() ); + $this->assertTrue( $this->log->hasRecordThatContains( $message, Logger::ERROR ) ); + } + + public function provider_invalid_manifests(): array { + $licence = [ 'OFL-Fixture.txt' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/OFL-Fixture.txt' ) ]; + + return [ + 'an http:// URL' => [ $licence + [ 'Chewy.ttf' => $this->manifest_entry( 'Chewy.ttf', 'http://example.com/Chewy.ttf' ) ], 'Font manifest rejected: an entry is invalid' ], + 'a family file not on it' => [ $licence, 'Font manifest rejected: a family file is not on it' ], + 'a manifest returning a value' => [ 'assertSame( 10, has_action( 'admin_init', [ $this->model, 'check_added_sources' ] ) ); + $this->assertSame( 10, has_action( 'gfpdf_font_sources_changed', [ GPDFAPI::get_mvc_class( 'Model_Font_Downloader' ), 'schedule_sync' ] ) ); + + $this->copy_fixture( 'Chewy.ttf' ); + $this->add_record( 'fixturesans', [ 'regular' => 'Chewy.ttf' ] ); + $this->add_fixture_source(); + + $this->model->check_added_sources(); + + $this->assertArrayNotHasKey( 'fixturesans', $this->custom_font_records() ); + $this->assertSame( [ 'fixture' => '1.0.0' ], get_option( Model_Font_Sources::SOURCE_VERSIONS ) ); + $this->assertSame( 1, $this->changes ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::SYNC_EVENT ) ); + + wp_clear_scheduled_hook( Model_Font_Downloader::SYNC_EVENT ); + $this->model->check_added_sources(); + + $this->assertSame( 1, $this->changes ); + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::SYNC_EVENT ) ); + } + + public function test_bumping_a_sources_version_adopts_and_syncs_again() { + update_option( Model_Font_Sources::SOURCE_VERSIONS, [ 'fixture' => '1.0.0' ] ); + + $this->copy_fixture( 'Chewy.ttf' ); + $this->add_record( 'fixturesans', [ 'regular' => 'Chewy.ttf' ] ); + $this->add_fixture_source( [ 'version' => '1.1.0' ] ); + + $this->model->check_added_sources(); + + $this->assertArrayNotHasKey( 'fixturesans', $this->custom_font_records() ); + $this->assertSame( [ 'fixture' => '1.1.0' ], get_option( Model_Font_Sources::SOURCE_VERSIONS ) ); + $this->assertSame( 1, $this->changes ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::SYNC_EVENT ) ); + } + + public function test_an_unchanged_source_keeps_an_edited_record() { + update_option( Model_Font_Sources::SOURCE_VERSIONS, [ 'fixture' => '1.0.0' ] ); + + $this->copy_fixture( 'Chewy.ttf' ); + $this->add_record( 'fixturesans', [ 'regular' => 'Chewy.ttf' ] ); + $this->add_fixture_source(); + + $this->model->check_added_sources(); + + $this->assertArrayHasKey( 'fixturesans', $this->custom_font_records() ); + $this->assertSame( 0, $this->changes ); + } + + public function test_a_removed_source_is_forgotten() { + update_option( Model_Font_Sources::SOURCE_VERSIONS, [ 'fixture' => '1.0.0' ] ); + + $this->model->check_added_sources(); + + $this->assertSame( [], get_option( Model_Font_Sources::SOURCE_VERSIONS ) ); + $this->assertSame( 0, $this->changes ); + } + + public function test_a_source_with_a_rejected_manifest_is_not_recorded() { + $this->add_fixture_source( [], [ 'Other.ttf' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Other.ttf' ) ] ); + + $this->model->check_added_sources(); + + $this->assertSame( [], get_option( Model_Font_Sources::SOURCE_VERSIONS ) ); + $this->assertSame( 0, $this->changes ); + } + + public function test_a_site_without_added_sources_stores_the_versions_once_to_autoload_them() { + $this->model->check_added_sources(); + $this->assertSame( [], get_option( Model_Font_Sources::SOURCE_VERSIONS ) ); + + add_action( + 'update_option_' . Model_Font_Sources::SOURCE_VERSIONS, + function () { + $this->fail( 'The versions were stored again' ); + } + ); + $this->model->check_added_sources(); + $this->assertSame( 0, $this->changes ); + } + + public function test_add_pdf_font_is_a_no_op_only_while_the_source_is_registered() { + $font = [ + 'font_name' => 'Fixture Sans', + 'regular' => $this->font_fixture( 'Chewy.ttf' ), + ]; + + $this->add_fixture_source(); + $this->assertTrue( GPDFAPI::add_pdf_font( $font ) ); + $this->assertSame( [], $this->custom_font_records() ); + + remove_all_filters( 'gfpdf_font_sources' ); + $this->rebuild_font_sources(); + $this->recorded_fonts[] = 'fixturesans'; + $this->copied_fonts[] = $this->font_path( 'Chewy.ttf' ); + + $this->assertTrue( GPDFAPI::add_pdf_font( $font ) ); + $this->assertArrayHasKey( 'fixturesans', $this->custom_font_records() ); + } + + public function test_only_a_required_sources_files_are_required() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'arimo' ); + $required = new Model_Required_Fonts( $this->model, GPDFAPI::get_options_class(), GPDFAPI::get_log_class() ); + + $this->add_fixture_source(); + $this->assertSame( [], $required->get_required_font_files() ); + + $this->add_fixture_source( [ 'required' => true ] ); + $this->assertSame( [ 'Chewy.ttf', 'OFL-Fixture.txt' ], $required->get_required_font_files() ); + } + + public function test_an_added_file_colliding_with_a_core_file_has_the_hashed_name_everywhere() { + $hashed = 'DejaVuSerifCondensed-' . substr( $this->manifest_entry( 'Chewy.ttf', '' )['sha'], 0, 8 ) . '.ttf'; + $this->add_colliding_source( 'Chewy.ttf', [ 'required' => true ] ); + + $sources = $this->model->get_sources(); + + /* Font data: the added family uses the hashed name, the core family keeps its own */ + $fonts = $this->model->register_font_data( [] ); + $this->assertSame( $hashed, $sources['fixture']['families']['fixturesans']['R'] ); + $this->assertSame( $hashed, $fonts['fixturesans']['R'] ); + $this->assertSame( 'DejaVuSerifCondensed.ttf', $fonts['dejavuserifcondensed']['R'] ); + + /* Manifest: the core file keeps the name, the added one takes the hashed name, with its own URL */ + $this->assertSame( [ $hashed ], array_keys( $this->model->get_source_manifest( 'fixture' ) ) ); + $this->assertArrayHasKey( 'DejaVuSerifCondensed.ttf', $this->model->get_source_manifest( 'core' ) ); + $this->assertSame( $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Fixture-Chewy.ttf' ), $this->model->get_manifest()[ $hashed ] ); + $this->assertSame( $this->manifest_entry( 'DejaVuSerifCondensed.ttf', '' )['sha'], $this->model->get_manifest()['DejaVuSerifCondensed.ttf']['sha'] ); + $this->assertTrue( $this->model->is_core_font_file( $hashed ) ); + $this->assertTrue( $this->log->hasRecordThatContains( 'Font file name used by two sources for different files', Logger::NOTICE ) ); + + /* A required added source's file is required under the hashed name */ + $required = new Model_Required_Fonts( $this->model, GPDFAPI::get_options_class(), GPDFAPI::get_log_class() ); + $this->assertContains( $hashed, $required->get_required_font_files() ); + + /* Download path: the added file is verified and saved under the hashed name, never over the core file's name */ + $this->serve_font_downloads(); + $this->copied_fonts[] = $this->font_path( $hashed ); + $downloader = new Seamed_Font_Downloader( GPDFAPI::get_log_class(), [ 'sources' => $this->model ] ); + + $this->assertFalse( $this->model->is_installed( $hashed ) ); + $this->assertTrue( $downloader->download( $hashed ) ); + $this->assertSame( [ 'https://example.com/Fixture-Chewy.ttf' ], $this->requested ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_path( $hashed ) ); + $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertSame( $this->font_path( $hashed ), $this->model->get_font_path( $hashed ) ); + $this->assertTrue( $this->model->is_installed( $hashed ) ); + $this->assertFalse( $this->model->is_installed( 'DejaVuSerifCondensed.ttf' ), 'The core font is not on disk' ); + } + + public function test_an_added_file_with_the_same_bytes_as_a_core_file_shares_its_file() { + $this->add_colliding_source( 'DejaVuSerifCondensed.ttf' ); + + $sources = $this->model->get_sources(); + $fonts = $this->model->register_font_data( [] ); + + $this->assertSame( 'DejaVuSerifCondensed.ttf', $sources['fixture']['families']['fixturesans']['R'] ); + $this->assertSame( 'DejaVuSerifCondensed.ttf', $fonts['fixturesans']['R'] ); + $this->assertSame( [ 'DejaVuSerifCondensed.ttf' ], array_keys( $this->model->get_source_manifest( 'fixture' ) ) ); + $this->assertFalse( $this->log->hasRecordThatContains( 'Font file name used by two sources', Logger::NOTICE ) ); + + /* One manifest entry, and so one download, for the one file */ + $this->assertCount( 1, array_keys( $this->model->get_manifest(), $this->model->get_manifest()['DejaVuSerifCondensed.ttf'], true ) ); + $this->assertSame( $this->model->get_font_path( 'DejaVuSerifCondensed.ttf' ), $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + + $this->serve_font_downloads(); + $this->copied_fonts[] = $this->font_path( 'DejaVuSerifCondensed.ttf' ); + $downloader = new Seamed_Font_Downloader( GPDFAPI::get_log_class(), [ 'sources' => $this->model ] ); + + $this->assertTrue( $downloader->download( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertCount( 1, $this->requested ); + $this->assertFileEquals( $this->font_fixture( 'DejaVuSerifCondensed.ttf' ), $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertTrue( $this->model->is_installed( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertFalse( $this->model->has_hashed_copy( 'DejaVuSerifCondensed.ttf' ) ); + } + + public function test_invalid_sources_and_manifests_do_not_stop_a_valid_source_being_renamed() { + $licence = [ 'OFL-Fixture.txt' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/OFL-Fixture.txt' ) ]; + $name = 'DejaVuSerifCondensed.ttf'; + $hashed = 'DejaVuSerifCondensed-' . substr( $this->manifest_entry( 'Chewy.ttf', '' )['sha'], 0, 8 ) . '.ttf'; + + /* Not arrays, a family without a Regular file, a path as a file, and no manifest are dropped before any manifest is read */ + add_filter( + 'gfpdf_font_sources', + function ( $sources ) { + return $sources + [ + 'notarray' => 'x', + 'nofamily' => [ 'families' => 'x', 'manifest' => $this->write_manifest_file( [] ) ], + 'badpath' => [ 'families' => [ 'a' => [ 'R' => '../DejaVuSerifCondensed.ttf' ] ], 'manifest' => $this->write_manifest_file( [] ) ], + 'nomanifest' => [ 'families' => [ 'b' => [ 'R' => 'B.ttf' ] ] ], + ]; + } + ); + + /* A rejected manifest (an http:// entry, a family file missing, a non-array) takes no file name */ + $this->add_fixture_source( [ 'families' => [ 'httpsans' => [ 'R' => $name ] ] ], [ $name => $this->manifest_entry( 'Chewy.ttf', 'http://example.com/a.ttf' ) ], 'http' ); + $this->add_fixture_source( [ 'families' => [ 'missingsans' => [ 'R' => $name ] ] ], $licence, 'missing' ); + $this->add_fixture_source( [ 'families' => [ 'valuesans' => [ 'R' => $name ] ] ], 'add_fixture_source( [ 'families' => [ 'goodsans' => [ 'R' => $name ] ] ], [ $name => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Good.ttf' ) ], 'good' ); + + $sources = $this->model->get_sources(); + + $this->assertSame( [ 'bundled', 'core', 'http', 'missing', 'value', 'good' ], array_keys( $sources ) ); + $this->assertSame( [], $this->model->get_source_manifest( 'http' ) ); + $this->assertSame( [], $this->model->get_source_manifest( 'missing' ) ); + $this->assertSame( [], $this->model->get_source_manifest( 'value' ) ); + $this->assertSame( [ $hashed ], array_keys( $this->model->get_source_manifest( 'good' ) ) ); + $this->assertSame( $hashed, $sources['good']['families']['goodsans']['R'] ); + + /* The rejected sources keep their families as listed, and the notice is for the one valid source */ + $this->assertSame( $name, $sources['http']['families']['httpsans']['R'] ); + foreach ( [ 'notarray', 'nofamily', 'badpath', 'nomanifest' ] as $id ) { + $this->assertArrayNotHasKey( $id, $sources ); + } + $this->assertCount( 1, array_filter( $this->log->getRecords(), static function ( $record ) { + return strpos( $record['message'], 'Font file name used by two sources' ) === 0; + } ) ); + $this->assertTrue( $this->log->hasRecordThatContains( 'Font manifest rejected: an entry is invalid', Logger::ERROR ) ); + $this->assertTrue( $this->log->hasRecordThatContains( 'Font manifest rejected: a family file is not on it', Logger::ERROR ) ); + } + + public function test_the_filter_runs_once_and_the_sources_are_memoised() { + $runs = 0; + $inside = null; + add_filter( + 'gfpdf_font_sources', + function ( $sources ) use ( &$runs, &$inside ) { + $runs++; + $inside = $this->model->get_sources(); + + /* Reading a manifest from the callback must not stick, as it only sees the core sources */ + $this->model->get_manifest(); + + return $sources; + }, + 5 + ); + $this->add_colliding_source( 'Chewy.ttf' ); + $hashed = 'DejaVuSerifCondensed-' . substr( $this->manifest_entry( 'Chewy.ttf', '' )['sha'], 0, 8 ) . '.ttf'; + + $first = $this->model->get_sources(); + $this->model->get_manifest(); + $this->model->get_source_manifest( 'fixture' ); + $this->model->register_font_data( [] ); + $this->model->check_added_sources(); + $this->model->flush(); + + $this->assertSame( 1, $runs ); + $this->assertSame( [ 'bundled', 'core' ], array_keys( $inside ), 'A callback gets the core sources, not a recursion' ); + $this->assertSame( $first, $this->model->get_sources() ); + $this->assertArrayHasKey( $hashed, $this->model->get_manifest() ); + $this->assertSame( [ 'fixture' => '1.0.0' ], get_option( Model_Font_Sources::SOURCE_VERSIONS ) ); + } + + /** + * Register an added source whose one file has a core file's name but the fixture's bytes + * + * @param string $fixture The fixture the source lists for DejaVuSerifCondensed.ttf + */ + protected function add_colliding_source( string $fixture, array $changes = [] ): void { + $url = $fixture === 'Chewy.ttf' ? 'https://example.com/Fixture-Chewy.ttf' : 'https://example.com/Fixture-DejaVuSerifCondensed.ttf'; + $this->served_as[ basename( $url ) ] = $fixture; + + $this->add_fixture_source( + $changes + [ 'families' => [ 'fixturesans' => [ 'name' => 'Fixture Sans', 'R' => 'DejaVuSerifCondensed.ttf' ] ] ], + [ 'DejaVuSerifCondensed.ttf' => $this->manifest_entry( $fixture, $url ) ] + ); + } + + /** + * Register the fixture source: `fixturesans`, whose Regular is Chewy, in a "Fixture Fonts" group + * + * @param array $changes Keys to replace in the source; a null value removes the key + * @param array|string|null $manifest Manifest entries, the file's raw contents, or null for Chewy and a licence + * @param mixed $id The source ID + */ + protected function add_fixture_source( array $changes = [], $manifest = null, $id = 'fixture' ): void { + $manifest = $manifest ?? [ + 'Chewy.ttf' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Chewy.ttf' ), + 'OFL-Fixture.txt' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/OFL-Fixture.txt' ), + ]; + + $source = array_merge( + [ + 'version' => '1.0.0', + 'families' => [ + 'fixturesans' => [ + 'name' => 'Fixture Sans', + 'R' => 'Chewy.ttf', + ], + ], + 'manifest' => $this->write_manifest_file( $manifest ), + 'group' => 'Fixture Fonts', + ], + $changes + ); + + $source = array_filter( + $source, + static function ( $value ) { + return $value !== null; + } + ); + + add_filter( + 'gfpdf_font_sources', + static function ( $sources ) use ( $id, $source ) { + $sources[ $id ] = $source; + + return $sources; + } + ); + + $this->rebuild_font_sources(); + } + + /** + * The custom font records keyed by ID + */ + protected function custom_font_records(): array { + return GPDFAPI::get_mvc_class( 'Model_Custom_Fonts' )->get_custom_fonts(); + } + + /** + * Assert the registered sources are the core ones, logged as dropping the fixture for the reason + */ + protected function assert_dropped( string $reason ): void { + $this->assertSame( [ 'bundled', 'core' ], array_keys( $this->model->get_sources() ) ); + $this->assertFalse( $this->model->is_family_key( 'fixturesans' ) ); + $this->assertTrue( $this->log->hasRecordThatContains( 'Font source dropped: ' . $reason, Logger::ERROR ) ); + } + + /** + * Make the registered font sources apply `gfpdf_font_sources` again + */ + protected function rebuild_font_sources(): void { + $this->set_private( $this->model, 'sources', null ); + $this->set_private( $this->model, 'manifests', [] ); + $this->set_private( $this->model, 'manifest', null ); + $this->model->flush(); + } + + /** + * How many font list groups list the key + */ + protected function count_listings( array $fonts, string $key ): int { + return count( + array_filter( + $fonts, + static function ( $group ) use ( $key ) { + return isset( $group[ $key ] ); + } + ) + ); + } +} diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php index c2949e8ac..61e3771ee 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php @@ -54,11 +54,6 @@ class Test_Model_Font_Sources extends TestCase { */ protected $log; - /** - * @var string A test manifest, unique per test so opcache never serves an earlier test's copy - */ - protected $manifest_path; - public function set_up(): void { parent::set_up(); @@ -68,8 +63,6 @@ public function set_up(): void { wp_mkdir_p( $this->font_dir ); - $this->manifest_path = get_temp_dir() . 'gfpdf-test-core-fonts-' . wp_generate_password( 8, false ) . '.php'; - $this->log = new TestHandler(); GPDFAPI::get_log_class()->pushHandler( $this->log ); } @@ -77,7 +70,6 @@ public function set_up(): void { public function tear_down(): void { GPDFAPI::get_log_class()->popHandler(); $this->remove_font_fixtures(); - wp_delete_file( $this->manifest_path ); GPDFAPI::get_options_class()->delete_option( 'default_font' ); parent::tear_down(); @@ -1140,12 +1132,6 @@ public function test_adoption_checks_the_records_file_not_a_hashed_copy() { * @param array|string|null $manifest Entries, the file's raw contents, or null for no file */ protected function get_model_with_manifest( $manifest ): Model_Font_Sources { - wp_delete_file( $this->manifest_path ); - if ( $manifest !== null ) { - $contents = is_array( $manifest ) ? 'manifest_path, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents - } - $model = new class( GPDFAPI::get_data_class(), $this->custom_fonts, GPDFAPI::get_log_class() ) extends Model_Font_Sources { /** @var string */ public $manifest_path = ''; @@ -1155,7 +1141,7 @@ protected function get_core_source(): array { } }; - $model->manifest_path = $this->manifest_path; + $model->manifest_path = $manifest === null ? get_temp_dir() . 'gfpdf-test-missing-manifest.php' : $this->write_manifest_file( $manifest ); return $model; } @@ -1172,15 +1158,6 @@ protected function core_fontdata(): array { * @param array $second */ protected function get_model_with_two_sources( array $first, array $second ): Model_Font_Sources { - $write = function ( string $suffix, array $entries ): string { - $path = $this->manifest_path . $suffix . '.php'; - wp_delete_file( $path ); - file_put_contents( $path, 'copied_fonts[] = $path; - - return $path; - }; - $model = new class( GPDFAPI::get_data_class(), $this->custom_fonts, GPDFAPI::get_log_class() ) extends Model_Font_Sources { /** @var string[] The first source's and second source's manifests */ public $manifests_at = []; @@ -1212,7 +1189,7 @@ protected function get_core_source(): array { } }; - $model->manifests_at = [ $write( '-first', $first ), $second === [] ? '' : $write( '-second', $second ) ]; + $model->manifests_at = [ $this->write_manifest_file( $first ), $second === [] ? '' : $this->write_manifest_file( $second ) ]; $model->real_core = $second === []; return $model; diff --git a/tests/phpunit/snapshots/hooks.txt b/tests/phpunit/snapshots/hooks.txt index 7378e6b0c..fcc9d50e0 100644 --- a/tests/phpunit/snapshots/hooks.txt +++ b/tests/phpunit/snapshots/hooks.txt @@ -13,6 +13,7 @@ action gfpdf_entry_detail_post_pdf_name_markup 2 action gfpdf_entry_detail_pre_markup 1 action gfpdf_entry_detail_pre_pdf_links_markup 2 action gfpdf_entry_detail_pre_pdf_name_markup 2 +action gfpdf_font_sources_changed 1 action gfpdf_form_settings_rest_after_insert_pdf 3 action gfpdf_form_settings_rest_after_update_pdf 3 action gfpdf_form_settings_rest_delete_pdf 3 @@ -138,6 +139,7 @@ filter gfpdf_first_page_footer_field_setting 1 filter gfpdf_first_page_header_field_setting 1 filter gfpdf_font_list 1 filter gfpdf_font_location 3 +filter gfpdf_font_sources 1 filter gfpdf_footer_field_setting 1 filter gfpdf_form_add_pdf 2 filter gfpdf_form_data 3