From ef4857f9c759ff4536ebd977ca61ddb1564f63c0 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Tue, 10 Nov 2026 16:54:55 +1100 Subject: [PATCH] Fetch large font files in the background when a PDF needs them A downloadable font file over 5 MB (Model_Font_Sources::LARGE_FILE_SIZE, read from the manifest's size, so it covers plugin-added sources and later manifests too) is never downloaded mid-render. On today's manifest that's Sun-ExtA (23 MB), Sun-ExtB (17.6 MB) and Aegyptus (5.8 MB); UnBatang (4.97 MB) is just under. When mPDF asks the on-demand finder for a missing large file, Model_Font_Downloader::download_or_queue() adds it to a "wanted" list (gfpdf_font_wanted, a network option like the failure record) and queues the background sync, marks the render degraded through Helper_Render_Health and returns the fallback font. The sync fetches required + wanted files with the same retry, give-up and failure notice, and the 6.19 upgrade cleanup keeps wanted files. A file whose downloads are off, or that gave up, isn't queued and the render is cached as before. Debug Mode falls back for a queued file too, since it isn't an error; a large file that can't download still throws there. Because the finder no longer downloads a large file mid-render, Sun-ExtA is listed in backupSubsFont and Sun-ExtB left as mPDF's backupSIPFont (with Sun-ExtA's sip-ext link) whether or not they're on disk. mPDF's FontSubstitution (GravityPDF/mpdf#827, #837) already picks a substitute from the ranges files the fork ships, without loading the font, and asks the finder only when a character needs it. That is the detection: an English site rendering CJK text queues Sun-ExtA, a CJK Extension B character queues Sun-ExtB, and Extension E (no core font has it), Latin or emoji text queues nothing. A selected or CSS font that's a large file goes the same way. An earlier draft scanned the HTML before the render against the ranges files; it duplicated mPDF's scan rules (skipped blocks, Plane 2, ranges parsing) and added an HTML pass to every render, so it was dropped for this. Degraded rather than a cache-generation bump when the download completes: the render that lacked the font is never cached, so nothing needs invalidating, and other PDFs' cached copies aren't thrown away when a font arrives. A large file whose name another font holds (a hand-replaced Sun-ExtA, found by size alone) follows the same rule: the finder queues the manifest's, marks the render degraded and keeps the file at the name in use for that render. A hashed copy of a large file is used without queueing, whether or not the plain file is there. The wanted list is a main-network option, as the failure record is. The gfpdf-dejavu-symbols stand-in for backupSIPFont is gone: mPDF's default Sun-ExtB is always the SIP font now. Jomolhari and Quivira stay off the substitution list (follow-up). Co-Authored-By: Claude Opus 5.5 --- .../Mpdf/On_Demand_Font_File_Finder.php | 10 +- src/Model/Model_Font_Downloader.php | 106 +++++++++++++++++- src/Model/Model_Font_Sources.php | 64 ++++------- src/Model/Model_Uninstall.php | 5 +- .../phpunit/Concerns/RecordsFontFailures.php | 3 +- .../Test_Controller_System_Report.php | 2 +- .../Mpdf/Test_On_Demand_Font_File_Finder.php | 96 ++++++++++++++++ .../integration/Helper/Test_Helper_PDF.php | 15 +-- .../Model/Test_Model_Font_Downloader.php | 76 ++++++++++++- .../Model/Test_Model_Font_Sources.php | 61 +++++++--- .../integration/Model/Test_Uninstaller.php | 2 + .../integration/Test_Font_Rendering.php | 82 +++++++++++--- .../fonts/emoji-cjk-without-sun-exta.txt | 2 - .../core/system-status/core-fonts.spec.ts | 4 +- ...ubstitution-fonts-in-the-system-report.png | Bin 18068 -> 18932 bytes 15 files changed, 424 insertions(+), 104 deletions(-) delete mode 100644 tests/phpunit/snapshots/fonts/emoji-cjk-without-sun-exta.txt 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 7a7ff33c3ccc7b9429cfcbdffd957a70f90bd41b..eba5852d0bb2cceb94445d5f3a2ddd202d3d1413 100644 GIT binary patch literal 18932 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 I&MBb@0FZQwR{#J2 literal 18068 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