diff --git a/src/Controller/Controller_Font_Downloader.php b/src/Controller/Controller_Font_Downloader.php index a68870750..655429850 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() ); } /** @@ -72,9 +150,11 @@ public function init() { */ 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' ] ); + 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/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/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/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_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 bcab58fcb..459782599 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 @@ -31,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 */ @@ -78,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 @@ -115,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; @@ -243,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 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 { + public function download( string $file, bool $verify = false ): bool { $manifest = $this->font_sources->get_manifest(); if ( ! isset( $manifest[ $file ] ) ) { @@ -256,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; } @@ -269,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; @@ -277,20 +320,98 @@ 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. * - * @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 */ @@ -311,9 +432,10 @@ public function prune( ?array $keep = null ): 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'] ) { + if ( ! is_file( $path ) || filesize( $path ) !== $entry['size'] || ( $since > 0 && filemtime( $path ) > $since ) ) { continue; } @@ -350,6 +472,267 @@ 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. 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 + * + * @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 = []; + + /* 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; + } + + $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; + $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; + } + } + + $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 * @@ -379,6 +762,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 * @@ -406,11 +823,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() ); } @@ -429,7 +841,13 @@ 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 ), + 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 */ @@ -450,52 +868,111 @@ 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 []; } /** - * 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[] * @@ -510,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 ); } ) ); @@ -536,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; @@ -683,8 +1160,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 ); @@ -698,10 +1176,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 ); @@ -733,23 +1212,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 */ @@ -757,7 +1233,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; @@ -765,8 +1241,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 48af64c9d..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 */ @@ -61,12 +62,33 @@ 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 */ 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 @@ -109,6 +131,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 * @@ -133,6 +161,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 */ @@ -143,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 * @@ -151,15 +193,247 @@ public function __construct( Helper_Data $data, Model_Custom_Fonts $custom_fonts */ public function get_sources(): array { if ( $this->sources === null ) { - $this->sources = [ + $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 * @@ -309,57 +583,79 @@ public function get_font_data(): array { * @since 6.19.0 */ public function flush(): void { - $this->font_data = null; - $this->language_font_keys = null; - delete_transient( self::LANGUAGE_FONT_KEYS ); + $this->font_data = null; + $this->language_font_keys = null; + $this->language_font_locales = null; + $this->record_collisions = []; + 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 ); + } + + /* Per site, and may be set without its language pack */ + $locale_keys = array_keys( $this->map_language_font_locales( [ get_locale() ] ) ); + + $this->language_font_keys = array_values( array_unique( array_merge( array_filter( $keys, 'is_string' ), $locale_keys ) ) ); } - $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' ) ); + return $this->language_font_keys; + } - 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; } - $mapper = $this->get_language_mapper(); - $locales = array_filter( array_merge( (array) get_available_languages(), [ $locale ] ), 'is_string' ); - $keys = []; + $this->language_font_locales = $this->map_language_font_locales( + array_merge( (array) get_available_languages(), [ get_locale() ] ) + ); + + return $this->language_font_locales; + } - foreach ( array_unique( $locales ) as $language ) { + /** + * 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; + $keys[ $key ][] = $language; } } - $this->language_font_keys = array_values( array_unique( $keys ) ); - - set_transient( - self::LANGUAGE_FONT_KEYS, - [ - 'locale' => $locale, - 'keys' => $this->language_font_keys, - ], - DAY_IN_SECONDS - ); - - return $this->language_font_keys; + return $keys; } /** @@ -423,21 +719,33 @@ 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 * * @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 ) { - $this->manifest += $this->get_source_manifest( (string) $id ); + foreach ( array_keys( $sources ) as $id ) { + $this->manifest += $this->get_unaliased_manifest( (string) $id ); + } + } + + /* 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 $this->manifest; + return $manifest; } /** @@ -463,13 +771,144 @@ 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_source_manifest( $id, $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 { + $this->get_sources(); + + $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 []; + } + + $sources = $this->get_sources(); + + if ( $this->family_files === null ) { + $this->family_files = []; + 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 ) ) { + $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 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 * @@ -480,21 +919,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; } /** @@ -507,18 +1082,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 * @@ -592,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 */ @@ -721,26 +1320,100 @@ protected function get_core_source(): array { } /** - * A source's manifest, keyed by file name, skipping malformed entries + * 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_source_manifest( (string) $id, $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. 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 []; } @@ -759,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', @@ -781,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 ); } /** @@ -795,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']; } @@ -828,8 +1506,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 d5c3705ae..ce7365f39 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,68 @@ public function get_required_font_files(): 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 + * + * @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 * @@ -183,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; } } @@ -211,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, ]; @@ -267,48 +336,150 @@ 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_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 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 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 ); + $offset += $this->form_page; + $full = count( $page ) === $this->form_page; + } while ( $full ); + + return $counts; + } - $this->selected_font_keys = array_values( array_unique( $keys ) ); + /** + * 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 []; } - return $this->selected_font_keys; + $counts = []; + $sites = get_sites( + [ + 'fields' => 'ids', + 'number' => 0, + 'site__not_in' => [ get_current_blog_id() ], + ] + ); + + foreach ( $sites as $site_id ) { + foreach ( $this->get_site_font_keys( (int) $site_id ) as $key ) { + $counts[ $key ] = ( $counts[ $key ] ?? 0 ) + 1; + } + } + + 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 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 * + * @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/Model/Model_Uninstall.php b/src/Model/Model_Uninstall.php index 2f14e10c5..f215e6648 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,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_Downloader::FAILURES ); /* 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 */ @@ -222,8 +227,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 +246,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/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/Concerns/CopiesFontFixtures.php b/tests/phpunit/Concerns/CopiesFontFixtures.php index f1bb95643..bb363a69d 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; } @@ -145,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. * @@ -168,6 +188,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 +206,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 +318,7 @@ protected function remove_font_fixtures(): void { FlushCache::flush(); } - if ( $this->served_as !== [] ) { + if ( $this->manifest_changed ) { $this->set_shared_manifest( null ); } @@ -263,7 +327,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 +337,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/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/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/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 576358b35..5027e2b27 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; @@ -69,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' ] ) ); @@ -253,6 +255,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/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/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_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_Downloaded_Fonts.php b/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php new file mode 100644 index 000000000..9a7635658 --- /dev/null +++ b/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php @@ -0,0 +1,367 @@ +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_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( + [ + '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_Font_Downloader.php b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php index 1ef433b3d..0b2ed2c57 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(); @@ -362,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' ); @@ -478,6 +621,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 +718,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() { @@ -535,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(); @@ -798,7 +1000,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 +1067,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->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() ); - $this->assertSame( [], $this->get_font_failures() ); + /* 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..61e3771ee 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; @@ -53,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(); @@ -67,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 ); } @@ -76,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(); @@ -532,7 +525,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 +542,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 +553,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 +562,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', @@ -960,18 +956,182 @@ 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 * * @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 = ''; @@ -981,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; } @@ -989,4 +1149,49 @@ 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 { + $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 = [ $this->write_manifest_file( $first ), $second === [] ? '' : $this->write_manifest_file( $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 71bd4bf16..2a4ef0392 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(); } @@ -108,6 +113,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() ); @@ -225,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( [ @@ -328,6 +403,139 @@ 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() ) ); + } + + 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_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/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/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 ); } /** 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 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/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 8e2511759..99db96264 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 !== '' ) { @@ -229,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 [ @@ -248,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 */