From 38e02affaea19541717ffececb9159374485fee2 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Fri, 6 Nov 2026 10:28:35 +1100 Subject: [PATCH] Save a core font under a hashed name when another font's file holds its name download() returned true for any file at a manifest name, so a family could silently use another font's file, and merging two sources' manifests with += gave the later source the first's file for a name they use for different files. A file at a manifest name is another font's when its size matches no build the sources know (the manifest's, the legacy manifest's or a source's `legacy` hashes). The family's font is then saved as {stem}-{first 8 of the sha}{ext}, derived from the manifest with nothing stored, and mPDF looks for that name first. The file at the name is never touched. A size mismatch is a stat call, so it is also found while rendering; a file of a known size that is not the manifest's is found by the background sync, the only place a file already on disk is hashed. With downloads off, or a download failing or backing off, the file at the name stays in use, so a conflict never renders worse than before. Two sources that use one name for different files get it at merge time: the later source's manifest entry and family file references take the hashed name, so nothing downstream sees a duplicate. A font upload can't take a name a core font's copy could have, as that copy is used before the file at the plain name, and a file of the wrong size at the hashed name isn't treated as the copy. Every reader of a font file's path now agrees: the finder, the downloaded fonts list and delete (a hashed copy is deleted alone), the installed counts and needs-download checks, the sync's file list and retries, prune and adoption (which read the file at the name), and the unregistered fonts auto-loader (which skips hashed copies). Saving a hashed copy flushes the font metrics and removes a copy left by an older release of the font. Co-Authored-By: Claude Sonnet 5.5 --- src/Controller/Controller_Custom_Fonts.php | 34 +- src/Helper/Helper_Abstract_Options.php | 4 +- .../Mpdf/On_Demand_Font_File_Finder.php | 9 +- src/Model/Model_Font_Downloader.php | 169 +++++++- src/Model/Model_Font_Sources.php | 393 +++++++++++++++++- src/Model/Model_PDF.php | 4 +- src/Model/Model_Required_Fonts.php | 4 +- tests/phpunit/Concerns/CopiesFontFixtures.php | 46 +- .../Concerns/Seamed_Font_Downloader.php | 11 + .../Test_Controller_Custom_Fonts.php | 33 ++ .../Mpdf/Test_On_Demand_Font_File_Finder.php | 103 ++++- .../integration/Helper/Test_Options_API.php | 15 + .../Model/Test_Downloaded_Fonts.php | 55 +++ .../Model/Test_Model_Font_Downloader.php | 176 ++++++++ .../Model/Test_Model_Font_Sources.php | 225 ++++++++++ .../Model/Test_Model_Required_Fonts.php | 29 ++ tests/phpunit/integration/Model/Test_PDF.php | 16 + .../integration/Test_Font_Rendering.php | 91 ++-- 18 files changed, 1321 insertions(+), 96 deletions(-) diff --git a/src/Controller/Controller_Custom_Fonts.php b/src/Controller/Controller_Custom_Fonts.php index d8e5c0090..7943b3dcd 100644 --- a/src/Controller/Controller_Custom_Fonts.php +++ b/src/Controller/Controller_Custom_Fonts.php @@ -19,6 +19,7 @@ use GFPDF\Helper\Helper_Data; use GFPDF\Model\Model_Custom_Fonts; use GFPDF\Model\Model_Font_Downloader; +use GFPDF\Model\Model_Font_Sources; use GFPDF\Model\Model_Required_Fonts; use GFPDF\Statics\Upload_Size; use GFPDF_Vendor\GravityPdf\Upload\ErrorCode; @@ -664,7 +665,7 @@ protected function move_fonts_to_font_dir( array $files ): array { } /** - * Save the font, adding a number to its name if the name is taken + * Save the font, adding a number to its name if the name is taken or is one a core font's copy could have * * The library claims the name as it writes, so a clash only shows up as its DESTINATION_EXISTS error * @@ -676,22 +677,39 @@ protected function upload_with_unique_name( File $file ): void { $name = $file->getName(); for ( $attempt = 1; ; $attempt++ ) { - try { - $file->upload(); + $reserved = $this->is_reserved_name( $file ); - return; - } catch ( UploadException $e ) { - if ( $e->getErrorCode() !== ErrorCode::DESTINATION_EXISTS || $attempt === 10 ) { - throw $e; + if ( ! $reserved ) { + try { + $file->upload(); + + return; + } catch ( UploadException $e ) { + if ( $e->getErrorCode() !== ErrorCode::DESTINATION_EXISTS || $attempt >= 10 ) { + throw $e; + } } } /* Leaves room for the dot, extension and number, which the library would otherwise cut off the end */ $base = mb_strcut( $name, 0, Filename::MAX_LENGTH - strlen( $file->getExtension() ) - 6, 'UTF-8' ); - $file->setName( $base . wp_rand( 10000, 99999 ) ); + + /* Digits alone could complete a reserved name's hash */ + $file->setName( $base . ( $reserved ? '_' . wp_rand( 1000, 9999 ) : wp_rand( 10000, 99999 ) ) ); } } + /** + * Whether the name is one a core font's copy could have, which is used before the file at the font's own name + * + * @since 6.19.0 + */ + protected function is_reserved_name( File $file ): bool { + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + + return $sources instanceof Model_Font_Sources && $sources->is_reserved_name( $file->getNameWithExtension() ); + } + /** * Get a user-facing reason the font upload was refused * diff --git a/src/Helper/Helper_Abstract_Options.php b/src/Helper/Helper_Abstract_Options.php index 05c3b6b6f..a10afce5b 100644 --- a/src/Helper/Helper_Abstract_Options.php +++ b/src/Helper/Helper_Abstract_Options.php @@ -1126,12 +1126,12 @@ public function settings_sanitize( $input = [] ) { $gfpdf_options = $this->settings; /* phpcs:ignore WordPress.Security.NonceVerification.Missing */ - if ( empty( $_POST['_wp_http_referer'] ) || empty( $_POST['option_page'] ) || $_POST['option_page'] !== 'gfpdf_settings' ) { + if ( empty( $_POST['option_page'] ) || $_POST['option_page'] !== 'gfpdf_settings' ) { return $input; } /* phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per key below */ - parse_str( wp_unslash( $_POST['_wp_http_referer'] ), $referrer ); + parse_str( wp_unslash( $_POST['_wp_http_referer'] ?? '' ), $referrer ); $all_settings = $this->get_registered_fields(); $tab = sanitize_key( $referrer['tab'] ?? 'general' ); diff --git a/src/Helper/Mpdf/On_Demand_Font_File_Finder.php b/src/Helper/Mpdf/On_Demand_Font_File_Finder.php index a23053e36..05dd82bec 100644 --- a/src/Helper/Mpdf/On_Demand_Font_File_Finder.php +++ b/src/Helper/Mpdf/On_Demand_Font_File_Finder.php @@ -81,7 +81,7 @@ public function findFontFile( $name ) { } try { - return parent::findFontFile( $name ); + $found = parent::findFontFile( $name ); } catch ( MpdfException $e ) { if ( ! $this->downloader->is_core_font_file( $name ) ) { throw $e; @@ -99,6 +99,13 @@ public function findFontFile( $name ) { return $fallback; } + + /* 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 ) ) { + return $this->downloader->get_font_path( $name ); + } + + return $found; } /** diff --git a/src/Model/Model_Font_Downloader.php b/src/Model/Model_Font_Downloader.php index 4e9cd1f32..cd59be24a 100644 --- a/src/Model/Model_Font_Downloader.php +++ b/src/Model/Model_Font_Downloader.php @@ -274,11 +274,34 @@ protected function get_auto_install_constant(): ?bool { } /** - * Download a file on the manifest into the fonts folder, skipped if it's there + * Whether a Font Manager record of this site uses the file, so it isn't the core font's * * @since 6.19.0 */ - public function download( string $file ): bool { + public function is_record_file( string $file ): bool { + return $this->font_sources->is_record_file( $file ); + } + + /** + * Whether a path is in the fonts folder, as opposed to a bundled font's or one a filter added + * + * @since 6.19.0 + */ + public function is_in_fonts_folder( string $path ): bool { + return untrailingslashit( dirname( $path ) ) === untrailingslashit( $this->data->template_font_location ); + } + + /** + * Download a file on the manifest into the fonts folder, or under its hashed name if another font's file has its name + * + * @param string $file The manifest file name + * @param bool $verify Hash a file of a known size too, to find another font's of the same size + * + * @return bool Whether the font's own copy is on disk now + * + * @since 6.19.0 + */ + public function download( string $file, bool $verify = false ): bool { $manifest = $this->font_sources->get_manifest(); if ( ! isset( $manifest[ $file ] ) ) { @@ -287,8 +310,13 @@ public function download( string $file ): bool { return false; } - $font_path = $this->get_font_path( $file ); - if ( is_file( $font_path ) ) { + if ( $this->font_sources->has_hashed_copy( $file ) ) { + return true; + } + + $font_path = $this->font_sources->get_plain_path( $file ); + $conflict = is_file( $font_path ); + if ( $conflict && ! $this->is_conflict( $file, $verify && $this->is_auto_install_enabled( $file ) ) ) { return true; } @@ -300,7 +328,8 @@ public function download( string $file ): bool { return false; } - if ( ! $this->save( $file, $font_path, $manifest[ $file ] ) ) { + $target = $conflict ? $this->font_sources->get_plain_path( $this->font_sources->get_hashed_name( $file ) ) : $font_path; + if ( ! $this->save( $file, $target, $manifest[ $file ] ) ) { $this->record_failure( $file ); return false; @@ -308,9 +337,81 @@ public function download( string $file ): bool { $this->clear_failure( $file ); + if ( $conflict ) { + $this->log->notice( 'Font saved under a new name as another font has its name', [ 'name' => $file ] ); + $this->remove_older_hashed_copies( $file, basename( $target ) ); + + /* The metrics and PDFs cached from the file that was there are stale */ + FlushCache::flush(); + } + return true; } + /** + * Delete the copies a font's older releases left under other hashed names, if the name matches the copy's own SHA-1 + * + * @param string $file The manifest file name + * @param string $keep The hashed name to keep + * + * @since 6.19.0 + */ + protected function remove_older_hashed_copies( string $file, string $keep ): void { + $dir = $this->data->template_font_location; + + foreach ( scandir( $dir ) ?: [] as $name ) { + $parsed = $this->font_sources->parse_hashed_name( $name ); + if ( $name === $keep || $parsed === null || $parsed['file'] !== $file ) { + continue; + } + + if ( strpos( $this->hash_blob( $dir . $name, (int) filesize( $dir . $name ) ), $parsed['sha'] ) === 0 ) { + wp_delete_file_from_directory( $dir . $name, $dir ); + $this->log->notice( 'Font copy for an older release removed', [ 'name' => $name ] ); + } + } + } + + /** + * Whether the existing file at a manifest name is another font's: no known build has its size, or with $verify its + * SHA-1. A file that can't be read is left alone. + * + * @since 6.19.0 + */ + protected function is_conflict( string $file, bool $verify ): bool { + if ( $this->font_sources->is_size_conflict( $file ) ) { + return true; + } + + if ( ! $verify || $this->font_sources->get_hashed_name( $file ) === '' ) { + return false; + } + + $path = $this->font_sources->get_plain_path( $file ); + $size = (int) filesize( $path ); + $sha = $this->hash_blob( $path, $size ); + + return $sha !== '' && ! in_array( $sha, $this->font_sources->get_known_builds( $file )[ $size ] ?? [], true ); + } + + /** + * Hash a font on disk, separate so tests can count the calls + * + * @since 6.19.0 + */ + protected function hash_blob( string $path, int $size ): string { + return Git_Blob::sha1( $path, $size ); + } + + /** + * Whether the file's own copy is on disk, hashing the file at its name to be sure it's the manifest's + * + * @since 6.19.0 + */ + protected function is_resolved( string $file ): bool { + return $this->font_sources->has_hashed_copy( $file ) || ( is_file( $this->font_sources->get_plain_path( $file ) ) && ! $this->is_conflict( $file, true ) ); + } + /** * Delete the unmodified files (fonts and licences) the Core Font installer downloaded that aren't kept. A file whose * download is turned off stays, as it couldn't come back. Only files of the installer's size are hashed. @@ -343,7 +444,8 @@ public function prune( ?array $keep = null, int $since = 0 ): array { continue; } - $path = $this->get_font_path( $file ); + /* The file at the legacy name, never a hashed copy, which isn't the installer's */ + $path = $this->font_sources->get_plain_path( $file ); clearstatcache( true, $path ); if ( ! is_file( $path ) || filesize( $path ) !== $entry['size'] || ( $since > 0 && filemtime( $path ) > $since ) ) { continue; @@ -531,11 +633,17 @@ protected function get_font_row( string $key, string $label, string $type, array * @since 6.19.0 */ protected function get_core_font_row( string $key, array $family, array $labels ): ?array { - $on_disk = array_filter( - $family['files'], - static function ( string $file ) use ( $family ): bool { - return isset( $family['sizes'][ $file ] ); - } + /* Named as on disk, so the sizes and the preview are the font's own copy */ + $on_disk = array_map( + function ( string $file ): string { + return basename( $this->get_font_path( $file ) ); + }, + array_filter( + $family['files'], + static function ( string $file ) use ( $family ): bool { + return isset( $family['sizes'][ $file ] ); + } + ) ); $can_download = $this->can_download_all( $family['files'] ); @@ -609,7 +717,8 @@ protected function get_size_fields( array $files, string $dir ): array { } /** - * Delete an unused downloaded family's files whatever their hash, keeping the licence files + * Delete an unused downloaded family's files whatever their hash, keeping the licence files. A file with a copy under + * its hashed name loses only the copy: the file at the name is another font's. * * @return array{id: string, removed: string[], bytes: int, font: array|null}|WP_Error `font` is its row now, if it can * be installed again. 404 when @@ -722,6 +831,9 @@ public function install_font( string $key ) { protected function get_core_families(): array { $families = []; + /* A Font Manager record's file at a family's name isn't the family's: its files have hashed names */ + $collisions = $this->font_sources->get_record_collisions(); + foreach ( $this->font_sources->get_sources() as $id => $source ) { if ( empty( $source['manifest'] ) ) { continue; @@ -734,7 +846,8 @@ protected function get_core_families(): array { continue; } - $files = []; + $family = $this->font_sources->use_hashed_names( $family, $collisions ); + $files = []; foreach ( [ 'R', 'B', 'I', 'BI' ] as $style ) { $file = $family[ $style ] ?? null; if ( is_string( $file ) && isset( $manifest[ $file ] ) && $this->font_sources->is_core_font_file( $file ) ) { @@ -957,7 +1070,13 @@ public function sync(): void { array_map( [ $this, 'delete_back_off' ], $due ); $this->syncing = true; - $left = $this->within_budget( array_merge( $due, $missing ), [ $this, 'download' ] ); + $left = $this->within_budget( + array_merge( $due, $missing ), + function ( $file ) { + /* Verified here, off the render path, to find another font's file of a known size */ + $this->download( (string) $file, true ); + } + ); $this->syncing = false; /* Includes the retries this run's first failures added */ @@ -1086,7 +1205,8 @@ protected function within_budget( array $items, callable $each ): array { } /** - * The files a paused run left, else the missing required files, less those that gave up + * The files a paused run left, else the required files, less those that gave up and those with a copy under their + * hashed name. A file already on disk is in the list so sync() can check it's the manifest's. * * @return string[] * @@ -1097,14 +1217,17 @@ protected function get_sync_files(): array { $files = is_array( $remaining ) ? array_filter( $remaining, 'is_string' ) : $this->required_fonts->get_required_font_files(); $gave_up = $this->get_failed_files(); - return array_values( - array_filter( - $files, - function ( $file ) use ( $gave_up ) { - return ! isset( $gave_up[ $file ] ) && ! $this->font_sources->is_downloaded( $file ); - } - ) + $files = array_filter( + $files, + function ( $file ) use ( $gave_up ) { + return ! isset( $gave_up[ $file ] ) && ! $this->font_sources->has_hashed_copy( $file ); + } ); + + $installed = array_filter( $files, [ $this->font_sources, 'is_installed' ] ); + + /* Those needing a download go first, so hashing the rest can't push one into a paused run */ + return array_merge( array_diff( $files, $installed ), $installed ); } /** @@ -1127,7 +1250,7 @@ protected function get_retries(): array { if ( $failure['retry_at'] > time() ) { $next = $next === 0 ? $failure['retry_at'] : min( $next, $failure['retry_at'] ); - } elseif ( $this->font_sources->is_downloaded( $file ) ) { + } elseif ( $this->is_resolved( $file ) ) { $this->clear_failure( $file ); } else { $due[] = $file; diff --git a/src/Model/Model_Font_Sources.php b/src/Model/Model_Font_Sources.php index c3bb0d38a..6ba8f4fc9 100644 --- a/src/Model/Model_Font_Sources.php +++ b/src/Model/Model_Font_Sources.php @@ -145,6 +145,14 @@ class Model_Font_Sources extends Helper_Abstract_Model { */ protected $legacy_manifests = []; + /** + * Core font files a Font Manager record of the site also uses (see get_record_collisions()), by blog ID + * + * @var array> + * @since 6.19.0 + */ + protected $record_collisions = []; + /** * @since 6.19.0 */ @@ -163,10 +171,12 @@ public function __construct( Helper_Data $data, Model_Custom_Fonts $custom_fonts */ public function get_sources(): array { if ( $this->sources === null ) { - $this->sources = [ - 'bundled' => $this->get_bundled_source(), - 'core' => $this->get_core_source(), - ]; + $this->sources = $this->rename_colliding_files( + [ + 'bundled' => $this->get_bundled_source(), + 'core' => $this->get_core_source(), + ] + ); } return $this->sources; @@ -350,6 +360,7 @@ public function flush(): void { $this->font_data = null; $this->language_font_keys = null; $this->language_font_locales = null; + $this->record_collisions = []; delete_site_transient( self::LANGUAGE_FONT_KEYS ); } @@ -482,7 +493,8 @@ public function get_fallback_file(): string { } /** - * Downloadable files across all sources, keyed by file name, read only when a file is needed + * Downloadable files across all sources, keyed by file name, read only when a file is needed. A name two sources + * use for different files is unique here: the later source's has the hashed name (see rename_colliding_files()). * * @return array * @@ -492,11 +504,31 @@ public function get_manifest(): array { if ( $this->manifest === null ) { $this->manifest = []; foreach ( array_keys( $this->get_sources() ) as $id ) { - $this->manifest += $this->get_source_manifest( (string) $id ); + $this->manifest += $this->get_unaliased_manifest( (string) $id ); + } + } + + return $this->with_record_aliases( $this->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. + * + * @param array $manifest + * + * @return array + * + * @since 6.19.0 + */ + protected function with_record_aliases( array $manifest ): array { + foreach ( $this->get_record_collisions() as $file => $hashed ) { + if ( isset( $manifest[ $file ] ) ) { + $manifest[ $hashed ] = $manifest[ $file ]; } } - return $this->manifest; + return $manifest; } /** @@ -522,13 +554,111 @@ public function get_legacy_manifest( string $id ): array { * @since 6.19.0 */ public function get_source_manifest( string $id ): array { + return $this->with_record_aliases( $this->get_unaliased_manifest( $id ) ); + } + + /** + * A source's manifest as read, without the hashed names of files a Font Manager record uses + * + * @return array + * + * @since 6.19.0 + */ + protected function get_unaliased_manifest( string $id ): array { + /* Building the sources reads the manifests of those that share a file name with another */ + $sources = $this->get_sources(); + if ( ! isset( $this->manifests[ $id ] ) ) { - $this->manifests[ $id ] = $this->read_manifest( $this->get_sources()[ $id ] ?? [] ); + $this->manifests[ $id ] = $this->read_manifest( $sources[ $id ] ?? [] ); } return $this->manifests[ $id ]; } + /** + * The core font files a Font Manager record of this site also uses, mapped to the hashed name their families take. + * A record whose file is a known build of the core font (an adopted copy) isn't one. Never hashes. + * + * @return array + * + * @since 6.19.0 + */ + public function get_record_collisions(): array { + $blog = get_current_blog_id(); + if ( isset( $this->record_collisions[ $blog ] ) ) { + return $this->record_collisions[ $blog ]; + } + + /* get_manifest() calls this, so it reads no collisions while they're worked out */ + $this->record_collisions[ $blog ] = []; + + $names = $this->get_record_file_names(); + if ( count( $names ) === 0 ) { + return []; + } + + $manifest = $this->get_manifest(); + + foreach ( $names as $file ) { + $path = $this->get_plain_path( $file ); + if ( ! isset( $manifest[ $file ] ) || ! is_file( $path ) ) { + continue; + } + + if ( ! isset( $this->get_known_builds( $file )[ (int) filesize( $path ) ] ) ) { + $this->record_collisions[ $blog ][ $file ] = $this->build_hashed_name( $file, $manifest[ $file ]['sha'] ); + } + } + + return $this->record_collisions[ $blog ]; + } + + /** + * Whether a Font Manager record of this site uses the file, which then isn't the core font's + * + * @since 6.19.0 + */ + public function is_record_file( string $file ): bool { + return isset( $this->get_record_collisions()[ $file ] ); + } + + /** + * The files of this site's Font Manager records that a downloadable family uses too, by name + * + * @return string[] + * + * @since 6.19.0 + */ + protected function get_record_file_names(): array { + $records = $this->custom_fonts->get_custom_fonts(); + if ( count( $records ) === 0 ) { + return []; + } + + $family_files = []; + foreach ( $this->get_sources() as $source ) { + foreach ( empty( $source['manifest'] ) ? [] : $source['families'] as $family ) { + foreach ( [ 'R', 'B', 'I', 'BI' ] as $style ) { + if ( is_array( $family ) && is_string( $family[ $style ] ?? null ) ) { + $family_files[ $family[ $style ] ] = true; + } + } + } + } + + $names = []; + foreach ( $records as $record ) { + foreach ( [ 'regular', 'italics', 'bold', 'bolditalics' ] as $style ) { + $file = is_string( $record[ $style ] ?? null ) ? basename( $record[ $style ] ) : ''; + if ( isset( $family_files[ $file ] ) ) { + $names[ $file ] = $file; + } + } + } + + return array_values( $names ); + } + /** * Whether a file is a font on the manifest, not one of its licence files * @@ -539,21 +669,175 @@ public function is_core_font_file( string $file ): bool { } /** - * Where a file goes in the fonts folder + * Where a manifest file goes in the fonts folder, under its own name * * @since 6.19.0 */ - public function get_font_path( string $file ): string { + public function get_plain_path( string $file ): string { return $this->data->template_font_location . $file; } /** - * Whether a file is in the fonts folder + * The path mPDF should load for a manifest file: its copy under the hashed name if there is one, else the file at + * its own name, which may be missing. Stat calls only, no hashing. + * + * @since 6.19.0 + */ + public function get_font_path( string $file ): string { + return $this->has_hashed_copy( $file ) ? $this->get_plain_path( $this->get_hashed_name( $file ) ) : $this->get_plain_path( $file ); + } + + /** + * The name a manifest file takes when another file holds its own: `{stem}-{first 8 of its SHA-1}{ext}`, e.g. + * Garuda-0abddd35.ttf. '' when it can't take one: it isn't on the manifest, or the name is already hashed. + * + * @since 6.19.0 + */ + public function get_hashed_name( string $file ): string { + $entry = $this->get_manifest()[ $file ] ?? null; + if ( $entry === null ) { + return ''; + } + + $tag = '-' . substr( $entry['sha'], 0, 8 ); + if ( substr( pathinfo( $file, PATHINFO_FILENAME ), -strlen( $tag ) ) === $tag ) { + return ''; + } + + return $this->build_hashed_name( $file, $entry['sha'] ); + } + + /** + * @since 6.19.0 + */ + protected function build_hashed_name( string $file, string $sha ): string { + $info = pathinfo( $file ); + + return $info['filename'] . '-' . substr( $sha, 0, 8 ) . ( isset( $info['extension'] ) ? '.' . $info['extension'] : '' ); + } + + /** + * The manifest file name and SHA-1 prefix a `{stem}-{sha}{ext}` name holds + * + * @return array{file: string, sha: string}|null Null for any other name + * + * @since 6.19.0 + */ + public function parse_hashed_name( string $name ): ?array { + if ( preg_match( '/^(.+)-([0-9a-f]{8})(\.[^.]+)$/i', $name, $match ) !== 1 ) { + return null; + } + + return [ + 'file' => $match[1] . $match[3], + 'sha' => strtolower( $match[2] ), + ]; + } + + /** + * Whether a file name is a manifest file's hashed name + * + * @since 6.19.0 + */ + public function is_hashed_copy( string $name ): bool { + $parsed = $this->parse_hashed_name( $name ); + $entry = $parsed !== null ? $this->get_manifest()[ $parsed['file'] ] ?? null : null; + + return $entry !== null && $parsed['sha'] === substr( $entry['sha'], 0, 8 ); + } + + /** + * Whether a file name is one a manifest file could take as its hashed name, in any case and for any release's SHA-1 + * + * @since 6.19.0 + */ + public function is_reserved_name( string $name ): bool { + $parsed = $this->parse_hashed_name( $name ); + + return $parsed !== null && isset( array_change_key_case( $this->get_manifest() )[ strtolower( $parsed['file'] ) ] ); + } + + /** + * Whether the file's copy under its hashed name is in the fonts folder, going by its size + * + * @since 6.19.0 + */ + public function has_hashed_copy( string $file ): bool { + $hashed = $this->get_hashed_name( $file ); + if ( $hashed === '' ) { + return false; + } + + $path = $this->get_plain_path( $hashed ); + + 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_font_path( $file ) ); + return is_file( $this->get_plain_path( $file ) ); + } + + /** + * Whether the file's own copy is in the fonts folder: its hashed copy, or the file at its name unless that is + * another font's by size (see is_size_conflict()) + * + * @since 6.19.0 + */ + public function is_installed( string $file ): bool { + return $this->has_hashed_copy( $file ) || ( is_file( $this->get_plain_path( $file ) ) && ! $this->is_size_conflict( $file ) ); + } + + /** + * Whether the file at a manifest name is another font's going by its size alone: no build the sources know has it + * + * @since 6.19.0 + */ + public function is_size_conflict( string $file ): bool { + $path = $this->get_plain_path( $file ); + + /* With no other name to take, the file is the one that is used */ + if ( $this->get_hashed_name( $file ) === '' || ! is_file( $path ) ) { + return false; + } + + $size = (int) filesize( $path ); + + /* The usual case needs no legacy manifest */ + return $size !== (int) $this->get_manifest()[ $file ]['size'] && ! isset( $this->get_known_builds( $file )[ $size ] ); + } + + /** + * The builds of a manifest file the sources know (the manifest's, the legacy manifests' and the sources' `legacy` + * hashes) as git blob SHA-1s by size + * + * @return array + * + * @since 6.19.0 + */ + public function get_known_builds( string $file ): array { + $entry = $this->get_manifest()[ $file ] ?? null; + $builds = []; + if ( $entry !== null ) { + $builds[ $entry['size'] ][] = $entry['sha']; + } + + foreach ( $this->get_sources() as $id => $source ) { + $old = $this->get_legacy_manifest( (string) $id )[ $file ] ?? null; + if ( $old !== null ) { + $builds[ $old['size'] ][] = $old['sha']; + } + + foreach ( empty( $source['manifest'] ) ? [] : (array) ( $source['legacy'] ?? [] ) as $sha => $size ) { + $builds[ (int) $size ][] = (string) $sha; + } + } + + return $builds; } /** @@ -566,18 +850,42 @@ public function is_downloaded( string $file ): bool { public function register_font_data( $fonts ): array { $fonts = is_array( $fonts ) ? $fonts : []; + $collisions = $this->get_record_collisions(); + foreach ( $this->get_sources() as $source ) { foreach ( $source['families'] as $key => $family ) { if ( ! isset( $fonts[ $key ] ) ) { unset( $family['name'], $family['listed'] ); $fonts[ $key ] = $family; } + + /* mPDF's own data already has the core families */ + if ( is_array( $fonts[ $key ] ) && ! empty( $source['manifest'] ) ) { + $fonts[ $key ] = $this->use_hashed_names( $fonts[ $key ], $collisions ); + } } } return $fonts; } + /** + * A family with the given files renamed to their hashed names + * + * @param array $collisions Hashed names by file name + * + * @since 6.19.0 + */ + public function use_hashed_names( array $family, array $collisions ): array { + foreach ( [ 'R', 'B', 'I', 'BI' ] as $style ) { + if ( is_string( $family[ $style ] ?? null ) && isset( $collisions[ $family[ $style ] ] ) ) { + $family[ $style ] = $collisions[ $family[ $style ] ]; + } + } + + return $family; + } + /** * Add each source's `dir` to mPDF's `fontDir`, turning a single folder string into a list * @@ -797,6 +1105,63 @@ protected function get_core_source(): array { ]; } + /** + * Give a file name two sources use for different files its hashed name in the later source's manifest and families + * + * @param array $sources + * + * @return array + * + * @since 6.19.0 + */ + protected function rename_colliding_files( array $sources ): array { + $downloadable = []; + foreach ( $sources as $id => $source ) { + if ( ! empty( $source['manifest'] ) ) { + $downloadable[] = $id; + } + } + + if ( count( $downloadable ) < 2 ) { + return $sources; + } + + $taken = []; + foreach ( $downloadable as $id ) { + $renames = []; + $resolved = []; + + foreach ( $this->read_manifest( $sources[ $id ] ) as $file => $entry ) { + $file = (string) $file; + $name = $file; + + if ( isset( $taken[ $file ] ) && $taken[ $file ] !== $entry['sha'] ) { + $name = $this->build_hashed_name( $file, $entry['sha'] ); + $renames[ $file ] = $name; + $this->log->notice( + 'Font file name used by two sources for different files', + [ + 'source' => $id, + 'name' => $file, + 'as' => $name, + ] + ); + } + + $taken[ $name ] = $taken[ $name ] ?? $entry['sha']; + $resolved[ $name ] = $entry; + } + + $this->manifests[ $id ] = $resolved; + + foreach ( array_filter( $sources[ $id ]['families'] ?? [], 'is_array' ) as $key => $family ) { + $sources[ $id ]['families'][ $key ] = $this->use_hashed_names( $family, $renames ); + } + } + + return $sources; + } + /** * A source's manifest, keyed by file name, skipping malformed entries * @@ -905,8 +1270,8 @@ protected function has_only_known_files( array $font, array $known ): bool { return false; } - /* mPDF finds a font by its file name in the fonts folder, so check the copy there */ - $path = $this->get_font_path( basename( $file ) ); + /* mPDF finds a font by its file name in the fonts folder, so check the copy there, not a hashed one */ + $path = $this->get_plain_path( basename( $file ) ); if ( ! is_file( $path ) || ! is_readable( $path ) ) { return false; } diff --git a/src/Model/Model_PDF.php b/src/Model/Model_PDF.php index e98525db1..e9ead8c19 100644 --- a/src/Model/Model_PDF.php +++ b/src/Model/Model_PDF.php @@ -2370,8 +2370,8 @@ function ( $val ) use ( &$flattened_fonts_array ) { $font_name = basename( $font ); $short_name = $this->options->get_font_short_name( substr( $font_name, 0, -4 ) ); - /* Check if it exists already, otherwise add it */ - if ( ! isset( $fonts[ $short_name ] ) && array_search( $font_name, $flattened_fonts_array, true ) === false ) { + /* Check if it exists already, otherwise add it. A core font's copy under a hashed name is found by that font */ + if ( ! isset( $fonts[ $short_name ] ) && array_search( $font_name, $flattened_fonts_array, true ) === false && ! $this->get_font_sources()->is_hashed_copy( $font_name ) ) { $fonts[ $short_name ] = [ 'R' => $font_name, ]; diff --git a/src/Model/Model_Required_Fonts.php b/src/Model/Model_Required_Fonts.php index fdbd7fd84..951b3dd26 100644 --- a/src/Model/Model_Required_Fonts.php +++ b/src/Model/Model_Required_Fonts.php @@ -303,7 +303,7 @@ public function pdf_needs_download( array $pdf ): bool { */ public function needs_download( $font ): bool { foreach ( $this->get_font_files( $font ) as $file ) { - if ( ! $this->font_sources->is_downloaded( $file ) ) { + if ( ! $this->font_sources->is_installed( $file ) ) { return true; } } @@ -331,7 +331,7 @@ public function get_installed_counts(): array { $files = array_intersect( $required, array_keys( $manifest ) ); $counts[ (string) $id ] = [ - 'installed' => count( array_filter( $files, [ $this->font_sources, 'is_downloaded' ] ) ), + 'installed' => count( array_filter( $files, [ $this->font_sources, 'is_installed' ] ) ), 'total' => count( $files ), 'manifest' => count( $manifest ) > 0, ]; diff --git a/tests/phpunit/Concerns/CopiesFontFixtures.php b/tests/phpunit/Concerns/CopiesFontFixtures.php index f1bb95643..ac3b8e192 100644 --- a/tests/phpunit/Concerns/CopiesFontFixtures.php +++ b/tests/phpunit/Concerns/CopiesFontFixtures.php @@ -168,6 +168,9 @@ function ( $preempt, $args, $url ) use ( $response, $change ) { if ( is_file( $this->font_fixture( $fixture ) ) ) { copy( $this->font_fixture( $fixture ), $args['filename'] ); $this->copied_fonts[] = $this->font_path( $file ); + + /* A file saved beside another font's has its hashed name */ + $this->copied_fonts[] = $this->font_path( GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( $file ) ); } if ( $change !== null ) { @@ -183,16 +186,52 @@ function ( $preempt, $args, $url ) use ( $response, $change ) { ); } + /** + * Put another font's file, of a different size, at a manifest name + */ + protected function put_other_font_at( string $name ): void { + $this->copy_fixture( 'Chewy.ttf', $name ); + } + + /** + * Put a copy of the fixture with its last byte changed at a manifest name: the fixture's size, but not its hash + */ + protected function put_same_size_font_at( string $name, string $fixture ): void { + $bytes = (string) file_get_contents( $this->font_fixture( $fixture ) ); + $last = strlen( $bytes ) - 1; + + $bytes[ $last ] = $bytes[ $last ] === 'x' ? 'y' : 'x'; + file_put_contents( $this->font_path( $name ), $bytes ); + $this->copied_fonts[] = $this->font_path( $name ); + } + + /** + * Put a renderable copy of the fixture at a manifest name that is one byte longer, so a size check finds it isn't + * the manifest's + */ + protected function put_longer_font_at( string $name, string $fixture ): void { + file_put_contents( $this->font_path( $name ), (string) file_get_contents( $this->font_fixture( $fixture ) ) . "\0" ); + $this->copied_fonts[] = $this->font_path( $name ); + } + /** * Serve a fixture as a manifest file, with a manifest entry matching the fixture so the plugin's downloader verifies it */ protected function serve_fixture_as( string $fixture, string $file ): void { + $this->list_fixture_as( $fixture, $file ); + $this->served_as[ $file ] = $fixture; + } + + /** + * List the fixture as the manifest file's build, so a copy of it at that name is the font's own and not another + * font's, which the plugin would save the real one beside + */ + protected function list_fixture_as( string $fixture, string $file ): void { $manifest = GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_manifest(); $manifest[ $file ] = $this->manifest_entry( $fixture, $manifest[ $file ]['url'] ?? 'https://example.com/' . $file ); $this->set_shared_manifest( $manifest ); - $this->served_as[ $file ] = $fixture; } /** @@ -254,9 +293,7 @@ protected function remove_font_fixtures(): void { FlushCache::flush(); } - if ( $this->served_as !== [] ) { - $this->set_shared_manifest( null ); - } + $this->set_shared_manifest( null ); $this->set_private( GPDFAPI::get_mvc_class( 'Model_Font_Sources' ), 'legacy_manifests', [] ); @@ -272,6 +309,7 @@ protected function remove_font_fixtures(): void { protected function put_freeserif_on_disk(): void { foreach ( static::$freeserif as $file ) { $this->copy_fixture( 'Chewy.ttf', $file ); + $this->list_fixture_as( 'Chewy.ttf', $file ); } } } diff --git a/tests/phpunit/Concerns/Seamed_Font_Downloader.php b/tests/phpunit/Concerns/Seamed_Font_Downloader.php index 5498b9873..86e12fb1b 100644 --- a/tests/phpunit/Concerns/Seamed_Font_Downloader.php +++ b/tests/phpunit/Concerns/Seamed_Font_Downloader.php @@ -15,6 +15,11 @@ */ class Seamed_Font_Downloader extends Model_Font_Downloader { + /** + * @var string[] The files hashed to find another font's, by name + */ + public $hashed = []; + /** * Build the downloader with the real dependencies, overriding the seams given * @@ -47,4 +52,10 @@ public function get_required_font_files(): array { public function set_required( array $files ): void { $this->required_fonts->files = $files; } + + protected function hash_blob( string $path, int $size ): string { + $this->hashed[] = basename( $path ); + + return parent::hash_blob( $path, $size ); + } } diff --git a/tests/phpunit/integration/Controller/Test_Controller_Custom_Fonts.php b/tests/phpunit/integration/Controller/Test_Controller_Custom_Fonts.php index e11d17d09..1c80a1ca0 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_Custom_Fonts.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Custom_Fonts.php @@ -239,6 +239,39 @@ public function test_add_item_saves_styles_uploaded_under_the_same_filename() { $this->assertFileExists( $font['bold'] ); } + /** + * @since 6.19.0 + */ + public function test_add_item_does_not_save_under_a_core_fonts_hashed_name() { + wp_set_current_user( $this->admin_user ); + + $hashed = strtolower( GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( 'DejaVuSansCondensed.ttf' ) ); + + $request = $this->new_font_request(); + $this->set_font_files( $request, [ 'regular' => [ $hashed, self::$test_fonts[0] ] ] ); + + $font = rest_get_server()->dispatch( $request )->get_data(); + + $this->assertFileExists( $font['regular'] ); + $this->assertFileDoesNotExist( $this->tmp_font_location . $hashed ); + } + + /** + * @since 6.19.0 + */ + public function test_add_item_finishes_when_a_taken_names_number_would_make_it_reserved() { + wp_set_current_user( $this->admin_user ); + mkdir( $this->tmp_font_location . 'DejaVuSansCondensed-700.ttf' ); + + $request = $this->new_font_request(); + $this->set_font_files( $request, [ 'regular' => [ 'DejaVuSansCondensed-700.ttf', self::$test_fonts[0] ] ] ); + + $font = rest_get_server()->dispatch( $request )->get_data(); + + $this->assertMatchesRegularExpression( '/DejaVuSansCondensed-700_[0-9]{4}\.ttf$/', $font['regular'] ); + $this->assertFileExists( $font['regular'] ); + } + /** * @since 6.18.0 */ diff --git a/tests/phpunit/integration/Helper/Mpdf/Test_On_Demand_Font_File_Finder.php b/tests/phpunit/integration/Helper/Mpdf/Test_On_Demand_Font_File_Finder.php index 694bd8b6d..662af59f5 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 @@ -8,6 +8,7 @@ use GFPDF\Model\Model_Font_Sources; use GFPDF\Tests\Concerns\CopiesFontFixtures; use GFPDF\Tests\Concerns\RecordsFontFailures; +use GFPDF\Tests\Concerns\Seamed_Font_Downloader; use GFPDF\Tests\Integration\TestCase; use GFPDF_Vendor\Mpdf\MpdfException; use GFPDF_Vendor\Psr\Log\NullLogger; @@ -67,7 +68,6 @@ public function test_a_font_on_disk_is_found_without_a_request() { $this->assertFileEquals( $this->font_fixture( self::FONT ), $this->finder()->findFontFile( self::FONT ) ); $this->assertSame( [], $this->requested ); - $this->assertNull( $this->get_private( $this->sources, 'manifest' ) ); } public function test_a_missing_core_font_is_downloaded() { @@ -191,6 +191,107 @@ public function test_debug_mode_builds_a_finder_without_a_fallback() { $this->assertFalse( $this->get_private( $this->downloader->get_font_file_finder( [] ), 'fallback' ) ); } + public function test_a_file_at_the_name_the_manifest_does_not_own_is_left_and_the_font_saved_under_its_hashed_name() { + $this->put_other_font_at( self::FONT ); + $this->serve_font_downloads(); + $hashed = $this->sources->get_hashed_name( self::FONT ); + + $this->assertMatchesRegularExpression( '/^DejaVuSerifCondensed-[0-9a-f]{8}\.ttf$/', $hashed ); + $this->assertSame( $this->font_dir . $hashed, $this->finder()->findFontFile( self::FONT ) ); + $this->assertFileEquals( $this->font_fixture( self::FONT ), $this->font_dir . $hashed ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_dir . self::FONT ); + $this->assertCount( 1, $this->requested ); + } + + public function test_a_font_manager_records_file_at_a_core_fonts_name_is_not_replaced_by_the_core_font() { + $this->put_other_font_at( self::FONT ); + $this->add_record( 'mine', [ 'regular' => self::FONT ] ); + $this->serve_font_downloads(); + + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->finder()->findFontFile( self::FONT ) ); + $this->assertSame( [], $this->requested ); + $this->assertFileDoesNotExist( $this->font_dir . $this->sources->get_hashed_name( self::FONT ) ); + } + + public function test_the_hashed_copy_is_found_before_the_file_at_the_name_without_a_request_or_hashing() { + $this->put_other_font_at( self::FONT ); + $this->copy_fixture( self::FONT, $this->sources->get_hashed_name( self::FONT ) ); + $this->serve_font_downloads(); + $downloader = $this->counting_downloader(); + + $finder = new On_Demand_Font_File_Finder( [ $this->font_dir ], $downloader, self::DEFAULT_FONT ); + + $this->assertSame( $this->font_dir . $this->sources->get_hashed_name( self::FONT ), $finder->findFontFile( self::FONT ) ); + $this->assertSame( [], $this->requested ); + $this->assertSame( [], $downloader->hashed ); + } + + public function test_a_file_of_a_known_size_is_not_hashed_while_rendering() { + copy( $this->font_fixture( self::FONT ), $this->font_dir . self::FONT ); + $this->put_same_size_font_at( self::DEFAULT_FONT, self::DEFAULT_FONT ); + $this->serve_font_downloads(); + $downloader = $this->counting_downloader(); + + $finder = new On_Demand_Font_File_Finder( [ $this->font_dir ], $downloader, self::DEFAULT_FONT ); + + $this->assertSame( $this->font_dir . self::FONT, $finder->findFontFile( self::FONT ) ); + $this->assertSame( $this->font_dir . self::DEFAULT_FONT, $finder->findFontFile( self::DEFAULT_FONT ) ); + $this->assertSame( [], $this->requested ); + $this->assertSame( [], $downloader->hashed ); + } + + public function test_a_file_from_an_older_release_is_not_another_fonts() { + copy( $this->font_fixture( 'DejaVuSerifCondensed-v1.0.0.ttf' ), $this->font_dir . self::FONT ); + $this->serve_font_downloads(); + + $this->assertSame( $this->font_dir . self::FONT, $this->finder()->findFontFile( self::FONT ) ); + $this->assertSame( [], $this->requested ); + $this->assertFileDoesNotExist( $this->font_dir . $this->sources->get_hashed_name( self::FONT ) ); + } + + public function test_a_file_at_the_name_stays_in_use_when_downloads_are_off() { + $this->put_other_font_at( self::FONT ); + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + $this->serve_font_downloads(); + + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->finder()->findFontFile( self::FONT ) ); + $this->assertSame( [], $this->requested ); + $this->assertEmpty( $this->get_font_failures() ); + } + + public function test_a_file_at_the_name_stays_in_use_when_the_download_fails_and_backs_off() { + $this->put_other_font_at( self::FONT ); + $this->fail_font_downloads(); + + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->finder()->findFontFile( self::FONT ) ); + $this->assertSame( [ self::FONT ], array_keys( $this->get_font_failures() ) ); + $this->assertCount( 1, $this->requested ); + + /* Backing off: no second request, and the same file */ + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->finder()->findFontFile( self::FONT ) ); + $this->assertCount( 1, $this->requested ); + $this->assertFileDoesNotExist( $this->font_dir . $this->sources->get_hashed_name( self::FONT ) ); + } + + public function test_a_file_at_the_name_stays_in_use_in_debug_mode_when_the_download_fails() { + $this->put_other_font_at( self::FONT ); + $this->fail_font_downloads(); + + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->finder( self::DEFAULT_FONT, false )->findFontFile( self::FONT ) ); + } + + public function test_a_font_in_another_directory_is_found_without_loading_the_manifest() { + $this->serve_font_downloads(); + + $this->assertSame( PDF_PLUGIN_DIR . 'fonts/gfpdf-Arimo-Regular.ttf', ( new On_Demand_Font_File_Finder( [ PDF_PLUGIN_DIR . 'fonts' ], $this->downloader, '' ) )->findFontFile( 'gfpdf-Arimo-Regular.ttf' ) ); + $this->assertNull( $this->get_private( $this->sources, 'manifest' ) ); + $this->assertSame( [], $this->requested ); + } + + private function counting_downloader(): Seamed_Font_Downloader { + return new Seamed_Font_Downloader( new NullLogger(), [ 'sources' => $this->sources ] ); + } + /** * A finder searching the fonts folder */ diff --git a/tests/phpunit/integration/Helper/Test_Options_API.php b/tests/phpunit/integration/Helper/Test_Options_API.php index 1bbfa8843..aeedfa8ec 100644 --- a/tests/phpunit/integration/Helper/Test_Options_API.php +++ b/tests/phpunit/integration/Helper/Test_Options_API.php @@ -875,6 +875,21 @@ public function test_settings_sanitize() { $this->assertArrayNotHasKey( 'other_type', $updated_settings ); } + public function test_settings_sanitize_uses_the_general_tab_with_no_referrer() { + unset( $_POST['_wp_http_referer'] ); + $_POST['option_page'] = 'gfpdf_settings'; + + $updated_settings = $this->options->settings_sanitize( + [ + 'default_pdf_size' => 'A5', + 'other_type' => 'wont validate', + ] + ); + + $this->assertSame( 'A5', $updated_settings['default_pdf_size'] ); + $this->assertArrayNotHasKey( 'other_type', $updated_settings ); + } + /** * @since 7.0 */ diff --git a/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php b/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php index aa725a3c9..9c15235d0 100644 --- a/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php +++ b/tests/phpunit/integration/Model/Test_Downloaded_Fonts.php @@ -365,6 +365,57 @@ public function test_delete_removes_the_family_whatever_its_hash_and_keeps_the_l $this->assertCount( 1, $this->log_messages( 'Downloaded font deleted' ) ); } + public function test_delete_removes_only_the_hashed_copy_and_never_the_file_at_the_name() { + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + $hashed = $sources->get_hashed_name( 'DejaVuSerifCondensed.ttf' ); + + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSerifCondensed.ttf' ); + $this->copy_fixture( 'DejaVuSerifCondensed.ttf', $hashed ); + + $row = $this->core_font_row( 'dejavuserifcondensed' ); + $this->assertSame( $hashed, $row['regular'], 'The hashed copy is the family\'s file' ); + $this->assertSame( filesize( $this->font_fixture( 'DejaVuSerifCondensed.ttf' ) ), $row['file_sizes']['regular'] ); + + $result = $this->downloader()->delete_downloaded_font( 'dejavuserifcondensed' ); + + $this->assertSame( [ 'DejaVuSerifCondensed.ttf' ], $result['removed'] ); + $this->assertSame( filesize( $this->font_fixture( 'DejaVuSerifCondensed.ttf' ) ), $result['bytes'] ); + $this->assertFileDoesNotExist( $this->font_path( $hashed ) ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + } + + public function test_a_family_sharing_a_name_with_a_record_lists_and_deletes_only_its_hashed_copy() { + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + $hashed = $sources->get_hashed_name( 'DejaVuSerifCondensed.ttf' ); + + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSerifCondensed.ttf' ); + $this->add_record( 'mine', [ 'regular' => 'DejaVuSerifCondensed.ttf' ] ); + + $this->assertSame( 'available', $this->core_font_row( 'dejavuserifcondensed' )['type'], 'The record\'s file is not the family\'s' ); + + $this->copy_fixture( 'DejaVuSerifCondensed.ttf', $hashed ); + + $this->assertSame( $hashed, $this->core_font_row( 'dejavuserifcondensed' )['regular'] ); + + $result = $this->downloader()->delete_downloaded_font( 'dejavuserifcondensed' ); + + $this->assertSame( [ $hashed ], $result['removed'] ); + $this->assertFileDoesNotExist( $this->font_path( $hashed ) ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + } + + public function test_deleting_a_record_leaves_the_hashed_copy() { + $hashed = GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( 'DejaVuSerifCondensed.ttf' ); + + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSerifCondensed.ttf' ); + $this->copy_fixture( 'DejaVuSerifCondensed.ttf', $hashed ); + $this->add_record( 'mine', [ 'regular' => 'DejaVuSerifCondensed.ttf' ] ); + + $this->assertTrue( GPDFAPI::delete_pdf_font( 'mine' ) ); + $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertFileExists( $this->font_path( $hashed ) ); + } + public function test_delete_clears_the_familys_failures_and_metrics() { $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); $this->set_font_failures( @@ -502,6 +553,10 @@ private function assert_font_error( $result, string $code, int $status ): void { $this->assertSame( [ 'status' => $status ], $result->get_error_data() ); } + private function core_font_row( string $key ): array { + return array_column( $this->downloader()->get_installed_fonts(), null, 'id' )[ $key ]; + } + private function downloader( ?Model_Font_Sources $sources = null ): Seamed_Font_Downloader { $this->log = new TestHandler(); diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php index 0c07c6566..24427c663 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php @@ -76,6 +76,7 @@ public function test_download_saves_a_verified_font() { public function test_install_downloads_the_missing_files_despite_a_back_off() { foreach ( [ 'Bold', 'Italic', 'BoldItalic' ] as $style ) { $this->copy_fixture( 'Chewy.ttf', 'DejaVuSerifCondensed-' . $style . '.ttf' ); + $this->list_fixture_as( 'Chewy.ttf', 'DejaVuSerifCondensed-' . $style . '.ttf' ); } $this->set_back_off( self::FONT ); $this->serve_font_downloads(); @@ -420,6 +421,147 @@ public function test_the_kill_switch_constant_turns_downloads_off() { $this->assertSame( [], $this->requested ); } + public function test_download_saves_the_font_under_its_hashed_name_when_another_font_holds_the_name() { + $this->put_other_font_at( self::FONT ); + $this->serve_font_downloads(); + $hashed = GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ); + + $this->assertTrue( $this->downloader->download( self::FONT ) ); + $this->assertFileEquals( $this->font_fixture( self::FONT ), $this->font_path( $hashed ) ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_path( self::FONT ), 'The other font is left alone' ); + $this->assertSame( $this->font_path( $hashed ), $this->downloader->get_font_path( self::FONT ) ); + $this->assertCount( 1, $this->log_messages( 'Font saved under a new name as another font has its name' ) ); + $this->assertNoStrayTmpFiles(); + + /* The next request finds the copy and asks for nothing */ + $this->requested = []; + $this->assertTrue( $this->new_downloader()->download( self::FONT ) ); + $this->assertSame( [], $this->requested ); + } + + public function test_saving_a_hashed_copy_removes_the_copy_of_an_older_release_but_not_a_lookalike() { + $old = 'DejaVuSerifCondensed-' . substr( (string) key( $this->fixture_blob( 'DejaVuSans.ttf' ) ), 0, 8 ) . '.ttf'; + + $this->put_other_font_at( self::FONT ); + $this->copy_fixture( 'DejaVuSans.ttf', $old ); + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSerifCondensed-deadbeef.ttf' ); + $this->serve_font_downloads(); + + $this->assertTrue( $this->downloader->download( self::FONT ) ); + $this->assertFileDoesNotExist( $this->font_path( $old ) ); + $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed-deadbeef.ttf' ), 'Its name is not its own hash' ); + $this->assertFileExists( $this->font_path( GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ) ) ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_path( self::FONT ) ); + } + + public function test_the_hashed_name_is_the_stem_the_first_eight_of_the_sha_and_the_extension() { + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + $sha = $sources->get_manifest()[ self::FONT ]['sha']; + + $this->assertSame( 'DejaVuSerifCondensed-' . substr( $sha, 0, 8 ) . '.ttf', $sources->get_hashed_name( self::FONT ) ); + $this->assertSame( '', $sources->get_hashed_name( 'NotAFont.ttf' ) ); + $this->assertTrue( $sources->is_hashed_copy( $sources->get_hashed_name( self::FONT ) ) ); + $this->assertFalse( $sources->is_hashed_copy( self::FONT ) ); + $this->assertFalse( $sources->is_hashed_copy( 'DejaVuSerifCondensed-00000000.ttf' ) ); + } + + public function test_another_fonts_file_stays_when_downloads_are_off() { + $this->put_other_font_at( self::FONT ); + add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); + $this->serve_font_downloads(); + + $this->assertFalse( $this->downloader->download( self::FONT ) ); + $this->assertSame( [], $this->requested ); + $this->assertSame( $this->font_path( self::FONT ), $this->downloader->get_font_path( self::FONT ) ); + $this->assertTrue( GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->is_downloaded( self::FONT ), 'mPDF can still load it' ); + } + + public function test_a_failed_download_beside_another_font_is_recorded_under_the_manifest_name() { + $this->put_other_font_at( self::FONT ); + $this->fail_font_downloads(); + + $this->assertFalse( $this->downloader->download( self::FONT ) ); + $this->assertSame( [ self::FONT ], array_keys( $this->get_font_failures() ) ); + $this->assertIsInt( $this->get_back_off( self::FONT ) ); + $this->assertFileEquals( $this->font_fixture( 'Chewy.ttf' ), $this->font_path( self::FONT ) ); + } + + public function test_a_file_of_a_known_size_is_not_hashed_unless_verifying() { + $this->put_same_size_font_at( self::FONT, self::FONT ); + $this->serve_font_downloads(); + $downloader = $this->seamed( [] ); + + $this->assertTrue( $downloader->download( self::FONT ) ); + $this->assertSame( [], $downloader->hashed ); + $this->assertSame( [], $this->requested ); + + /* Verifying finds that it isn't the manifest's */ + $this->assertTrue( $downloader->download( self::FONT, true ) ); + $this->assertSame( [ self::FONT ], $downloader->hashed ); + $this->assertFileEquals( $this->font_fixture( self::FONT ), $this->font_path( GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ) ) ); + } + + public function test_a_file_from_an_older_release_is_kept_when_verifying() { + $this->copy_fixture( 'DejaVuSerifCondensed-v1.0.0.ttf', self::FONT ); + $this->serve_font_downloads(); + + /* The real list of what the installer downloaded, not the fixtures set_up() stands in for it */ + $this->set_private( GPDFAPI::get_mvc_class( 'Model_Font_Sources' ), 'legacy_manifests', [] ); + + $this->assertTrue( $this->downloader->download( self::FONT, true ) ); + $this->assertSame( [], $this->requested ); + } + + public function test_sync_hashes_each_required_file_on_disk_once_and_saves_the_other_fonts_under_new_names() { + $this->copy_fixture( self::FONT ); + $this->put_same_size_font_at( 'DejaVuSansCondensed.ttf', 'DejaVuSansCondensed.ttf' ); + $this->serve_font_downloads(); + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + + $downloader = $this->seamed_sync( [ self::FONT, 'DejaVuSansCondensed.ttf' ] ); + $downloader->sync(); + + $this->assertEqualsCanonicalizing( [ self::FONT, 'DejaVuSansCondensed.ttf' ], $downloader->hashed ); + $this->assertCount( 1, $this->requested ); + $this->assertFileExists( $this->font_path( $sources->get_hashed_name( 'DejaVuSansCondensed.ttf' ) ) ); + $this->assertFileDoesNotExist( $this->font_path( $sources->get_hashed_name( self::FONT ) ) ); + + /* A file with a copy under its hashed name is not hashed again */ + $next = $this->seamed_sync( [ self::FONT, 'DejaVuSansCondensed.ttf' ] ); + $next->sync(); + + $this->assertSame( [ self::FONT ], $next->hashed ); + $this->assertCount( 1, $this->requested ); + } + + public function test_a_retry_for_a_file_beside_another_font_is_not_cleared() { + $this->put_other_font_at( self::FONT ); + $this->set_font_failures( [ self::FONT => $this->failure() ] ); + $this->set_back_off( self::FONT ); + $this->serve_font_downloads(); + + $this->seamed_sync( [] )->sync(); + + $this->assertFileExists( $this->font_path( GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ) ) ); + $this->assertEmpty( $this->get_font_failures() ); + } + + public function test_prune_never_removes_a_hashed_copy_or_a_replaced_file() { + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + $hashed = $sources->get_hashed_name( self::FONT ); + + /* The unmodified installer file goes, beside the manifest's copy of it */ + $this->copy_fixture( self::FONT ); + $this->copy_fixture( self::FONT, $hashed ); + $this->copy_fixture( 'Chewy.ttf', 'DejaVuSans.ttf' ); + $this->copy_fixture( 'DejaVuSans.ttf', $sources->get_hashed_name( 'DejaVuSans.ttf' ) ); + + $this->assertSame( [ self::FONT ], $this->downloader->prune( [] ) ); + $this->assertFileExists( $this->font_path( $hashed ) ); + $this->assertFileExists( $this->font_path( 'DejaVuSans.ttf' ), 'A replacement' ); + $this->assertFileExists( $this->font_path( $sources->get_hashed_name( 'DejaVuSans.ttf' ) ) ); + } + public function test_prune_removes_unmodified_core_fonts_and_licences() { $this->set_installer_manifest( array_combine( static::$core_fixtures, static::$core_fixtures ) + [ 'DejaVuinfo.txt' => 'chewy-LICENSE.txt' ] ); $this->copy_fixture( 'chewy-LICENSE.txt', 'DejaVuinfo.txt' ); @@ -835,6 +977,7 @@ public function test_sync_downloads_the_required_set_as_it_is_when_it_runs() { public function test_sync_skips_files_already_on_disk() { $this->copy_fixture( 'Chewy.ttf', 'Garuda.ttf' ); + $this->list_fixture_as( 'Chewy.ttf', 'Garuda.ttf' ); $this->serve_font_downloads(); $this->seamed_sync( [ 'Garuda.ttf', self::FONT ] )->sync(); @@ -843,6 +986,39 @@ public function test_sync_skips_files_already_on_disk() { $this->assertSame( [], $this->get_scheduled_args() ); } + public function test_a_file_of_another_size_at_the_hashed_name_is_not_the_fonts_copy() { + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + + $this->copy_fixture( self::FONT, self::FONT ); + $this->copy_fixture( 'Chewy.ttf', $sources->get_hashed_name( self::FONT ) ); + + $this->assertFalse( $sources->has_hashed_copy( self::FONT ) ); + $this->assertSame( $this->font_path( self::FONT ), $sources->get_font_path( self::FONT ) ); + } + + public function test_only_a_manifest_files_hashed_form_is_a_reserved_name() { + $sources = GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + + $this->assertTrue( $sources->is_reserved_name( $sources->get_hashed_name( self::FONT ) ) ); + $this->assertTrue( $sources->is_reserved_name( strtoupper( $sources->get_hashed_name( self::FONT ) ) ) ); + $this->assertTrue( $sources->is_reserved_name( pathinfo( self::FONT, PATHINFO_FILENAME ) . '-00000000.ttf' ) ); + $this->assertFalse( $sources->is_reserved_name( self::FONT ) ); + $this->assertFalse( $sources->is_reserved_name( 'Mine-00000000.ttf' ) ); + } + + public function test_a_sync_downloads_missing_files_before_hashing_those_on_disk() { + $this->copy_fixture( 'Chewy.ttf', 'Garuda.ttf' ); + $this->list_fixture_as( 'Chewy.ttf', 'Garuda.ttf' ); + $this->serve_font_downloads(); + + $downloader = $this->seamed_sync( [ 'Garuda.ttf', self::FONT ], 0 ); + $downloader->sync(); + + $this->assertFileExists( $this->font_path( self::FONT ) ); + $this->assertSame( [], $downloader->hashed ); + $this->assertSame( [ 'Garuda.ttf' ], get_transient( Model_Font_Downloader::SYNC_REMAINING ) ); + } + public function test_a_paused_sync_leaves_the_rest_for_its_next_run() { $this->serve_fixture_as( 'Chewy.ttf', 'Garuda.ttf' ); $this->serve_font_downloads(); diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php index 43b24c111..ce381e7a1 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php @@ -6,6 +6,7 @@ use GFPDF\Exceptions\GravityPdfIdException; use GFPDF\Tests\Concerns\CopiesFontFixtures; +use GFPDF\Tests\Concerns\Seamed_Font_Downloader; use GFPDF\Tests\Concerns\UnregistersMvcClasses; use GFPDF\Tests\Concerns\StubsFontSources; use GFPDF\Tests\Integration\TestCase; @@ -1006,6 +1007,176 @@ protected function assert_record_kept( array $adopted ): void { $this->assertTrue( $this->log->hasRecordThatContains( 'Could not remove a custom font record replaced by a font source', Logger::ERROR ) ); } + public function test_two_sources_using_one_name_for_different_files_give_the_later_one_the_hashed_name() { + $first = [ 'Shared.ttf' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Shared.ttf' ) ]; + $second = [ + 'Shared.ttf' => $this->manifest_entry( 'DejaVuSans.ttf', 'https://example.com/DejaVuSans.ttf' ), + 'Only.ttf' => $this->manifest_entry( 'DejaVuSans.ttf', 'https://example.com/Only.ttf' ), + ]; + $model = $this->get_model_with_two_sources( $first, $second ); + $hashed = 'Shared-' . substr( $second['Shared.ttf']['sha'], 0, 8 ) . '.ttf'; + + $sources = $model->get_sources(); + + $this->assertSame( [ 'Shared.ttf' ], array_keys( $model->get_source_manifest( 'bundled' ) ) ); + $this->assertSame( [ $hashed, 'Only.ttf' ], array_keys( $model->get_source_manifest( 'core' ) ) ); + $this->assertSame( [ 'Shared.ttf', $hashed, 'Only.ttf' ], array_keys( $model->get_manifest() ) ); + $this->assertSame( $first['Shared.ttf'], $model->get_manifest()['Shared.ttf'] ); + $this->assertSame( $second['Shared.ttf'], $model->get_manifest()[ $hashed ] ); + + /* Only the later source's family files are rewritten, and only the ones renamed */ + $this->assertSame( 'Shared.ttf', $sources['bundled']['families']['first']['R'] ); + $this->assertSame( $hashed, $sources['core']['families']['second']['R'] ); + $this->assertSame( 'Only.ttf', $sources['core']['families']['second']['B'] ); + $this->assertSame( $hashed, $model->register_font_data( [] )['second']['R'] ); + + $this->assertTrue( $this->log->hasRecordThatContains( 'Font file name used by two sources for different files', Logger::NOTICE ) ); + } + + public function test_a_family_uses_the_hashed_name_of_a_file_a_font_manager_record_uses() { + $this->put_longer_font_at( 'DejaVuSans.ttf', 'DejaVuSans.ttf' ); + $this->add_record( 'mine', [ 'regular' => 'DejaVuSans.ttf' ] ); + $hashed = $this->model->get_hashed_name( 'DejaVuSans.ttf' ); + + $fonts = $this->model->register_font_data( [] ); + + $this->assertSame( $hashed, $fonts['dejavusans']['R'] ); + $this->assertSame( 'DejaVuSans-Bold.ttf', $fonts['dejavusans']['B'], 'Only the shared file is renamed' ); + $this->assertSame( [ 'DejaVuSans.ttf' => $hashed ], $this->model->get_record_collisions() ); + $this->assertTrue( $this->model->is_record_file( 'DejaVuSans.ttf' ) ); + $this->assertArrayHasKey( $hashed, $this->model->get_manifest() ); + $this->assertArrayHasKey( $hashed, $this->model->get_source_manifest( 'core' ) ); + $this->assertTrue( $this->model->is_core_font_file( $hashed ) ); + } + + public function test_a_record_holding_a_known_build_of_the_core_font_is_not_custom() { + $this->copy_fixture( 'DejaVuSans.ttf' ); + $this->add_record( 'mine', [ 'regular' => 'DejaVuSans.ttf' ] ); + + $this->assertSame( [], $this->model->get_record_collisions() ); + $this->assertSame( 'DejaVuSans.ttf', $this->model->register_font_data( [] )['dejavusans']['R'] ); + } + + public function test_a_record_on_another_site_is_only_a_collision_on_that_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $this->put_longer_font_at( 'DejaVuSans.ttf', 'DejaVuSans.ttf' ); + $blog = self::factory()->blog->create(); + + switch_to_blog( $blog ); + try { + $this->add_record( 'mine', [ 'regular' => 'DejaVuSans.ttf' ] ); + $this->assertSame( [ 'DejaVuSans.ttf' ], array_keys( $this->model->get_record_collisions() ) ); + GPDFAPI::delete_plugin_option( 'custom_fonts' ); + } finally { + restore_current_blog(); + } + + $this->model->flush(); + $this->assertSame( [], $this->model->get_record_collisions() ); + } + + public function test_a_record_whose_file_is_not_a_core_name_reads_no_manifest() { + $this->copy_fixture( 'Chewy.ttf' ); + $this->add_record( 'chewy', [ 'regular' => 'Chewy.ttf' ] ); + $this->set_private( $this->model, 'manifests', [] ); + + $this->assertSame( [], $this->model->get_record_collisions() ); + $this->assertSame( [], $this->get_private( $this->model, 'manifests' ) ); + } + + public function test_a_renamed_file_is_not_renamed_again() { + $model = $this->get_model_with_two_sources( + [ 'Shared.ttf' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Shared.ttf' ) ], + [ 'Shared.ttf' => $this->manifest_entry( 'DejaVuSans.ttf', 'https://example.com/DejaVuSans.ttf' ) ] + ); + + $renamed = array_keys( $model->get_source_manifest( 'core' ) )[0]; + + $this->assertSame( '', $model->get_hashed_name( $renamed ) ); + $this->assertFalse( $model->is_size_conflict( $renamed ) ); + } + + public function test_two_sources_using_one_name_for_the_same_file_share_it() { + $entry = $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Shared.ttf' ); + $model = $this->get_model_with_two_sources( [ 'Shared.ttf' => $entry ], [ 'Shared.ttf' => $entry ] ); + + $sources = $model->get_sources(); + + $this->assertSame( [ 'Shared.ttf' ], array_keys( $model->get_manifest() ) ); + $this->assertSame( 'Shared.ttf', $sources['core']['families']['second']['R'] ); + $this->assertFalse( $this->log->hasRecordThatContains( 'Font file name used by two sources for different files', Logger::NOTICE ) ); + } + + public function test_a_single_downloadable_source_is_not_read_when_the_sources_are_built() { + $model = new Model_Font_Sources( GPDFAPI::get_data_class(), $this->custom_fonts, GPDFAPI::get_log_class() ); + $model->get_sources(); + + $this->assertSame( [], $this->get_private( $model, 'manifests' ) ); + $this->assertNull( $this->get_private( $model, 'manifest' ) ); + } + + public function test_the_renamed_source_downloads_to_its_hashed_name() { + $model = $this->get_model_with_two_sources( [ 'DejaVuSerifCondensed.ttf' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Chewy.ttf' ) ], [] ); + $core = $model->get_source_manifest( 'core' ); + + /* The later source is the real core manifest, so its file takes the hashed name */ + $name = 'DejaVuSerifCondensed-' . substr( $this->manifest_entry( 'DejaVuSerifCondensed.ttf', '' )['sha'], 0, 8 ) . '.ttf'; + $this->assertArrayHasKey( $name, $core ); + $this->assertSame( $name, $model->get_sources()['core']['families']['dejavuserifcondensed']['R'] ); + + $this->serve_font_downloads(); + $this->copied_fonts[] = $this->font_dir . $name; + $downloader = new Seamed_Font_Downloader( GPDFAPI::get_log_class(), [ 'sources' => $model ] ); + + $this->assertTrue( $downloader->download( $name ) ); + $this->assertFileEquals( $this->font_fixture( 'DejaVuSerifCondensed.ttf' ), $this->font_dir . $name ); + $this->assertFileDoesNotExist( $this->font_dir . 'DejaVuSerifCondensed.ttf' ); + $this->assertSame( [ $core[ $name ]['url'] ], $this->requested ); + } + + public function test_a_file_is_another_fonts_when_its_size_is_no_known_build() { + $model = $this->get_model_with_manifest( [ 'Mine.ttf' => $this->manifest_entry( 'DejaVuSans.ttf', 'https://example.com/Mine.ttf' ) ] ); + + $this->assertFalse( $model->is_size_conflict( 'Mine.ttf' ), 'Missing' ); + $this->assertFalse( $model->is_installed( 'Mine.ttf' ) ); + + $this->copy_fixture( 'DejaVuSans.ttf', 'Mine.ttf' ); + $this->assertFalse( $model->is_size_conflict( 'Mine.ttf' ) ); + $this->assertTrue( $model->is_installed( 'Mine.ttf' ) ); + + $this->copy_fixture( 'Chewy.ttf', 'Mine.ttf' ); + $this->assertTrue( $model->is_size_conflict( 'Mine.ttf' ) ); + $this->assertFalse( $model->is_installed( 'Mine.ttf' ) ); + $this->assertTrue( $model->is_downloaded( 'Mine.ttf' ), 'The file at its name' ); + + $this->copy_fixture( 'DejaVuSans.ttf', $model->get_hashed_name( 'Mine.ttf' ) ); + $this->assertTrue( $model->is_installed( 'Mine.ttf' ) ); + $this->assertSame( $this->font_dir . $model->get_hashed_name( 'Mine.ttf' ), $model->get_font_path( 'Mine.ttf' ) ); + } + + public function test_an_older_build_of_a_file_has_a_known_size() { + $model = $this->get_model_with_manifest( [ 'Mine.ttf' => $this->manifest_entry( 'DejaVuSans.ttf', 'https://example.com/Mine.ttf' ) ] ); + $this->set_private( $model, 'legacy_manifests', [ 'core' => [ 'Mine.ttf' => $this->manifest_entry( 'Chewy.ttf', 'https://example.com/Mine.ttf' ) ] ] ); + + $this->copy_fixture( 'Chewy.ttf', 'Mine.ttf' ); + + $this->assertFalse( $model->is_size_conflict( 'Mine.ttf' ) ); + $this->assertTrue( $model->is_installed( 'Mine.ttf' ) ); + $this->assertCount( 2, $model->get_known_builds( 'Mine.ttf' ) ); + } + + public function test_adoption_checks_the_records_file_not_a_hashed_copy() { + $model = $this->get_model_with_legacy( 'Chewy.ttf' ); + + $this->copy_fixture( 'Chewy.ttf', 'Arimo-Regular.ttf' ); + $this->add_record( 'arimo', [ 'regular' => 'Arimo-Regular.ttf' ] ); + + $this->assertSame( [ 'arimo' ], $model->adopt_records( 'bundled' ) ); + } + /** * A model whose core source reads a test manifest * @@ -1035,4 +1206,58 @@ protected function get_core_source(): array { protected function core_fontdata(): array { return ( new FontVariables() )->getDefaults()['fontdata']; } + + /** + * A model whose first source (`bundled`) and later source (`core`) each have a manifest of the entries given, and a + * family using each file. With no entries for the second, `core` is the real one. + * + * @param array $first + * @param array $second + */ + protected function get_model_with_two_sources( array $first, array $second ): Model_Font_Sources { + $write = function ( string $suffix, array $entries ): string { + $path = $this->manifest_path . $suffix . '.php'; + wp_delete_file( $path ); + file_put_contents( $path, 'copied_fonts[] = $path; + + return $path; + }; + + $model = new class( GPDFAPI::get_data_class(), $this->custom_fonts, GPDFAPI::get_log_class() ) extends Model_Font_Sources { + /** @var string[] The first source's and second source's manifests */ + public $manifests_at = []; + + /** @var bool Whether the second source is the real core one */ + public $real_core = false; + + protected function get_bundled_source(): array { + return [ + 'families' => [ 'first' => [ 'R' => 'Shared.ttf' ] ], + 'manifest' => $this->manifests_at[0], + ]; + } + + protected function get_core_source(): array { + if ( $this->real_core ) { + return parent::get_core_source(); + } + + return [ + 'families' => [ + 'second' => [ + 'R' => 'Shared.ttf', + 'B' => 'Only.ttf', + ], + ], + 'manifest' => $this->manifests_at[1], + ]; + } + }; + + $model->manifests_at = [ $write( '-first', $first ), $second === [] ? '' : $write( '-second', $second ) ]; + $model->real_core = $second === []; + + return $model; + } } diff --git a/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php b/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php index e273e93a6..68a38b6f7 100644 --- a/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php +++ b/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php @@ -275,6 +275,35 @@ public function test_installed_counts_are_the_required_files_per_downloadable_so $this->assertSame( 1, $this->required->get_installed_counts()['core']['installed'] ); } + public function test_another_fonts_file_at_a_name_is_not_an_installed_file() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavuserifcondensed' ); + $this->put_other_font_at( self::FONT ); + + $this->assertSame( 0, $this->required->get_installed_counts()['core']['installed'] ); + $this->assertTrue( $this->required->needs_download( 'dejavuserifcondensed' ) ); + $this->assertTrue( $this->required->pdf_needs_download( [ 'font' => 'dejavuserifcondensed' ] ) ); + + /* Its copy under the hashed name is */ + $this->copy_fixture( self::FONT, GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ) ); + + $this->assertSame( 1, $this->required->get_installed_counts()['core']['installed'] ); + } + + public function test_a_family_sharing_a_name_with_a_record_requires_its_hashed_file() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavuserifcondensed' ); + $this->put_other_font_at( self::FONT ); + $this->add_record( 'mine', [ 'regular' => self::FONT ] ); + $hashed = GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( self::FONT ); + + $this->assertContains( $hashed, $this->required->get_required_font_files() ); + $this->assertNotContains( self::FONT, $this->required->get_required_font_files() ); + $this->assertSame( [ 'installed' => 0, 'total' => 4, 'manifest' => true ], $this->required->get_installed_counts()['core'] ); + + $this->copy_fixture( self::FONT, $hashed ); + + $this->assertSame( 1, $this->required->get_installed_counts()['core']['installed'] ); + } + public function test_a_source_with_a_missing_manifest_is_flagged() { $this->assertSame( [ diff --git a/tests/phpunit/integration/Model/Test_PDF.php b/tests/phpunit/integration/Model/Test_PDF.php index 873bbbf37..1a160edc3 100644 --- a/tests/phpunit/integration/Model/Test_PDF.php +++ b/tests/phpunit/integration/Model/Test_PDF.php @@ -1798,6 +1798,22 @@ public function test_add_unregistered_fonts_to_mPDF() { $this->assertArrayNotHasKey( 'aladin', $fonts ); } + public function test_a_core_fonts_hashed_copy_is_not_autoloaded_as_a_font() { + global $gfpdf; + + $hashed = GPDFAPI::get_mvc_class( 'Model_Font_Sources' )->get_hashed_name( 'DejaVuSans.ttf' ); + $path = $gfpdf->data->template_font_location . $hashed; + touch( $path ); + + try { + $fonts = $this->model->add_unregistered_fonts_to_mPDF( [] ); + } finally { + wp_delete_file( $path ); + } + + $this->assertNotContains( $hashed, array_column( $fonts, 'R' ) ); + } + /** * Test that our field exists * diff --git a/tests/phpunit/integration/Test_Font_Rendering.php b/tests/phpunit/integration/Test_Font_Rendering.php index bc7db7fc1..9197725c0 100644 --- a/tests/phpunit/integration/Test_Font_Rendering.php +++ b/tests/phpunit/integration/Test_Font_Rendering.php @@ -123,6 +123,7 @@ public function provider_substitutes(): array { public function test_sun_exta_on_disk_is_substituted_from_without_a_download(): void { /* Sun-ExtA's font data needs no OpenType tables, so any fixture stands in; Chewy has no CJK */ $this->copy_fixture( 'Chewy.ttf', 'Sun-ExtA.ttf' ); + $this->list_fixture_as( 'Chewy.ttf', 'Sun-ExtA.ttf' ); FlushCache::flush(); $this->fail_font_downloads(); @@ -150,6 +151,7 @@ public function test_a_cjk_extension_b_character_does_not_download_sun_extb(): v public function test_sun_extb_on_disk_is_the_cjk_extension_b_fallback_without_a_download(): void { /* Sun-ExtB's font data needs no OpenType tables, so any fixture stands in */ $this->copy_fixture( 'Chewy.ttf', 'Sun-ExtB.ttf' ); + $this->list_fixture_as( 'Chewy.ttf', 'Sun-ExtB.ttf' ); FlushCache::flush(); $this->fail_font_downloads(); @@ -192,67 +194,78 @@ public function test_an_adopted_template_arimo_record_renders_the_bundled_faces( $this->assertRenderMatchesSnapshot( 'adopted-arimo', '

Adopted

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

Regular Bold

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

Regular Bold

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

สวัสดี

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

Regular

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

Thai สวัสดี

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

Regular

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

Fallback

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

Custom

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

Core

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

สวัสดี

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

Regular

', [ 'font' => 'dejavusans' ] ); - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertNull( $render ); - $this->assertTrue( - $log->hasRecordThatPasses( - static function ( $record ) { - return strpos( $record['context']['exception'] ?? '', 'Cannot find TTF TrueType font file' ) !== false; - }, - Logger::ERROR - ) - ); + $this->assertSame( 'DejaVuSans.ttf', basename( $render['mpdf']->fonts['dejavusans']['ttffile'] ) ); + $this->assertCount( 1, $this->requested ); } /**