diff --git a/src/Helper/Mpdf/On_Demand_Font_File_Finder.php b/src/Helper/Mpdf/On_Demand_Font_File_Finder.php index 05dd82bec..9f9f7261d 100644 --- a/src/Helper/Mpdf/On_Demand_Font_File_Finder.php +++ b/src/Helper/Mpdf/On_Demand_Font_File_Finder.php @@ -20,7 +20,8 @@ } /** - * Downloads a missing font, else falls back to a font on disk (outside Debug Mode) + * Downloads a missing font, or queues a large one for the background sync, else falls back to a font on disk (outside + * Debug Mode) * * @since 6.19.0 */ @@ -88,11 +89,12 @@ public function findFontFile( $name ) { } /* The downloader's fonts folder, as mPDF's AddFontDirectory() replaces the search directories */ - if ( $this->downloader->download( $name ) ) { + if ( $this->downloader->download_or_queue( $name ) ) { return $this->downloader->get_font_path( $name ); } - $fallback = $this->fallback ? $this->get_fallback_file() : ''; + /* Debug Mode falls back too for a queued file, as the font is on its way */ + $fallback = $this->fallback || $this->downloader->is_queued( $name ) ? $this->get_fallback_file() : ''; if ( $fallback === '' ) { throw $e; } @@ -101,7 +103,7 @@ public function findFontFile( $name ) { } /* Only a core font in the fonts folder can be another font's file, and a Font Manager record's stays the record's */ - if ( $this->downloader->is_in_fonts_folder( $found ) && $this->downloader->is_core_font_file( $name ) && ! $this->downloader->is_record_file( $name ) && $this->downloader->download( $name ) ) { + if ( $this->downloader->is_in_fonts_folder( $found ) && $this->downloader->is_core_font_file( $name ) && ! $this->downloader->is_record_file( $name ) && $this->downloader->download_or_queue( $name ) ) { return $this->downloader->get_font_path( $name ); } diff --git a/src/Model/Model_Font_Downloader.php b/src/Model/Model_Font_Downloader.php index e9181c514..4408ae1ad 100644 --- a/src/Model/Model_Font_Downloader.php +++ b/src/Model/Model_Font_Downloader.php @@ -9,6 +9,7 @@ use GFPDF\Helper\Helper_Abstract_Options; use GFPDF\Helper\Helper_Data; use GFPDF\Helper\Helper_Misc; +use GFPDF\Helper\Helper_Render_Health; use GFPDF\Helper\Mpdf\On_Demand_Font_File_Finder; use GFPDF\Statics\Git_Blob; use GFPDF_Vendor\Psr\Log\LoggerInterface; @@ -121,6 +122,14 @@ class Model_Font_Downloader extends Helper_Abstract_Model { */ const OUTDATED = [ 'Garuda.ttf', 'Garuda-Bold.ttf', 'Garuda-Oblique.ttf', 'Garuda-BoldOblique.ttf', 'Sun-ExtA.ttf', 'Sun-ExtB.ttf' ]; + /** + * Large files a PDF needed beyond the required set (e.g. Sun-ExtA on an English site), which the sync fetches and the + * cleanup keeps + * + * @since 6.19.0 + */ + const WANTED = 'gfpdf_font_wanted'; + /** * @var Model_Font_Sources * @since 6.19.0 @@ -489,7 +498,7 @@ public function prune( ?array $keep = null, int $since = 0 ): array { return []; } - $needed = $keep ?? $this->required_fonts->get_required_font_files(); + $needed = $keep ?? $this->with_wanted( $this->required_fonts->get_required_font_files() ); $keep = array_flip( array_diff( $needed, self::OUTDATED ) ); $removed = []; $bytes = 0; @@ -1103,6 +1112,97 @@ public static function back_off_key( string $file ): string { return self::BACK_OFF_KEY . md5( $file ); } + /** + * The files given and the wanted ones + * + * @param string[] $files + * + * @return string[] + * + * @since 6.19.0 + */ + protected function with_wanted( array $files ): array { + return array_unique( array_merge( $files, $this->get_wanted_files() ) ); + } + + /** + * Files a PDF needed beyond the required set + * + * @return string[] + * + * @since 6.19.0 + */ + public function get_wanted_files(): array { + $wanted = get_network_option( get_main_network_id(), self::WANTED, [] ); + + return is_array( $wanted ) ? array_values( array_filter( $wanted, 'is_string' ) ) : []; + } + + /** + * For a render: download() the file, unless it's a large one that isn't on disk, which is left to the background sync + * so the render never waits on it and isn't cached without it + * + * @return bool Whether the font's own copy is on disk now + * + * @since 6.19.0 + */ + public function download_or_queue( string $file ): bool { + if ( ! $this->font_sources->is_large_file( $file ) || $this->font_sources->is_installed( $file ) ) { + return $this->download( $file ); + } + + if ( $this->queue( $file ) ) { + Helper_Render_Health::mark(); + } + + return false; + } + + /** + * Whether a render without the file has it on the way: a large one, unless downloads are off for it or it gave up + * + * @since 6.19.0 + */ + public function is_queued( string $file ): bool { + return $this->font_sources->is_large_file( $file ) && $this->can_queue( $file ); + } + + /** + * Add a file to the wanted list and queue the sync for it, rather than download it now, unless it's awaiting its retry + * + * @return bool Whether it's still to come + * + * @since 6.19.0 + */ + public function queue( string $file ): bool { + if ( ! $this->can_queue( $file ) ) { + return false; + } + + $wanted = $this->get_wanted_files(); + $is_new = ! in_array( $file, $wanted, true ); + + if ( $is_new ) { + $this->log->notice( 'Font queued for the background sync as a PDF needs it', [ 'name' => $file ] ); + $wanted[] = $file; + update_network_option( get_main_network_id(), self::WANTED, $wanted ); + } + + /* A failure already scheduled its retry */ + if ( ! isset( $this->get_failures()[ $file ] ) && ( $is_new || wp_next_scheduled( self::SYNC_EVENT ) === false ) ) { + $this->schedule_sync(); + } + + return true; + } + + /** + * @since 6.19.0 + */ + protected function can_queue( string $file ): bool { + return ! isset( $this->get_failed_files()[ $file ] ) && $this->is_auto_install_enabled( $file ); + } + /** * Queue a single background sync * @@ -1215,7 +1315,7 @@ function ( $site_id ) use ( &$keys ) { return; } - $this->prune( $this->required_fonts->get_install_font_files( $keys ), $state['started'] ); + $this->prune( $this->with_wanted( $this->required_fonts->get_install_font_files( $keys ) ), $state['started'] ); update_network_option( get_main_network_id(), self::CLEANUP, true ); } @@ -1274,7 +1374,7 @@ protected function within_budget( array $items, callable $each ): array { */ protected function get_sync_files(): array { $remaining = get_transient( self::SYNC_REMAINING ); - $files = is_array( $remaining ) ? array_filter( $remaining, 'is_string' ) : $this->required_fonts->get_required_font_files(); + $files = is_array( $remaining ) ? array_filter( $remaining, 'is_string' ) : $this->with_wanted( $this->required_fonts->get_required_font_files() ); $gave_up = $this->get_failed_files(); $files = array_filter( diff --git a/src/Model/Model_Font_Sources.php b/src/Model/Model_Font_Sources.php index 624852743..13978f3fb 100644 --- a/src/Model/Model_Font_Sources.php +++ b/src/Model/Model_Font_Sources.php @@ -66,6 +66,13 @@ class Model_Font_Sources extends Helper_Abstract_Model { */ public const LANGUAGE_FONT_KEYS = 'gfpdf_language_font_keys'; + /** + * A downloadable file above this size is never fetched mid-render, only by the background sync + * + * @since 6.19.0 + */ + public const LARGE_FILE_SIZE = 5 * MB_IN_BYTES; + /** * @var Helper_Data * @since 6.19.0 @@ -233,34 +240,10 @@ public function set_default_font( $config, $form = [], $entry = [], $settings = $config['default_font'] = $this->get_default_font( [ $pdf_font ], $fontdata ); $config['backupSubsFont'] = $this->get_substitution_fonts( $fontdata, $config['default_font'] ); - $config['backupSIPFont'] = $this->get_sip_font( $fontdata ); - - /* Sun-ExtA names Sun-ExtB as its own fallback for those characters */ - foreach ( $fontdata as $key => $family ) { - $sip_ext = is_array( $family ) ? ( $family['sip-ext'] ?? '' ) : ''; - if ( is_string( $sip_ext ) && $sip_ext !== '' && ! $this->is_on_disk( $sip_ext, $fontdata ) ) { - unset( $config['fontdata'][ $key ]['sip-ext'] ); - } - } return $config; } - /** - * mPDF's backupSIPFont: Sun-ExtB once it's on disk, as mPDF would download 18 MB for one rare CJK Extension B - * character, else the bundled symbols. With none, mPDF stops substituting the rest of a run at that character, e.g. - * ε after π ; the first render after a font cache flush still does (GravityPDF/mpdf#828). - * - * @since 6.19.0 - */ - protected function get_sip_font( array $fontdata ): string { - if ( $this->is_on_disk( 'sun-extb', $fontdata ) ) { - return 'sun-extb'; - } - - return $this->is_registered( self::SYMBOLS_FONT, $fontdata ) ? self::SYMBOLS_FONT : ''; - } - /** * The first registered font given, else the default font, else Arimo * @@ -440,8 +423,8 @@ public function map_language_font_locales( array $locales ): array { } /** - * mPDF's backupSubsFont, Arimo first. mPDF reads which characters a font has from its ranges file and downloads only a - * font that has them, but Sun-ExtA is listed only once it's on disk, as it would download 23 MB mid-render. + * mPDF's backupSubsFont, Arimo first. mPDF reads which characters a font has from its ranges file and loads only a font + * that has them; the finder queues a large one (Sun-ExtA) for the background sync rather than download it mid-render. * * @param array|null $fontdata mPDF font data, or null for the registered fonts * @param string $font The PDF's font, left out as it can't stand in for its own missing glyphs @@ -457,7 +440,7 @@ public function get_substitution_fonts( ?array $fontdata = null, string $font = [ self::ARIMO, self::SYMBOLS_FONT ], self::SUBSTITUTION_CORE_FONTS, $this->get_language_font_keys(), - $this->is_on_disk( 'sun-exta', $fontdata ) ? [ 'sun-exta' ] : [] + [ 'sun-exta' ] ); $fonts = array_filter( @@ -479,15 +462,6 @@ protected function is_registered( string $font, array $fontdata ): bool { return is_string( $fontdata[ $font ]['R'] ?? null ); } - /** - * Whether mPDF's font data has a Regular file for the key, and it's in the fonts folder - * - * @since 6.19.0 - */ - protected function is_on_disk( string $font, array $fontdata ): bool { - return $this->is_registered( $font, $fontdata ) && $this->is_downloaded( $fontdata[ $font ]['R'] ); - } - /** * The bundled Arimo Regular path * @@ -687,6 +661,15 @@ public function is_core_font_file( string $file ): bool { return isset( $this->get_manifest()[ $file ] ) && in_array( strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ), [ 'ttf', 'otf' ], true ); } + /** + * Whether a downloadable file is above LARGE_FILE_SIZE + * + * @since 6.19.0 + */ + public function is_large_file( string $file ): bool { + return ( $this->get_manifest()[ $file ]['size'] ?? 0 ) > self::LARGE_FILE_SIZE; + } + /** * Where a manifest file goes in the fonts folder, under its own name * @@ -792,15 +775,6 @@ public function has_hashed_copy( string $file ): bool { return is_file( $path ) && (int) filesize( $path ) === (int) $this->get_manifest()[ $file ]['size']; } - /** - * 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_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()) diff --git a/src/Model/Model_Uninstall.php b/src/Model/Model_Uninstall.php index 61850d8b0..f07c0b46f 100644 --- a/src/Model/Model_Uninstall.php +++ b/src/Model/Model_Uninstall.php @@ -174,14 +174,15 @@ public function remove_plugin_transients() { } /** - * Remove the font state every site shares: the failed downloads and their back-offs, the language fonts and the - * 6.19 cleanup's progress + * Remove the font state every site shares: the failed and wanted downloads and their back-offs, the language + * fonts and the 6.19 cleanup's progress * * @since 6.19.0 */ 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_network_option( get_main_network_id(), Model_Font_Downloader::WANTED ); delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); $font_sources = \GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); diff --git a/tests/phpunit/Concerns/RecordsFontFailures.php b/tests/phpunit/Concerns/RecordsFontFailures.php index acb5f062a..de8d228d5 100644 --- a/tests/phpunit/Concerns/RecordsFontFailures.php +++ b/tests/phpunit/Concerns/RecordsFontFailures.php @@ -109,10 +109,11 @@ protected function font_cleanup_state( array $state = [] ): array { } /** - * Delete the failure record, and have the shared downloader read it afresh + * Delete the failure record and the wanted files, and have the shared downloader read it afresh */ protected function delete_font_failures(): void { delete_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ); + delete_network_option( get_main_network_id(), Model_Font_Downloader::WANTED ); GPDFAPI::get_mvc_class( 'Model_Font_Downloader' )->flush(); } } diff --git a/tests/phpunit/integration/Controller/Test_Controller_System_Report.php b/tests/phpunit/integration/Controller/Test_Controller_System_Report.php index 80b5be5d7..3c8d5b8c4 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_System_Report.php +++ b/tests/phpunit/integration/Controller/Test_Controller_System_Report.php @@ -106,7 +106,7 @@ public function test_system_report_lists_the_required_fonts_installed_and_the_su $this->assertSame( 'core: 0 of 4 required files installed', $directories['core_fonts_installed']['value'] ); $this->assertSame( 'Core Fonts installed', $directories['core_fonts_installed']['label_export'] ); - $this->assertSame( 'arimo, gfpdf-dejavu-symbols, dejavusanscondensed, freesans, garuda, khmeros, padaukbook, kaputaunicode, abyssinicasil', $directories['substitution_fonts']['value'] ); + $this->assertSame( 'arimo, gfpdf-dejavu-symbols, dejavusanscondensed, freesans, garuda, khmeros, padaukbook, kaputaunicode, abyssinicasil, sun-exta', $directories['substitution_fonts']['value'] ); } public function test_system_report_flags_a_missing_core_font_manifest() { 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 662af59f5..665a64423 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 @@ -4,6 +4,7 @@ namespace GFPDF\Helper\Mpdf; +use GFPDF\Helper\Helper_Render_Health; use GFPDF\Model\Model_Font_Downloader; use GFPDF\Model\Model_Font_Sources; use GFPDF\Tests\Concerns\CopiesFontFixtures; @@ -49,9 +50,11 @@ public function set_up(): void { $this->sources = new Model_Font_Sources( GPDFAPI::get_data_class(), GPDFAPI::get_mvc_class( 'Model_Custom_Fonts' ), new NullLogger() ); $this->downloader = new Model_Font_Downloader( $this->sources, GPDFAPI::get_data_class(), GPDFAPI::get_options_class(), GPDFAPI::get_misc_class(), new NullLogger(), GPDFAPI::get_mvc_class( 'Model_Required_Fonts' ) ); wp_mkdir_p( $this->font_dir ); + Helper_Render_Health::begin(); } public function tear_down(): void { + Helper_Render_Health::end(); wp_delete_file( $this->font_dir . self::FONT ); wp_delete_file( $this->font_dir . self::DEFAULT_FONT ); $this->remove_font_fixtures(); @@ -184,6 +187,86 @@ public function get_fallback_file(): string { ( new On_Demand_Font_File_Finder( [ $this->font_dir ], $downloader, '' ) )->findFontFile( self::FONT ); } + /** + * @dataProvider provider_large_files + */ + public function test_a_missing_large_file_is_queued_and_the_render_degraded( string $file ) { + $this->serve_font_downloads(); + + $this->assertSame( PDF_PLUGIN_DIR . 'fonts/gfpdf-Arimo-Regular.ttf', $this->finder( '' )->findFontFile( $file ) ); + $this->assertTrue( Helper_Render_Health::is_degraded() ); + + $this->assertSame( [], $this->requested ); + $this->assertSame( [ $file ], $this->downloader->get_wanted_files() ); + } + + public function provider_large_files(): array { + return [ + 'Sun-ExtA' => [ 'Sun-ExtA.ttf' ], + 'Aegyptus' => [ 'Aegyptus.otf' ], + ]; + } + + public function test_a_large_file_that_cannot_download_falls_back_without_degrading_the_render() { + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + + $this->assertSame( PDF_PLUGIN_DIR . 'fonts/gfpdf-Arimo-Regular.ttf', $this->finder( '' )->findFontFile( 'Sun-ExtA.ttf' ) ); + $this->assertFalse( Helper_Render_Health::is_degraded() ); + + $this->assertSame( [], $this->downloader->get_wanted_files() ); + } + + public function test_a_queued_large_file_falls_back_in_debug_mode_too() { + $this->assertSame( PDF_PLUGIN_DIR . 'fonts/gfpdf-Arimo-Regular.ttf', $this->finder( '', false )->findFontFile( 'Sun-ExtA.ttf' ) ); + $this->assertSame( [ 'Sun-ExtA.ttf' ], $this->downloader->get_wanted_files() ); + } + + public function test_a_large_file_that_cannot_download_throws_in_debug_mode() { + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + $this->expectException( MpdfException::class ); + + $this->finder( '', false )->findFontFile( 'Sun-ExtA.ttf' ); + } + + public function test_a_large_file_another_font_holds_the_name_of_is_queued_and_the_file_at_the_name_stays_in_use() { + $this->put_other_font_at( 'Sun-ExtA.ttf' ); + $this->serve_font_downloads(); + + $this->assertSame( $this->font_dir . '/Sun-ExtA.ttf', $this->finder( '' )->findFontFile( 'Sun-ExtA.ttf' ) ); + $this->assertTrue( Helper_Render_Health::is_degraded() ); + + $this->assertSame( [], $this->requested ); + $this->assertSame( [ 'Sun-ExtA.ttf' ], $this->downloader->get_wanted_files() ); + $this->assertFileDoesNotExist( $this->font_dir . $this->sources->get_hashed_name( 'Sun-ExtA.ttf' ) ); + } + + public function test_a_large_file_another_font_holds_the_name_of_stays_in_use_and_the_render_is_not_degraded_when_it_cannot_download() { + $this->put_other_font_at( 'Sun-ExtA.ttf' ); + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + + $this->assertSame( $this->font_dir . '/Sun-ExtA.ttf', $this->finder( '', false )->findFontFile( 'Sun-ExtA.ttf' ) ); + $this->assertFalse( Helper_Render_Health::is_degraded() ); + + $this->assertSame( [], $this->downloader->get_wanted_files() ); + } + + public function test_a_large_files_hashed_copy_is_found_without_a_plain_file_or_a_queue() { + $hashed = $this->sources->get_hashed_name( 'Sun-ExtA.ttf' ); + $this->put_large_file_at( 'Sun-ExtA.ttf', $hashed ); + + $this->assertSame( $this->font_dir . $hashed, $this->finder( '' )->findFontFile( 'Sun-ExtA.ttf' ) ); + $this->assertFalse( Helper_Render_Health::is_degraded() ); + $this->assertSame( [], $this->downloader->get_wanted_files() ); + } + + public function test_a_large_file_at_its_name_that_is_not_another_fonts_is_used_as_it_is() { + $this->put_large_file_at( 'Sun-ExtA.ttf', 'Sun-ExtA.ttf' ); + + $this->assertSame( $this->font_dir . 'Sun-ExtA.ttf', $this->finder( '' )->findFontFile( 'Sun-ExtA.ttf' ) ); + $this->assertFalse( Helper_Render_Health::is_degraded() ); + $this->assertSame( [], $this->downloader->get_wanted_files() ); + } + public function test_debug_mode_builds_a_finder_without_a_fallback() { $this->assertTrue( $this->get_private( $this->downloader->get_font_file_finder( [] ), 'fallback' ) ); @@ -288,6 +371,19 @@ public function test_a_font_in_another_directory_is_found_without_loading_the_ma $this->assertSame( [], $this->requested ); } + /** + * Write a file of the manifest's size, set above the large file threshold + */ + private function put_large_file_at( string $file, string $target ): void { + $size = Model_Font_Sources::LARGE_FILE_SIZE + 1; + $manifest = $this->sources->get_manifest(); + + $manifest[ $file ]['size'] = $size; + $this->set_private( $this->sources, 'manifest', $manifest ); + file_put_contents( $this->font_dir . $target, str_repeat( "\0", $size ) ); + $this->copied_fonts[] = $this->font_dir . $target; + } + private function counting_downloader(): Seamed_Font_Downloader { return new Seamed_Font_Downloader( new NullLogger(), [ 'sources' => $this->sources ] ); } diff --git a/tests/phpunit/integration/Helper/Test_Helper_PDF.php b/tests/phpunit/integration/Helper/Test_Helper_PDF.php index 17c5a2b86..431f759de 100644 --- a/tests/phpunit/integration/Helper/Test_Helper_PDF.php +++ b/tests/phpunit/integration/Helper/Test_Helper_PDF.php @@ -31,8 +31,8 @@ class Test_Helper_PDF extends TestCase { use SpawnsPhpProcesses; use UsesLockableTmpLocation; - /** mPDF's backupSubsFont on a site with no language font or Sun-ExtA */ - private const SUBSTITUTES = [ 'arimo', 'gfpdf-dejavu-symbols', 'dejavusanscondensed', 'freesans', 'garuda', 'khmeros', 'padaukbook', 'kaputaunicode', 'abyssinicasil' ]; + /** mPDF's backupSubsFont on a site with no language font */ + private const SUBSTITUTES = [ 'arimo', 'gfpdf-dejavu-symbols', 'dejavusanscondensed', 'freesans', 'garuda', 'khmeros', 'padaukbook', 'kaputaunicode', 'abyssinicasil', 'sun-exta' ]; public static function set_up_before_class(): void { parent::set_up_before_class(); @@ -284,16 +284,7 @@ public function test_mpdf_does_not_substitute_from_the_pdfs_own_font() { $this->assertSame( array_slice( self::SUBSTITUTES, 1 ), $this->pdf->get_pdf_class()->backupSubsFont ); } - public function test_mpdf_falls_back_to_the_symbols_until_sun_extb_is_on_disk() { - $this->pdf->set_output_type( 'SAVE' ); - $this->pdf->init(); - - $this->assertSame( 'gfpdf-dejavu-symbols', $this->pdf->get_pdf_class()->backupSIPFont ); - $this->assertArrayNotHasKey( 'sip-ext', $this->pdf->get_pdf_class()->fontdata['sun-exta'] ); - } - - public function test_mpdf_falls_back_to_sun_extb_on_disk() { - $this->copy_fixture( 'Chewy.ttf', 'Sun-ExtB.ttf' ); + public function test_mpdf_falls_back_to_sun_extb_whether_or_not_it_is_on_disk() { $this->pdf->set_output_type( 'SAVE' ); $this->pdf->init(); diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php index 45d6e6a2a..5b2a9d856 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php @@ -511,7 +511,7 @@ public function test_another_fonts_file_stays_when_downloads_are_off() { $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' ); + $this->assertFileExists( $this->font_path( self::FONT ), 'mPDF can still load it' ); } public function test_a_failed_download_beside_another_font_is_recorded_under_the_manifest_name() { @@ -1634,6 +1634,80 @@ public function test_a_font_on_disk_keeps_a_retry_still_to_come() { $this->assertSame( [ self::FONT ], array_keys( $this->get_font_failures() ) ); } + public function test_a_queued_file_is_wanted_without_a_request() { + $this->serve_font_downloads(); + + $this->assertTrue( $this->downloader->queue( 'Sun-ExtA.ttf' ) ); + $this->assertTrue( $this->downloader->queue( 'Sun-ExtB.ttf' ) ); + + $this->assertSame( [], $this->requested ); + $this->assertSame( [ 'Sun-ExtA.ttf', 'Sun-ExtB.ttf' ], $this->downloader->get_wanted_files() ); + $this->assertSame( [ [] ], $this->get_scheduled_args() ); + } + + public function test_a_file_is_not_queued_while_downloads_are_off() { + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + + $this->assertFalse( $this->downloader->queue( 'Sun-ExtA.ttf' ) ); + $this->assertSame( [], $this->downloader->get_wanted_files() ); + $this->assertSame( [], $this->get_scheduled_args() ); + } + + public function test_a_file_that_gave_up_is_not_queued_again() { + $this->set_font_failures( [ 'Sun-ExtA.ttf' => $this->failure( time() ) ] ); + + $this->assertFalse( $this->new_downloader()->queue( 'Sun-ExtA.ttf' ) ); + $this->assertSame( [], $this->get_scheduled_args() ); + } + + public function test_a_wanted_file_is_queued_once() { + $this->downloader->queue( 'Sun-ExtA.ttf' ); + $this->downloader->queue( 'Sun-ExtA.ttf' ); + + $this->assertSame( [ 'Sun-ExtA.ttf' ], $this->downloader->get_wanted_files() ); + $this->assertCount( 1, $this->log_messages( 'Font queued for the background sync as a PDF needs it' ) ); + $this->assertSame( [ [] ], $this->get_scheduled_args() ); + } + + public function test_a_wanted_file_awaiting_its_retry_does_not_pull_the_sync_forward() { + $this->downloader->queue( 'Sun-ExtA.ttf' ); + wp_clear_scheduled_hook( Model_Font_Downloader::SYNC_EVENT ); + $this->set_font_failures( [ 'Sun-ExtA.ttf' => $this->failure( 0, time() + 300 ) ] ); + + $this->new_downloader()->queue( 'Sun-ExtA.ttf' ); + + $this->assertSame( [], $this->get_scheduled_args() ); + } + + public function test_a_wanted_file_still_missing_is_queued_again_once_nothing_is_scheduled() { + $this->downloader->queue( 'Sun-ExtA.ttf' ); + wp_clear_scheduled_hook( Model_Font_Downloader::SYNC_EVENT ); + + $this->downloader->queue( 'Sun-ExtA.ttf' ); + + $this->assertSame( [ [] ], $this->get_scheduled_args() ); + } + + public function test_the_sync_downloads_the_wanted_files() { + $this->serve_fixture_as( 'Chewy.ttf', 'Sun-ExtA.ttf' ); + $this->serve_font_downloads(); + $this->downloader->queue( 'Sun-ExtA.ttf' ); + + $this->seamed_sync( [] )->sync(); + + $this->assertSame( [ 'Sun-ExtA.ttf' ], array_map( 'basename', $this->requested ) ); + $this->assertFileExists( $this->font_path( 'Sun-ExtA.ttf' ) ); + } + + public function test_prune_keeps_the_wanted_files() { + $this->set_installer_manifest( [ 'FreeSans.ttf' => 'Chewy.ttf' ] ); + $this->copy_fixture( 'Chewy.ttf', 'FreeSans.ttf' ); + $this->downloader->queue( 'FreeSans.ttf' ); + + $this->assertSame( [], $this->seamed( [ 'required' => [] ] )->prune() ); + $this->assertFileExists( $this->font_path( 'FreeSans.ttf' ) ); + } + 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 dd20ff3a2..dc5be0970 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php @@ -30,8 +30,8 @@ class Test_Model_Font_Sources extends TestCase { use StubsFontSources; use UnregistersMvcClasses; - /** The substitution list on a site with no language font or Sun-ExtA */ - private const SUBSTITUTES = [ 'arimo', 'gfpdf-dejavu-symbols', 'dejavusanscondensed', 'freesans', 'garuda', 'khmeros', 'padaukbook', 'kaputaunicode', 'abyssinicasil' ]; + /** The substitution list on a site with no language font */ + private const SUBSTITUTES = [ 'arimo', 'gfpdf-dejavu-symbols', 'dejavusanscondensed', 'freesans', 'garuda', 'khmeros', 'padaukbook', 'kaputaunicode', 'abyssinicasil', 'sun-exta' ]; /** * @var Model_Font_Sources @@ -279,8 +279,6 @@ public function test_mpdf_starts_in_the_pdfs_font() { $this->assertSame( 'dejavusans', $config['default_font'] ); - /* Only Sun-ExtA's link to Sun-ExtB, which isn't on disk, is dropped */ - unset( $fontdata['sun-exta']['sip-ext'] ); $this->assertSame( $fontdata, $config['fontdata'] ); } @@ -353,7 +351,7 @@ public function test_the_manifest_merges_every_source() { $this->assertFalse( $this->model->is_core_font_file( '../DejaVuSerifCondensed.ttf' ) ); $this->assertFalse( $this->model->is_core_font_file( 'gfpdf-Arimo-Regular.ttf' ) ); $this->assertSame( $this->font_dir . 'Garuda.ttf', $this->model->get_font_path( 'Garuda.ttf' ) ); - $this->assertFalse( $this->model->is_downloaded( 'Garuda.ttf' ) ); + $this->assertFileDoesNotExist( $this->font_dir . 'Garuda.ttf' ); $this->assertSame( PDF_PLUGIN_DIR . 'fonts/gfpdf-Arimo-Regular.ttf', $this->model->get_fallback_file() ); } @@ -682,24 +680,20 @@ public function test_a_mapped_font_is_a_language_font() { /** * @dataProvider provider_substitution_fonts */ - public function test_the_substitution_fonts_add_the_language_fonts_and_sun_exta_on_disk( array $languages, array $on_disk, array $expected ) { + public function test_the_substitution_fonts_add_the_language_fonts_then_sun_exta( array $languages, array $expected ) { $this->set_languages( $languages ); - foreach ( $on_disk as $file ) { - $this->copy_fixture( 'Chewy.ttf', $file ); - } $this->assertSame( $expected, $this->stub_font_sources()->get_substitution_fonts() ); } public function provider_substitution_fonts(): array { return [ - 'English' => [ [], [], self::SUBSTITUTES ], - 'Korean' => [ [ 'ko_KR' ], [], array_merge( self::SUBSTITUTES, [ 'unbatang' ] ) ], - 'Thai, its font already listed' => [ [ 'th' ], [], self::SUBSTITUTES ], - 'Japanese' => [ [ 'ja' ], [], array_merge( self::SUBSTITUTES, [ 'sun-exta' ] ) ], - 'Chinese' => [ [ 'zh_TW' ], [], array_merge( self::SUBSTITUTES, [ 'sun-exta' ] ) ], - 'English, Sun-ExtA on disk' => [ [], [ 'Sun-ExtA.ttf' ], array_merge( self::SUBSTITUTES, [ 'sun-exta' ] ) ], - 'Japanese and Korean, Sun-ExtA on disk' => [ [ 'ja', 'ko_KR' ], [ 'Sun-ExtA.ttf' ], array_merge( self::SUBSTITUTES, [ 'sun-exta', 'unbatang' ] ) ], + 'English' => [ [], self::SUBSTITUTES ], + 'Korean' => [ [ 'ko_KR' ], $this->substitutes_with( 'unbatang' ) ], + 'Thai, its font already listed' => [ [ 'th' ], self::SUBSTITUTES ], + 'Japanese, its font already listed' => [ [ 'ja' ], self::SUBSTITUTES ], + 'Chinese, its font already listed' => [ [ 'zh_TW' ], self::SUBSTITUTES ], + 'Japanese and Korean' => [ [ 'ja', 'ko_KR' ], array_merge( array_slice( self::SUBSTITUTES, 0, -1 ), [ 'sun-exta', 'unbatang' ] ) ], ]; } @@ -719,6 +713,39 @@ public function test_the_substitution_fonts_leave_out_the_pdfs_font() { $this->assertSame( self::SUBSTITUTES, $model->get_substitution_fonts( null, 'freeserif' ) ); } + /** + * @dataProvider provider_large_files + */ + public function test_a_large_file_is_one_over_five_mb_on_the_manifest( string $file, bool $large ) { + $this->assertSame( $large, $this->model->is_large_file( $file ) ); + } + + public function provider_large_files(): array { + return [ + 'Sun-ExtA, 23 MB' => [ 'Sun-ExtA.ttf', true ], + 'Sun-ExtB, 17.6 MB' => [ 'Sun-ExtB.ttf', true ], + 'Aegyptus, 5.8 MB' => [ 'Aegyptus.otf', true ], + 'UnBatang, just under 5 MB' => [ 'UnBatang_0613.ttf', false ], + 'Garuda' => [ 'Garuda.ttf', false ], + 'not on the manifest' => [ 'NotAFont.ttf', false ], + ]; + } + + public function test_the_large_file_threshold_reads_the_manifest_size() { + $manifest = $this->model->get_manifest(); + $manifest['Garuda.ttf']['size'] = Model_Font_Sources::LARGE_FILE_SIZE + 1; + $this->set_shared_manifest( $manifest ); + + $this->assertTrue( $this->model->is_large_file( 'Garuda.ttf' ) ); + } + + /** + * The substitution list with a language font, which comes before Sun-ExtA + */ + private function substitutes_with( string $font ): array { + return array_merge( array_slice( self::SUBSTITUTES, 0, -1 ), [ $font, 'sun-exta' ] ); + } + private function substitutes_without( string $font ): array { return array_values( array_diff( self::SUBSTITUTES, [ $font ] ) ); } @@ -1187,7 +1214,7 @@ public function test_a_file_is_another_fonts_when_its_size_is_no_known_build() { $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->assertFileExists( $this->font_dir . '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' ) ); diff --git a/tests/phpunit/integration/Model/Test_Uninstaller.php b/tests/phpunit/integration/Model/Test_Uninstaller.php index 185696f8e..61978070f 100644 --- a/tests/phpunit/integration/Model/Test_Uninstaller.php +++ b/tests/phpunit/integration/Model/Test_Uninstaller.php @@ -139,6 +139,7 @@ public function test_uninstall_plugin() { 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 ); + update_site_option( Model_Font_Downloader::WANTED, [ 'Sun-ExtA.ttf' ] ); wp_schedule_single_event( time(), Model_Font_Downloader::CLEANUP_EVENT ); /* Uninstall */ @@ -151,6 +152,7 @@ public function test_uninstall_plugin() { $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 ) ); + $this->assertFalse( get_site_option( Model_Font_Downloader::WANTED ) ); /* Check software was uninstalled */ $this->assertDirectoryDoesNotExist( $gfpdf->data->template_location ); diff --git a/tests/phpunit/integration/Test_Font_Rendering.php b/tests/phpunit/integration/Test_Font_Rendering.php index 89accbe17..356b7a278 100644 --- a/tests/phpunit/integration/Test_Font_Rendering.php +++ b/tests/phpunit/integration/Test_Font_Rendering.php @@ -5,6 +5,8 @@ namespace GFPDF\Tests\Integration; use GFPDF\Helper\Fonts\FlushCache; +use GFPDF\Helper\Helper_Render_Health; +use GFPDF\Model\Model_Font_Downloader; use GFPDF\Tests\Concerns\AssertsSnapshots; use GFPDF\Tests\Concerns\RecordsFontFailures; use GFPDF\Tests\Concerns\CopiesFontFixtures; @@ -43,6 +45,7 @@ public function tear_down(): void { static::copy_test_fonts(); $this->delete_font_failures(); + wp_clear_scheduled_hook( Model_Font_Downloader::SYNC_EVENT ); GPDFAPI::get_options_class()->delete_option( 'debug_mode' ); GPDFAPI::get_options_class()->delete_option( 'default_font' ); @@ -68,20 +71,61 @@ public function test_symbols_arimo_lacks_render_from_the_bundled_subset_without_ $this->assertSame( [], $this->requested ); } - public function test_an_emoji_and_cjk_on_an_english_site_do_not_download_sun_exta(): void { + /** + * @dataProvider provider_large_substitutes + */ + public function test_text_only_a_large_file_draws_queues_it_and_keeps_the_render_out_of_the_cache( string $text, string $file ): void { static::remove_test_fonts(); + FlushCache::flush(); + $this->serve_font_downloads(); + + $render = $this->render( '
' . $text . '
', [ 'font' => 'arimo' ] ); + + $this->assertSame( [], $this->requested ); + $this->assertTrue( $render['degraded'] ); + $this->assertSame( [ $file ], $this->wanted_files() ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::SYNC_EVENT ) ); + } - /* Cached metrics change which substitutes mPDF loads */ + public function provider_large_substitutes(): array { + return [ + 'CJK from Sun-ExtA, beside an emoji none has' => [ 'π ζ₯ζ¬', 'Sun-ExtA.ttf' ], + 'CJK Extension B from Sun-ExtB' => [ 'π ', 'Sun-ExtB.ttf' ], + ]; + } + + public function test_text_the_listed_fonts_draw_queues_nothing_and_is_cached(): void { + $this->serve_font_downloads(); + + $render = $this->render( 'Hello cafΓ© ΠΡΠΈΠ²Π΅Ρ
', [ 'font' => 'arimo' ] ); + + $this->assertFalse( $render['degraded'] ); + $this->assertSame( [], $this->wanted_files() ); + } + + public function test_a_large_pdf_font_is_queued_and_the_pdf_renders_in_arimo(): void { FlushCache::flush(); $this->serve_font_downloads(); - $render = $this->render( 'Smile π ζ₯ζ¬
', [ 'font' => 'arimo' ] ); - $this->assertMatchesSnapshot( 'fonts/emoji-cjk-without-sun-exta.txt', $this->describe_fonts( $render ) ); - $this->assertNotContains( 'sun-exta', $render['mpdf']->backupSubsFont ); - $this->assertArrayNotHasKey( 'sun-exta', $render['mpdf']->fonts ); + $render = $this->render( 'ζ₯ζ¬
', [ 'font' => 'sun-exta' ] ); - /* mPDF reads which characters each substitute has from its ranges file, and none of the others have these */ $this->assertSame( [], $this->requested ); + $this->assertTrue( $render['degraded'] ); + $this->assertSame( 'gfpdf-Arimo-Regular.ttf', basename( $render['mpdf']->fonts['sun-exta']['ttffile'] ?? '' ) ); + $this->assertSame( [ 'Sun-ExtA.ttf' ], $this->wanted_files() ); + } + + public function test_a_large_file_another_font_holds_the_name_of_is_queued_and_the_render_uses_that_font_and_is_not_cached(): void { + $this->put_other_font_at( 'Sun-ExtA.ttf' ); + FlushCache::flush(); + $this->serve_font_downloads(); + + $render = $this->render( 'ζ₯ζ¬
', [ 'font' => 'sun-exta' ] ); + + $this->assertSame( [], $this->requested ); + $this->assertTrue( $render['degraded'] ); + $this->assertSame( 'Sun-ExtA.ttf', basename( $render['mpdf']->fonts['sun-exta']['ttffile'] ?? '' ) ); + $this->assertSame( [ 'Sun-ExtA.ttf' ], $this->wanted_files() ); } /** @@ -134,18 +178,20 @@ public function test_sun_exta_on_disk_is_substituted_from_without_a_download(): $this->assertStringNotContainsString( 'Sun-ExtA', implode( ',', $this->requested ) ); } - public function test_a_cjk_extension_b_character_does_not_download_sun_extb(): void { + public function test_a_cjk_extension_b_character_queues_sun_extb_through_sun_extas_link_too(): void { /* Sun-ExtA's link to Sun-ExtB is the other way mPDF reaches it */ $this->copy_fixture( 'Chewy.ttf', 'Sun-ExtA.ttf' ); + $this->list_fixture_as( 'Chewy.ttf', 'Sun-ExtA.ttf' ); FlushCache::flush(); $this->serve_font_downloads(); foreach ( [ 'arimo', 'sun-exta' ] as $font ) { $render = $this->render( 'π
', [ 'font' => $font ] ); - $this->assertArrayNotHasKey( 'sun-extb', $render['mpdf']->fonts, $font ); + $this->assertTrue( $render['degraded'], $font ); } $this->assertStringNotContainsString( 'Sun-ExtB', implode( ',', $this->requested ) ); + $this->assertSame( [ 'Sun-ExtB.ttf' ], $this->wanted_files() ); } public function test_sun_extb_on_disk_is_the_cjk_extension_b_fallback_without_a_download(): void { @@ -280,7 +326,7 @@ protected function assertRenderMatchesSnapshot( string $name, string $html, arra /** * Render the fixture entry's PDF with the HTML swapped in, and stop once mPDF has written it * - * @return array{mpdf: Mpdf, pdf: string} + * @return array{mpdf: Mpdf, pdf: string, degraded: bool} */ protected function render( string $html, array $settings = [] ): array { [ $result, $render ] = $this->generate( $html, $settings ); @@ -292,14 +338,14 @@ protected function render( string $html, array $settings = [] ): array { } /** - * @return array{0: mixed, 1: array{mpdf: Mpdf, pdf: string}|null} The generate_and_save_pdf() result and the render + * @return array{0: mixed, 1: array{mpdf: Mpdf, pdf: string, degraded: bool}|null} The generate_and_save_pdf() result and the render */ protected function generate( string $html, array $settings = [] ): array { $form = $this->form( 'all-form-fields' ); $entry = $this->entry( 'all-form-fields' ); $capture = new class() extends RuntimeException { - /** @var array{mpdf: Mpdf, pdf: string}|null */ + /** @var array{mpdf: Mpdf, pdf: string, degraded: bool}|null */ public $result; }; @@ -309,8 +355,9 @@ protected function generate( string $html, array $settings = [] ): array { $stop = static function ( $mpdf ) use ( $capture ): void { $capture->result = [ - 'mpdf' => $mpdf, - 'pdf' => $mpdf->Output( '', 'S' ), + 'mpdf' => $mpdf, + 'pdf' => $mpdf->Output( '', 'S' ), + 'degraded' => Helper_Render_Health::is_degraded(), ]; throw $capture; @@ -361,4 +408,11 @@ function ( $code ) { return implode( "\n", $lines ) . "\n"; } + + /** + * @return string[] + */ + private function wanted_files(): array { + return GPDFAPI::get_mvc_class( 'Model_Font_Downloader' )->get_wanted_files(); + } } diff --git a/tests/phpunit/snapshots/fonts/emoji-cjk-without-sun-exta.txt b/tests/phpunit/snapshots/fonts/emoji-cjk-without-sun-exta.txt deleted file mode 100644 index 32432775a..000000000 --- a/tests/phpunit/snapshots/fonts/emoji-cjk-without-sun-exta.txt +++ /dev/null @@ -1,2 +0,0 @@ -BaseFont: Arimo-Regular -arimo: gfpdf-Arimo-Regular.ttf ζ₯ζ¬π diff --git a/tests/playwright/core/system-status/core-fonts.spec.ts b/tests/playwright/core/system-status/core-fonts.spec.ts index f1ec3a4ca..61a921e27 100644 --- a/tests/playwright/core/system-status/core-fonts.spec.ts +++ b/tests/playwright/core/system-status/core-fonts.spec.ts @@ -35,9 +35,9 @@ test.describe('Core Fonts', () => { /core: 0 of \d+ required files installed/ ); - // The bundled fonts and the core ones mPDF downloads only when they have the characters, as Sun-ExtA joins once it's on disk + // The bundled fonts and the core ones mPDF loads only when they have the characters await expect(substitution).toContainText( - 'arimo, gfpdf-dejavu-symbols, dejavusanscondensed, freesans, garuda, khmeros, padaukbook, kaputaunicode, abyssinicasil' + 'arimo, gfpdf-dejavu-symbols, dejavusanscondensed, freesans, garuda, khmeros, padaukbook, kaputaunicode, abyssinicasil, sun-exta' ); await expect( 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 7a7ff33c3..eba5852d0 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