diff --git a/src/Controller/Controller_Actions.php b/src/Controller/Controller_Actions.php index 8bdf91f21..ecf5cfe04 100644 --- a/src/Controller/Controller_Actions.php +++ b/src/Controller/Controller_Actions.php @@ -150,7 +150,7 @@ public function get_routes() { 'action' => 'failed_core_fonts', 'action_text' => esc_html__( 'View the system report', 'gravity-pdf' ), 'condition' => [ $this->model, 'has_failed_core_fonts' ], - 'process' => [ $this->model, 'system_report_redirect' ], + 'process' => [ $this->model, 'core_fonts_report_redirect' ], 'dismiss' => [ $this->model, 'dismiss_failed_core_fonts' ], 'view' => function ( $action, $button_text ) { return $this->view->failed_core_fonts( $this->model->get_undismissed_failed_core_fonts(), $action, $button_text ); diff --git a/src/Controller/Controller_System_Report.php b/src/Controller/Controller_System_Report.php index aaf0bc462..b95e5276e 100644 --- a/src/Controller/Controller_System_Report.php +++ b/src/Controller/Controller_System_Report.php @@ -106,6 +106,11 @@ public function site_status_tests( $tests ) { 'test' => [ $this, 'cache_cleanup_test' ], ]; + $tests['direct']['gravity_pdf_core_fonts'] = [ + 'label' => esc_html__( 'Gravity PDF core fonts', 'gravity-pdf' ), + 'test' => [ $this, 'core_fonts_test' ], + ]; + return $tests; } @@ -119,6 +124,16 @@ public function cache_cleanup_test() { return $this->view->get_cache_cleanup_test( $this->model->is_cache_cleanup_stalled() ); } + /** + * Run the core fonts Site Health test + * + * @return array + * @since 6.19.0 + */ + public function core_fonts_test() { + return $this->view->get_core_fonts_test( $this->model->get_core_font_health() ); + } + /** * Run the deprecated functionality Site Health test * diff --git a/src/Model/Model_Actions.php b/src/Model/Model_Actions.php index 8bc3f3fe2..e6df39a45 100644 --- a/src/Model/Model_Actions.php +++ b/src/Model/Model_Actions.php @@ -334,4 +334,14 @@ public function system_report_redirect() { wp_safe_redirect( Model_System_Report::get_report_url() ); exit; } + + /** + * Send the user to the system report row listing the failed core fonts + * + * @since 6.19.0 + */ + public function core_fonts_report_redirect() { + wp_safe_redirect( Model_System_Report::get_report_url( Model_System_Report::FONTS_FAILED_ANCHOR ) ); + exit; + } } diff --git a/src/Model/Model_Font_Downloader.php b/src/Model/Model_Font_Downloader.php index 9f42b658e..e9181c514 100644 --- a/src/Model/Model_Font_Downloader.php +++ b/src/Model/Model_Font_Downloader.php @@ -280,6 +280,61 @@ protected function get_auto_install_constant(): ?bool { return defined( 'GPDF_AUTO_INSTALL_FONTS' ) ? (bool) constant( 'GPDF_AUTO_INSTALL_FONTS' ) : null; } + /** + * Whether the manifest's files may be downloaded, whichever are missing, and what turned them off + * + * @since 6.19.0 + */ + public function get_auto_install_status(): string { + if ( $this->get_auto_install_constant() === false ) { + return 'Off (GPDF_AUTO_INSTALL_FONTS constant)'; + } + + if ( ! $this->is_auto_install_overridden() ) { + return 'On'; + } + + $files = array_keys( $this->font_sources->get_manifest() ); + $blocked = array_filter( + $files, + function ( $file ) { + return ! $this->is_auto_install_enabled( (string) $file ); + } + ); + + if ( $blocked === [] ) { + return 'On'; + } + + return sprintf( '%s (gfpdf_auto_install_fonts filter)', count( $blocked ) === count( $files ) ? 'Off' : 'Partly off' ); + } + + /** + * What stops the missing required files downloading, or null + * + * @param string[] $missing The missing required files + * + * @since 6.19.0 + */ + public function get_auto_install_blocker( array $missing ): ?string { + if ( $this->can_download_all( $missing ) ) { + return null; + } + + return $this->get_auto_install_constant() === false ? 'GPDF_AUTO_INSTALL_FONTS constant' : 'gfpdf_auto_install_fonts filter'; + } + + /** + * Whether the constant turned downloads off or the filter may have, so a file can be blocked + * + * @since 6.19.0 + */ + public function is_auto_install_overridden(): bool { + $locked = $this->get_auto_install_constant(); + + return $locked === null ? has_filter( 'gfpdf_auto_install_fonts' ) : ! $locked; + } + /** * Whether a Font Manager record of this site uses the file, so it isn't the core font's * @@ -1470,7 +1525,7 @@ protected function save_failures( array $failures ): void { } /** - * Failures by file, read once per request + * Failures by file, read once per request, when any give-up whose file is now on disk is cleared from the record * * @return array * @@ -1494,8 +1549,23 @@ protected function get_failures(): array { ]; } } + + $this->clear_installed_give_ups(); } return $this->failures; } + + /** + * Forget the give-ups of files now on disk, as when copied in by hand, so the notice and reports stop listing them + * + * @since 6.19.0 + */ + protected function clear_installed_give_ups(): void { + foreach ( $this->failures as $file => $failure ) { + if ( $failure['gave_up'] > 0 && $this->font_sources->is_installed( $file ) ) { + $this->clear_failure( $file ); + } + } + } } diff --git a/src/Model/Model_Font_Sources.php b/src/Model/Model_Font_Sources.php index 670cc757d..d8d4c01f6 100644 --- a/src/Model/Model_Font_Sources.php +++ b/src/Model/Model_Font_Sources.php @@ -123,6 +123,14 @@ class Model_Font_Sources extends Helper_Abstract_Model { */ protected $manifest; + /** + * The manifest with the hashed names of files a Font Manager record uses, and the collisions it was built from + * + * @var array{collisions: array, manifest: array}|null + * @since 6.19.0 + */ + protected $aliased_manifest; + /** * @var array|null What each core font supports, once read * @since 6.19.0 @@ -508,12 +516,24 @@ public function get_manifest(): array { } } - return $this->with_record_aliases( $this->manifest ); + $collisions = $this->get_record_collisions(); + if ( $collisions === [] ) { + return $this->manifest; + } + + /* Compared, not stored per site, as get_known_builds() calls this while the collisions are part built */ + if ( ( $this->aliased_manifest['collisions'] ?? null ) !== $collisions ) { + $this->aliased_manifest = [ + 'collisions' => $collisions, + 'manifest' => $this->with_record_aliases( $this->manifest ), + ]; + } + + return $this->aliased_manifest['manifest']; } /** - * A manifest with entries for the hashed names of files a Font Manager record uses. Never memoised, as a record can - * be added at any time. + * A manifest with entries for the hashed names of files a Font Manager record uses * * @param array $manifest * diff --git a/src/Model/Model_Required_Fonts.php b/src/Model/Model_Required_Fonts.php index 951b3dd26..9d6a342e7 100644 --- a/src/Model/Model_Required_Fonts.php +++ b/src/Model/Model_Required_Fonts.php @@ -340,6 +340,24 @@ public function get_installed_counts(): array { return $counts; } + /** + * The required files whose own copy isn't in the fonts folder, so one a different font holds the name of counts + * + * @return string[] + * + * @since 6.19.0 + */ + public function get_missing_files(): array { + return array_values( + array_filter( + $this->get_required_font_files(), + function ( $file ) { + return ! $this->font_sources->is_installed( $file ); + } + ) + ); + } + /** * A font key as mPDF stores it: trimmed and lower case, or empty for a non-string * diff --git a/src/Model/Model_System_Report.php b/src/Model/Model_System_Report.php index fcc696436..62bcc349f 100644 --- a/src/Model/Model_System_Report.php +++ b/src/Model/Model_System_Report.php @@ -44,6 +44,20 @@ class Model_System_Report extends Helper_Abstract_Model { */ const SECTION_ANCHOR = 'gfpdf-system-report'; + /** + * The ID of the "Core Fonts failed" report row + * + * @since 6.19.0 + */ + const FONTS_FAILED_ANCHOR = 'gfpdf-font-download-failed'; + + /** + * The ID of the "Core Font downloads" report row + * + * @since 6.19.0 + */ + const FONT_DOWNLOADS_ANCHOR = 'gfpdf-core-font-downloads'; + /** * The section each index of the pre-6.17.0 report items array stood for, in order * @@ -118,23 +132,31 @@ class Model_System_Report extends Helper_Abstract_Model { */ protected $font_sources; + /** + * @var Model_Font_Downloader|null + * + * @since 6.19.0 + */ + protected $font_downloader; + /** * Build the System Report from the plugin settings, environment checks, templates, PDF cache and fonts * * @since 6.0 - * @since 6.19.0 Added `$required_fonts` and `$font_sources`, which default to the registered models + * @since 6.19.0 Added `$required_fonts`, `$font_sources` and `$font_downloader`, which default to the registered models * @since 7.0 Added `$pdf_cache` */ - public function __construct( Helper_Abstract_Options $options, Helper_Data $data, LoggerInterface $log, Helper_Misc $misc, GFPDF_Major_Compatibility_Checks $status, Helper_Templates $templates, Model_Pdf_Cache $pdf_cache, ?Model_Required_Fonts $required_fonts = null, ?Model_Font_Sources $font_sources = null ) { - $this->options = $options; - $this->data = $data; - $this->log = $log; - $this->misc = $misc; - $this->status = $status; - $this->templates = $templates; - $this->pdf_cache = $pdf_cache; - $this->required_fonts = $required_fonts; - $this->font_sources = $font_sources; + public function __construct( Helper_Abstract_Options $options, Helper_Data $data, LoggerInterface $log, Helper_Misc $misc, GFPDF_Major_Compatibility_Checks $status, Helper_Templates $templates, Model_Pdf_Cache $pdf_cache, ?Model_Required_Fonts $required_fonts = null, ?Model_Font_Sources $font_sources = null, ?Model_Font_Downloader $font_downloader = null ) { + $this->options = $options; + $this->data = $data; + $this->log = $log; + $this->misc = $misc; + $this->status = $status; + $this->templates = $templates; + $this->pdf_cache = $pdf_cache; + $this->required_fonts = $required_fonts; + $this->font_sources = $font_sources; + $this->font_downloader = $font_downloader; } /** @@ -227,12 +249,21 @@ public function get_report_structure(): array { } /** - * The Gravity Forms system report, scrolled to the Gravity PDF section + * The Gravity Forms system report, scrolled to an anchor: by default the Gravity PDF section * * @since 6.17.0 */ - public static function get_report_url(): string { - return admin_url( 'admin.php?page=gf_system_status#' . self::SECTION_ANCHOR ); + public static function get_report_url( string $anchor = self::SECTION_ANCHOR ): string { + return admin_url( 'admin.php?page=gf_system_status#' . $anchor ); + } + + /** + * A row label that can be linked to, as Gravity Forms echoes labels unescaped and gives rows no ID + * + * @since 6.19.0 + */ + protected function get_anchored_label( string $anchor, string $label ): string { + return sprintf( '%s', $anchor, $label ); } /** @@ -276,6 +307,71 @@ protected function get_core_fonts_installed(): array { ]; } + /** + * Files whose download gave up, ignoring the admin notice's dismissals + * + * @return array{label: string, label_export: string, value: string} + * + * @since 6.19.0 + */ + protected function get_core_fonts_failed(): array { + $failed = []; + foreach ( $this->get_font_downloader()->get_failed_files() as $file => $gave_up ) { + $failed[] = sprintf( '%s (gave up %s)', $file, wp_date( get_option( 'date_format' ), $gave_up ) ); + } + + return [ + 'label' => $this->get_anchored_label( self::FONTS_FAILED_ANCHOR, esc_html__( 'Core Fonts failed', 'gravity-pdf' ) ), + 'label_export' => 'Core Fonts failed', + 'value' => esc_html( $failed !== [] ? implode( ', ', $failed ) : 'None' ), + ]; + } + + /** + * Whether core fonts may be downloaded, and what turned them off + * + * @return array{label: string, label_export: string, value: string} + * + * @since 6.19.0 + */ + protected function get_core_font_downloads(): array { + return [ + 'label' => $this->get_anchored_label( self::FONT_DOWNLOADS_ANCHOR, esc_html__( 'Core Font downloads', 'gravity-pdf' ) ), + 'label_export' => 'Core Font downloads', + 'value' => esc_html( $this->get_font_downloader()->get_auto_install_status() ), + ]; + } + + /** + * The missing required files, those that gave up (with when) and what turned downloads off, for Site Health + * + * @return array{missing: string[], failed: array, blocker: string|null} + * + * @since 6.19.0 + */ + public function get_core_font_health(): array { + $downloader = $this->get_font_downloader(); + $failed = $downloader->get_failed_files(); + + /* Finding the missing files scans every form, and Site Health waits on it, so only do so when it can matter */ + $missing = $failed !== [] || $downloader->is_auto_install_overridden() ? $this->get_required_fonts()->get_missing_files() : []; + + return [ + 'missing' => $missing, + 'failed' => array_intersect_key( $failed, array_flip( $missing ) ), + 'blocker' => $downloader->get_auto_install_blocker( $missing ), + ]; + } + + /** + * The injected downloader, else the registered model + * + * @since 6.19.0 + */ + protected function get_font_downloader(): Model_Font_Downloader { + return $this->font_downloader ?? GPDFAPI::get_mvc_class( 'Model_Font_Downloader' ); + } + /** * The injected required fonts, else the registered model * @@ -381,6 +477,8 @@ protected function get_report_items(): array { ], 'core_fonts_installed' => $this->get_core_fonts_installed(), + 'core_fonts_failed' => $this->get_core_fonts_failed(), + 'core_font_downloads' => $this->get_core_font_downloads(), 'substitution_fonts' => $this->get_substitution_fonts(), 'temp_folder_location' => [ diff --git a/src/View/View_Actions.php b/src/View/View_Actions.php index 5b61b30f0..bd4285678 100644 --- a/src/View/View_Actions.php +++ b/src/View/View_Actions.php @@ -23,6 +23,13 @@ * @since 4.0 */ class View_Actions extends Helper_Abstract_View { + /** + * The docs on installing the core fonts by hand + * + * @since 6.19.0 + */ + public const CORE_FONTS_DOCS_URL = 'https://docs.gravitypdf.com/users/core-pdf-fonts'; + /** * Set the view's name * @@ -137,12 +144,12 @@ public function failed_core_fonts( array $files, string $type, string $button_te $html = $this->load( 'list_notice', [ - 'heading' => __( 'Gravity PDF could not download these fonts. Until they are installed, PDFs use a fallback font in their place, so some characters may not display.', 'gravity-pdf' ), + 'heading' => __( 'Gravity PDF could not download these fonts.', 'gravity-pdf' ) . ' ' . self::get_missing_core_fonts_warning(), 'items' => $files, 'footer' => sprintf( /* translators: 1: Opening tag, 2: Closing tag */ esc_html__( 'You can %1$sinstall the fonts by hand%2$s. Dismissing this notice hides the fonts above. Any that fail later are reported again.', 'gravity-pdf' ), - '', + '', '' ), ], @@ -151,4 +158,30 @@ public function failed_core_fonts( array $files, string $type, string $button_te return $html . $this->get_action_buttons( $type, $button_text ); } + + /** + * What missing core fonts mean for a PDF + * + * @since 6.19.0 + */ + public static function get_missing_core_fonts_warning(): string { + return __( 'Until the fonts are installed, PDFs that need them use a fallback font and some characters may not display.', 'gravity-pdf' ); + } + + /** + * A bulleted list of code items. Bullets are set per-item: Gravity Forms resets `ul li` to none, and + * wp_kses_post() drops the `list-style` shorthand + * + * @param string[] $items + * + * @since 6.19.0 + */ + public static function get_code_list( array $items ): string { + $html = ''; + foreach ( $items as $item ) { + $html .= '
  • ' . esc_html( $item ) . '
  • '; + } + + return '
      ' . $html . '
    '; + } } diff --git a/src/View/View_System_Report.php b/src/View/View_System_Report.php index 0ecc84006..84d5a0191 100644 --- a/src/View/View_System_Report.php +++ b/src/View/View_System_Report.php @@ -502,6 +502,61 @@ public function get_cache_cleanup_test( bool $stalled ): array { return $result; } + /** + * Build the Site Health result for the core fonts the site's PDFs need + * + * @param array $health See Model_System_Report::get_core_font_health() + * + * @since 6.19.0 + */ + public function get_core_fonts_test( array $health ): array { + $intro = esc_html__( 'Gravity PDF automatically downloads fonts needed to correctly render PDFs.', 'gravity-pdf' ); + + $result = [ + 'label' => esc_html__( 'Gravity PDF has the core fonts your PDFs need', 'gravity-pdf' ), + 'status' => 'good', + 'badge' => $this->get_site_health_badge(), + 'description' => '

    ' . $intro . '

    ', + 'actions' => '', + 'test' => 'gravity_pdf_core_fonts', + ]; + + if ( $health['failed'] === [] && $health['blocker'] === null ) { + return $result; + } + + $result['status'] = 'recommended'; + $result['label'] = esc_html__( 'Some core fonts your PDFs need are missing', 'gravity-pdf' ); + $result['description'] = '

    ' . $intro . ' ' . esc_html( View_Actions::get_missing_core_fonts_warning() ) . '

    '; + $result['actions'] = $this->get_report_link_action( $health['failed'] !== [] ? Model_System_Report::FONTS_FAILED_ANCHOR : Model_System_Report::FONT_DOWNLOADS_ANCHOR ) . + '

    ' . esc_html__( 'Install the fonts by hand', 'gravity-pdf' ) . '

    '; + + if ( $health['failed'] !== [] ) { + /* A give-up needs someone to act and can cost PDFs their text; turning downloads off was a choice */ + $result['status'] = 'critical'; + $result['label'] = esc_html__( 'Gravity PDF could not download required fonts', 'gravity-pdf' ); + $result['description'] .= '

    ' . esc_html__( 'Fonts that failed to download:', 'gravity-pdf' ) . '

    ' . View_Actions::get_code_list( array_keys( $health['failed'] ) ); + } + + if ( $health['blocker'] !== null ) { + $others = array_diff( $health['missing'], array_keys( $health['failed'] ) ); + $list = ''; + + /* translators: %s: The GPDF_AUTO_INSTALL_FONTS constant or the gfpdf_auto_install_fonts filter */ + $off = sprintf( esc_html__( 'Core font downloads are turned off by %s.', 'gravity-pdf' ), preg_replace( '/^\S+/', '$0', esc_html( $health['blocker'] ) ) ); + if ( $others !== [] ) { + $off .= ' ' . esc_html__( 'These fonts are missing:', 'gravity-pdf' ); + $list = View_Actions::get_code_list( $others ); + } + + $result['description'] .= '

    ' . $off . '

    ' . $list; + + $result['actions'] .= '

    ' . esc_html__( 'Learn how to turn font downloads back on', 'gravity-pdf' ) . '

    '; + } + + return $result; + } + /** * @since 7.0 */ @@ -517,8 +572,8 @@ protected function get_site_health_badge(): array { * * @since 7.0 */ - protected function get_report_link_action(): string { - return '

    ' . esc_html__( 'View the Gravity Forms system report', 'gravity-pdf' ) . '

    '; + protected function get_report_link_action( string $anchor = Model_System_Report::SECTION_ANCHOR ): string { + return '

    ' . esc_html__( 'View the Gravity Forms system report', 'gravity-pdf' ) . '

    '; } /** diff --git a/src/View/html/Actions/list_notice.php b/src/View/html/Actions/list_notice.php index 039e9fdad..09bb09576 100644 --- a/src/View/html/Actions/list_notice.php +++ b/src/View/html/Actions/list_notice.php @@ -24,12 +24,7 @@ - -
      - -
    • - -
    + diff --git a/src/bootstrap.php b/src/bootstrap.php index 70da89717..2e841e1c9 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -941,7 +941,8 @@ public function check_system_status() { $this->templates, $this->singleton->get_class( 'Model_Pdf_Cache' ), $this->singleton->get_class( 'Model_Required_Fonts' ), - $this->singleton->get_class( 'Model_Font_Sources' ) + $this->singleton->get_class( 'Model_Font_Sources' ), + $this->singleton->get_class( 'Model_Font_Downloader' ) ); $class = new Controller\Controller_System_Report( $model, $view, $this->gform ); $class->init(); diff --git a/tests/phpunit/Concerns/Seamed_Font_Downloader.php b/tests/phpunit/Concerns/Seamed_Font_Downloader.php index 86e12fb1b..77bad9546 100644 --- a/tests/phpunit/Concerns/Seamed_Font_Downloader.php +++ b/tests/phpunit/Concerns/Seamed_Font_Downloader.php @@ -11,7 +11,8 @@ use GFPDF_Vendor\Psr\Log\LoggerInterface; /** - * A downloader with its seams set: `sources`, `budget` (seconds), `jitter` (seconds) and `required` files + * A downloader with its seams set: `sources`, `budget` (seconds), `jitter` (seconds), `required` files and + * `constant` (GPDF_AUTO_INSTALL_FONTS, null when undefined) */ class Seamed_Font_Downloader extends Model_Font_Downloader { @@ -20,10 +21,15 @@ class Seamed_Font_Downloader extends Model_Font_Downloader { */ public $hashed = []; + /** + * @var bool|null The `constant` seam + */ + private $constant; + /** * Build the downloader with the real dependencies, overriding the seams given * - * @param array{sources?: Model_Font_Sources, budget?: int|float, jitter?: int, required?: string[]} $seams + * @param array{sources?: Model_Font_Sources, budget?: int|float, jitter?: int, required?: string[], constant?: bool|null} $seams */ public function __construct( LoggerInterface $log, array $seams = [] ) { $sources = $seams['sources'] ?? GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); @@ -42,6 +48,21 @@ public function get_required_font_files(): array { $this->sync_budget = $seams['budget'] ?? $this->sync_budget; $this->retry_jitter = $seams['jitter'] ?? $this->retry_jitter; + $this->constant = $seams['constant'] ?? null; + } + + /** + * The `constant` seam when given, else the real constant + */ + protected function get_auto_install_constant(): ?bool { + return $this->constant ?? parent::get_auto_install_constant(); + } + + /** + * The required fonts model holding the `required` seam + */ + public function get_required_fonts(): Model_Required_Fonts { + return $this->required_fonts; } /** diff --git a/tests/phpunit/integration/Controller/Test_Controller_Actions.php b/tests/phpunit/integration/Controller/Test_Controller_Actions.php index 5cf43f396..fdc1a9040 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_Actions.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Actions.php @@ -231,7 +231,7 @@ function () { $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() - 10 ), 'FreeSans.ttf' => $this->failure() ] ); $html = $this->render_notices(); - $this->assertStringContainsString( 'Gravity PDF could not download these fonts. Until they are installed, PDFs use a fallback font in their place', $html ); + $this->assertStringContainsString( 'Gravity PDF could not download these fonts. Until the fonts are installed, PDFs that need them use a fallback font', $html ); $this->assertStringContainsString( 'Garuda.ttf', $html ); $this->assertStringNotContainsString( 'FreeSans.ttf', $html ); $this->assertStringContainsString( 'https://docs.gravitypdf.com/users/core-pdf-fonts', $html ); diff --git a/tests/phpunit/integration/Controller/Test_Controller_System_Report.php b/tests/phpunit/integration/Controller/Test_Controller_System_Report.php index 0bcf89a8f..80b5be5d7 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_System_Report.php +++ b/tests/phpunit/integration/Controller/Test_Controller_System_Report.php @@ -9,8 +9,11 @@ use GFPDF\Model\Model_System_Report; use GFPDF\Statics\Deprecation; use GFPDF\Tests\Concerns\AccessesPrivateState; +use GFPDF\Tests\Concerns\CopiesFontFixtures; use GFPDF\Tests\Concerns\CreatesLegacyDownloadUrls; use GFPDF\Tests\Concerns\CreatesLegacyTemplates; +use GFPDF\Tests\Concerns\RecordsFontFailures; +use GFPDF\Tests\Concerns\Seamed_Font_Downloader; use GFPDF\Tests\Concerns\StubsFontSources; use GFPDF\Tests\Integration\TestCase; @@ -32,7 +35,9 @@ class Test_Controller_System_Report extends TestCase { use AccessesPrivateState; use CreatesLegacyDownloadUrls; + use CopiesFontFixtures; use CreatesLegacyTemplates; + use RecordsFontFailures; use StubsFontSources; public function set_up(): void { @@ -44,6 +49,14 @@ public function set_up(): void { public function tear_down(): void { remove_filter( 'pre_http_request', [ $this, 'get_public_dir_api_response' ] ); + /* Null falls back to the registered models, which are what bootstrap injected */ + $report = \GPDFAPI::get_mvc_class( 'Model_System_Report' ); + $this->set_private( $report, 'font_downloader', null ); + $this->set_private( $report, 'required_fonts', null ); + + $this->remove_font_fixtures(); + $this->delete_font_failures(); + parent::tear_down(); } @@ -426,10 +439,10 @@ public function test_system_report_tools_tab_hooks() { /** * Get the Site Health test the controller registers */ - protected function get_site_health_test(): array { + protected function get_site_health_test( string $id = 'gravity_pdf_deprecated_features' ): array { $tests = apply_filters( 'site_status_tests', [ 'direct' => [] ] ); - return $tests['direct']['gravity_pdf_deprecated_features'] ?? []; + return $tests['direct'][ $id ] ?? []; } public function test_site_health_test_is_registered() { @@ -639,4 +652,158 @@ public function test_the_pdf_cache_row_and_site_health_warn_when_cron_is_not_cle rmdir( $entry_dir ); } } + + public function test_core_fonts_failed_lists_given_up_files_even_after_the_notice_is_dismissed() { + update_option( 'date_format', 'Y-m-d' ); + $this->set_font_failures( + [ + 'Garuda.ttf' => $this->failure( strtotime( '2026-11-03 12:00:00 UTC' ) ), + 'FreeSans.ttf' => $this->failure(), + ] + ); + + $this->assertSame( 'Core Fonts failed', $this->report_row( 'core_fonts_failed' )['label_export'] ); + $this->assertSame( 'Garuda.ttf (gave up 2026-11-03)', $this->report_row( 'core_fonts_failed' )['value'] ); + + \GPDFAPI::get_mvc_class( 'Model_Actions' )->dismiss_failed_core_fonts(); + + $this->assertFalse( \GPDFAPI::get_mvc_class( 'Model_Actions' )->has_failed_core_fonts() ); + $this->assertSame( 'Garuda.ttf (gave up 2026-11-03)', $this->report_row( 'core_fonts_failed' )['value'] ); + } + + public function test_core_fonts_failed_and_the_notice_clear_once_the_font_is_on_disk() { + $this->list_fixture_as( 'Chewy.ttf', 'Garuda.ttf' ); + $this->copy_fixture( 'Chewy.ttf', 'Garuda.ttf' ); + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() ) ] ); + + $this->assertSame( 'None', $this->report_row( 'core_fonts_failed' )['value'] ); + $this->assertFalse( \GPDFAPI::get_mvc_class( 'Model_Actions' )->has_failed_core_fonts() ); + } + + public function test_the_font_rows_can_be_linked_to() { + $this->use_report_downloader( [] ); + + $this->assertStringContainsString( 'id="' . Model_System_Report::FONTS_FAILED_ANCHOR . '"', $this->report_row( 'core_fonts_failed' )['label'] ); + $this->assertStringContainsString( 'id="' . Model_System_Report::FONT_DOWNLOADS_ANCHOR . '"', $this->report_row( 'core_font_downloads' )['label'] ); + } + + public function test_core_fonts_failed_says_none_without_a_given_up_file() { + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure() ] ); + + $this->assertSame( 'None', $this->report_row( 'core_fonts_failed' )['value'] ); + } + + public function test_core_font_downloads_reads_the_downloaders_status() { + $this->use_report_downloader( [ 'Garuda.ttf' ] ); + + $this->assertSame( 'Core Font downloads', $this->report_row( 'core_font_downloads' )['label_export'] ); + $this->assertSame( 'On', $this->report_row( 'core_font_downloads' )['value'] ); + + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + + $this->assertSame( 'Off (gfpdf_auto_install_fonts filter)', $this->report_row( 'core_font_downloads' )['value'] ); + } + + public function test_the_core_fonts_site_health_test_is_gated_on_the_gravity_forms_capability() { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) ); + + $this->assertSame( [], $this->get_site_health_test( 'gravity_pdf_core_fonts' ) ); + } + + public function test_the_core_fonts_site_health_test_is_good_with_every_required_file_on_disk() { + $this->list_fixture_as( 'Chewy.ttf', 'Garuda.ttf' ); + $this->copy_fixture( 'Chewy.ttf', 'Garuda.ttf' ); + $this->use_report_downloader( [ 'Garuda.ttf' ] ); + + /* A file that gave up but has since been copied in by hand no longer counts */ + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() ) ] ); + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + + $result = $this->run_core_fonts_test(); + + $this->assertSame( 'good', $result['status'] ); + $this->assertSame( 'gravity_pdf_core_fonts', $result['test'] ); + } + + public function test_the_core_fonts_site_health_test_is_good_while_missing_files_wait_to_download() { + $this->use_report_downloader( [ 'Garuda.ttf' ] ); + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure() ] ); + + $this->assertSame( 'good', $this->run_core_fonts_test()['status'] ); + } + + public function test_the_core_font_health_only_scans_the_forms_when_a_file_could_be_reported() { + $this->use_report_downloader( [ 'Garuda.ttf' ] ); + $report = \GPDFAPI::get_mvc_class( 'Model_System_Report' ); + + $this->assertSame( [], $report->get_core_font_health()['missing'] ); + + add_filter( 'gfpdf_auto_install_fonts', '__return_true' ); + + $this->assertSame( [ 'Garuda.ttf' ], $report->get_core_font_health()['missing'] ); + } + + public function test_the_core_fonts_site_health_test_is_critical_for_a_file_that_gave_up() { + $this->use_report_downloader( [ 'Garuda.ttf', 'FreeSans.ttf' ] ); + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() ) ] ); + + $result = $this->run_core_fonts_test(); + + $this->assertSame( 'critical', $result['status'] ); + $this->assertSame( 'Gravity PDF could not download required fonts', $result['label'] ); + $this->assertStringContainsString( 'render PDFs. Until the fonts are installed', $result['description'] ); + $this->assertStringContainsString( 'may not display.

    Fonts that failed to download:

    assertStringContainsString( 'Garuda.ttf', $result['description'] ); + $this->assertStringNotContainsString( 'turned off', $result['description'] ); + $this->assertStringContainsString( 'page=gf_system_status#' . Model_System_Report::FONTS_FAILED_ANCHOR, $result['actions'] ); + $this->assertStringNotContainsString( 'gfpdf_auto_install_fonts', $result['actions'] ); + + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + + $this->assertSame( 'critical', $this->run_core_fonts_test()['status'] ); + } + + public function test_the_core_fonts_site_health_test_recommends_action_for_missing_files_while_downloads_are_off() { + $this->use_report_downloader( [ 'Garuda.ttf' ] ); + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + + $result = $this->run_core_fonts_test(); + + $this->assertSame( 'recommended', $result['status'] ); + $this->assertSame( 'Some core fonts your PDFs need are missing', $result['label'] ); + $this->assertStringContainsString( 'turned off by gfpdf_auto_install_fonts filter. These fonts are missing:

    assertStringContainsString( 'Garuda.ttf', $result['description'] ); + $this->assertStringContainsString( 'render PDFs. Until the fonts are installed', $result['description'] ); + $this->assertStringContainsString( 'page=gf_system_status#' . Model_System_Report::FONT_DOWNLOADS_ANCHOR, $result['actions'] ); + $this->assertStringContainsString( 'gfpdf_auto_install_fonts', $result['actions'] ); + } + + /** + * Give the registered report a downloader whose required set is the files given + * + * @param string[] $files + */ + private function use_report_downloader( array $files ): void { + $report = \GPDFAPI::get_mvc_class( 'Model_System_Report' ); + $downloader = new Seamed_Font_Downloader( \GPDFAPI::get_log_class(), [ 'required' => $files ] ); + + $this->set_private( $report, 'font_downloader', $downloader ); + $this->set_private( $report, 'required_fonts', $downloader->get_required_fonts() ); + } + + /** + * Run the core fonts Site Health test as an administrator + */ + private function run_core_fonts_test(): array { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + return call_user_func( $this->get_site_health_test( 'gravity_pdf_core_fonts' )['test'] ); + } + + /** + * A row of the report's directories section + */ + private function report_row( string $key ): array { + return $this->get_report_section( 'directories' )[ $key ]; + } } diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php index 62c1c4570..45d6e6a2a 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php @@ -427,15 +427,7 @@ static function () use ( $filtered ) { ); $this->serve_font_downloads(); - $downloader = new class( ...$this->downloader_args() ) extends Model_Font_Downloader { - /** @var bool|null */ - public $constant; - - protected function get_auto_install_constant(): ?bool { - return $this->constant; - } - }; - $downloader->constant = $constant; + $downloader = $this->seamed( [ 'constant' => $constant ] ); $this->assertSame( $downloads, $downloader->download( self::FONT ) ); $this->assertCount( $downloads ? 1 : 0, $this->requested ); @@ -608,6 +600,125 @@ public function test_prune_never_removes_a_hashed_copy_or_a_replaced_file() { $this->assertFileExists( $this->font_path( $sources->get_hashed_name( 'DejaVuSans.ttf' ) ) ); } + public function test_the_missing_files_are_the_required_files_not_on_disk() { + $this->list_fixture_as( 'Chewy.ttf', 'Garuda.ttf' ); + $this->copy_fixture( 'Chewy.ttf', 'Garuda.ttf' ); + + $this->assertSame( [ self::FONT ], $this->seamed( [ 'required' => [ 'Garuda.ttf', self::FONT ] ] )->get_required_fonts()->get_missing_files() ); + } + + public function test_a_file_another_font_holds_the_name_of_is_missing_until_its_hashed_copy_is_saved() { + $this->put_other_font_at( self::FONT ); + $required = $this->seamed( [ 'required' => [ self::FONT ] ] )->get_required_fonts(); + + $this->assertSame( [ self::FONT ], $required->get_missing_files() ); + + $this->copy_fixture( self::FONT, GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ) ); + $this->assertSame( [], $required->get_missing_files() ); + } + + public function test_the_auto_install_status_does_not_depend_on_the_missing_files() { + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + + $downloader = $this->seamed( [ 'required' => [] ] ); + $this->assertSame( 'Off (gfpdf_auto_install_fonts filter)', $downloader->get_auto_install_status() ); + $this->assertNull( $downloader->get_auto_install_blocker( [] ) ); + } + + public function test_the_blocker_names_the_constant() { + $downloader = $this->seamed( + [ + 'constant' => false, + 'required' => [], + ] + ); + + $this->assertSame( 'GPDF_AUTO_INSTALL_FONTS constant', $downloader->get_auto_install_blocker( [ 'Garuda.ttf' ] ) ); + } + + public function test_the_auto_install_status_only_walks_the_manifest_when_a_filter_could_block_a_file() { + $sources = new class( GPDFAPI::get_data_class(), GPDFAPI::get_mvc_class( 'Model_Custom_Fonts' ), GPDFAPI::get_log_class() ) extends Model_Font_Sources { + /** @var int */ + public $reads = 0; + + public function get_manifest(): array { + $this->reads++; + + return parent::get_manifest(); + } + }; + + $downloader = $this->seamed( [ 'sources' => $sources ] ); + + $this->assertSame( 'On', $downloader->get_auto_install_status() ); + $this->assertSame( 0, $sources->reads ); + + add_filter( 'gfpdf_auto_install_fonts', '__return_true' ); + + $this->assertSame( 'On', $downloader->get_auto_install_status() ); + $this->assertSame( 1, $sources->reads ); + } + + public function test_the_auto_install_status_is_partly_off_when_the_filter_blocks_some_files() { + $this->list_fixture_as( 'Chewy.ttf', 'Garuda.ttf' ); + $this->copy_fixture( 'Chewy.ttf', 'Garuda.ttf' ); + add_filter( + 'gfpdf_auto_install_fonts', + static function ( $download, $file ) { + return $file !== 'Garuda.ttf'; + }, + 10, + 2 + ); + + $downloader = $this->seamed( [ 'required' => [ 'Garuda.ttf', self::FONT ] ] ); + $this->assertSame( 'Partly off (gfpdf_auto_install_fonts filter)', $downloader->get_auto_install_status() ); + + /* Installed, so nothing it blocks is needed yet */ + $this->assertNull( $downloader->get_auto_install_blocker( $downloader->get_required_fonts()->get_missing_files() ) ); + + wp_delete_file( $this->font_path( 'Garuda.ttf' ) ); + $this->assertSame( 'gfpdf_auto_install_fonts filter', $downloader->get_auto_install_blocker( $downloader->get_required_fonts()->get_missing_files() ) ); + } + + /** + * @dataProvider provider_auto_install_status + * + * @param bool|null $constant GPDF_AUTO_INSTALL_FONTS, or null when it isn't defined + */ + public function test_the_auto_install_status_names_what_turned_downloads_off( ?bool $constant, bool $filtered, string $expected ) { + add_filter( 'gfpdf_auto_install_fonts', $filtered ? '__return_true' : '__return_false' ); + + $downloader = $this->seamed( + [ + 'required' => [ self::FONT ], + 'constant' => $constant, + ] + ); + + $this->assertSame( $expected, $downloader->get_auto_install_status() ); + } + + public function test_downloads_are_overridden_by_a_constant_turning_them_off_or_any_filter() { + $this->assertFalse( $this->seamed( [] )->is_auto_install_overridden() ); + $this->assertFalse( $this->seamed( [ 'constant' => true ] )->is_auto_install_overridden() ); + $this->assertTrue( $this->seamed( [ 'constant' => false ] )->is_auto_install_overridden() ); + + add_filter( 'gfpdf_auto_install_fonts', '__return_true' ); + + $this->assertTrue( $this->seamed( [] )->is_auto_install_overridden() ); + $this->assertFalse( $this->seamed( [ 'constant' => true ] )->is_auto_install_overridden() ); + } + + public function provider_auto_install_status(): array { + return [ + 'the constant on, the filter off' => [ true, false, 'On' ], + 'the constant off, the filter on' => [ false, true, 'Off (GPDF_AUTO_INSTALL_FONTS constant)' ], + 'no constant, the filter off' => [ null, false, 'Off (gfpdf_auto_install_fonts filter)' ], + 'no constant, the filter on' => [ null, true, 'On' ], + ]; + } + 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' ); @@ -1494,6 +1605,35 @@ public function test_dismissing_hides_the_failed_files_until_one_fails_again() { $this->assertSame( [ 'FreeSans.ttf' ], $this->new_downloader()->get_undismissed_failed_files() ); } + public function test_a_font_on_disk_clears_its_give_up_for_every_reader() { + $this->list_fixture_as( 'Chewy.ttf', self::FONT ); + $this->copy_fixture( 'Chewy.ttf', self::FONT ); + $this->set_font_failures( + [ + self::FONT => $this->failure( time() - 10 ), + 'Garuda.ttf' => $this->failure( time() - 10 ), + ] + ); + $this->set_back_off( self::FONT ); + + $downloader = $this->new_downloader(); + + $this->assertSame( [ 'Garuda.ttf' ], array_keys( $downloader->get_failed_files() ) ); + $this->assertSame( [ 'Garuda.ttf' ], $downloader->get_undismissed_failed_files() ); + $this->assertSame( [ 'Garuda.ttf' ], array_keys( $this->get_font_failures() ) ); + $this->assertFalse( $this->get_back_off( self::FONT ) ); + } + + public function test_a_font_on_disk_keeps_a_retry_still_to_come() { + $this->list_fixture_as( 'Chewy.ttf', self::FONT ); + $this->copy_fixture( 'Chewy.ttf', self::FONT ); + $this->set_font_failures( [ self::FONT => $this->failure() ] ); + + $this->new_downloader()->get_failed_files(); + + $this->assertSame( [ self::FONT ], array_keys( $this->get_font_failures() ) ); + } + public function test_only_a_true_dismissal_counts() { $this->set_font_failures( [ self::FONT => [ 'retry_at' => 0, 'gave_up' => time(), 'dismissed' => 'yes' ] ] ); diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php index ce381e7a1..8cac6882a 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php @@ -1049,6 +1049,50 @@ public function test_a_family_uses_the_hashed_name_of_a_file_a_font_manager_reco $this->assertTrue( $this->model->is_core_font_file( $hashed ) ); } + public function test_a_collision_builds_the_aliased_manifest_once_until_the_collisions_change() { + $model = new class( GPDFAPI::get_data_class(), $this->custom_fonts, GPDFAPI::get_log_class() ) extends Model_Font_Sources { + /** @var int */ + public $built = 0; + + protected function with_record_aliases( array $manifest ): array { + $this->built++; + + return parent::with_record_aliases( $manifest ); + } + }; + + $this->put_longer_font_at( 'DejaVuSans.ttf', 'DejaVuSans.ttf' ); + $this->add_record( 'mine', [ 'regular' => 'DejaVuSans.ttf' ] ); + $hashed = $model->get_hashed_name( 'DejaVuSans.ttf' ); + + $model->is_installed( 'DejaVuSans.ttf' ); + $this->assertArrayHasKey( $hashed, $model->get_manifest() ); + $this->assertSame( 1, $model->built ); + + GPDFAPI::delete_plugin_option( 'custom_fonts' ); + $model->flush(); + + $this->assertArrayNotHasKey( $hashed, $model->get_manifest() ); + } + + public function test_the_manifest_has_every_collision_not_those_found_first() { + $this->put_longer_font_at( 'DejaVuSans.ttf', 'DejaVuSans.ttf' ); + $this->put_longer_font_at( 'DejaVuSans-Bold.ttf', 'DejaVuSans-Bold.ttf' ); + $this->add_record( + 'mine', + [ + 'regular' => 'DejaVuSans.ttf', + 'bold' => 'DejaVuSans-Bold.ttf', + ] + ); + + $manifest = $this->model->get_manifest(); + + $this->assertCount( 2, $this->model->get_record_collisions() ); + $this->assertArrayHasKey( $this->model->get_record_collisions()['DejaVuSans.ttf'], $manifest ); + $this->assertArrayHasKey( $this->model->get_record_collisions()['DejaVuSans-Bold.ttf'], $manifest ); + } + 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' ] ); diff --git a/tests/playwright/core/system-status/core-fonts.spec.ts b/tests/playwright/core/system-status/core-fonts.spec.ts index 0dea23ce7..f1ec3a4ca 100644 --- a/tests/playwright/core/system-status/core-fonts.spec.ts +++ b/tests/playwright/core/system-status/core-fonts.spec.ts @@ -40,6 +40,10 @@ test.describe('Core Fonts', () => { 'arimo, gfpdf-dejavu-symbols, dejavusanscondensed, freesans, garuda, khmeros, padaukbook, kaputaunicode, abyssinicasil' ); + await expect( + page.getByRole('row', { name: /^Core Font downloads/ }) + ).toContainText('Off (gfpdf_auto_install_fonts filter)'); + await snapshot(page, testinfo, [installed, substitution], { mask: [installed.locator('td').last()], }); diff --git a/tests/playwright/visual-baselines/core-core-system-status-core-fonts-core-fonts-failed-downloads-should-raise-a-dismissible-notice-listing-each-font-that-failed-to-download.png b/tests/playwright/visual-baselines/core-core-system-status-core-fonts-core-fonts-failed-downloads-should-raise-a-dismissible-notice-listing-each-font-that-failed-to-download.png index 09878aeda..8a19056c7 100644 Binary files a/tests/playwright/visual-baselines/core-core-system-status-core-fonts-core-fonts-failed-downloads-should-raise-a-dismissible-notice-listing-each-font-that-failed-to-download.png and b/tests/playwright/visual-baselines/core-core-system-status-core-fonts-core-fonts-failed-downloads-should-raise-a-dismissible-notice-listing-each-font-that-failed-to-download.png differ diff --git a/tests/playwright/visual-baselines/core-core-system-status-core-fonts-core-fonts-should-report-the-installed-core-fonts-and-substitution-fonts-in-the-system-report.png b/tests/playwright/visual-baselines/core-core-system-status-core-fonts-core-fonts-should-report-the-installed-core-fonts-and-substitution-fonts-in-the-system-report.png index b60ca1ff6..7a7ff33c3 100644 Binary files a/tests/playwright/visual-baselines/core-core-system-status-core-fonts-core-fonts-should-report-the-installed-core-fonts-and-substitution-fonts-in-the-system-report.png and b/tests/playwright/visual-baselines/core-core-system-status-core-fonts-core-fonts-should-report-the-installed-core-fonts-and-substitution-fonts-in-the-system-report.png differ diff --git a/tests/playwright/visual-baselines/multisite-multisite-failed-fonts-notice-failed-core-fonts-notice-on-a-multisite-should-list-the-fonts-that-gave-up-on-every-site-and-in-the-network-admin.png b/tests/playwright/visual-baselines/multisite-multisite-failed-fonts-notice-failed-core-fonts-notice-on-a-multisite-should-list-the-fonts-that-gave-up-on-every-site-and-in-the-network-admin.png index 09878aeda..8a19056c7 100644 Binary files a/tests/playwright/visual-baselines/multisite-multisite-failed-fonts-notice-failed-core-fonts-notice-on-a-multisite-should-list-the-fonts-that-gave-up-on-every-site-and-in-the-network-admin.png and b/tests/playwright/visual-baselines/multisite-multisite-failed-fonts-notice-failed-core-fonts-notice-on-a-multisite-should-list-the-fonts-that-gave-up-on-every-site-and-in-the-network-admin.png differ