From 37b7e8cfde55b973b422f69e4897589d68b97695 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 9 Nov 2026 10:07:08 +1100 Subject: [PATCH] List custom, core and bundled fonts together in the Font Manager On-demand downloading makes deleting a core font safe, as one a PDF needs comes back on its own, but the Font Manager only listed custom fonts, so a downloaded font could only be removed by hand from the fonts folder. There is now one list that the search box filters: custom fonts, downloadable fonts with files on disk and listed bundled fonts. Following the 7.0 Font Manager design for what 6.19 can already do, fonts sit in a sidebar with the selected font first and Custom, Bundled and Core groups below it. Clicking a font opens its detail: bundled and core fonts are read-only, custom fonts open the update form. Each shows its styles, size, what's using it and a live preview, and links to the settings of the PDFs using it. Uploads are rows rather than tiles, deleting asks in a dialog, and the actions sit in a bar pinned to the bottom of the pane. A preview is a whole TTF per style, so it waits until it has been on screen for a moment, as moving to another font doesn't cancel a download, and shows one style until another is picked from its dropdown. Anything over 4 MB, such as Sun-ExtA at 22 MB, waits for a click on Load preview, using the per-style file_sizes now on each row. Text uses font-display: swap rather than staying blank while the files load, with a spinner beside each style until its file has, or a note if it couldn't load. Sun-ExtA isn't offered a preview, as its vhea table claims more metrics than it has glyphs and browsers reject the file. A fonts folder moved with gfpdf_font_location has its path mapped to a URL, and the preview says it's unavailable when there isn't one. The route now decides which panel shows, rather than a class toggled on the DOM, so browser Back no longer leaves the update form open. That removes the slide-in panel's height and tab index workarounds. GET /fonts/?scope=all returns every row and GET /fonts/ stays custom-only by default so existing consumers keep working. Working out what's in use walks every form, and on Multisite every site, so GET /fonts/?scope=usage serves `reasons`, `pdfs_label` and `pdfs` separately: rows appear at once and Delete enables when the reasons arrive. Saved custom fonts return the same row as the list. A font in use can't be deleted, custom fonts included: DELETE /fonts/{id} now returns 409 for one, though not GPDFAPI::delete_pdf_font(), which the upgrade uses to remove records a font source now serves. DELETE /fonts/{id}?scope=core returns 409 when in use and 404 for a bundled, unknown or absent family. It removes the family's manifest files whatever their hash (an explicit action, unlike the upgrade prune), keeps the licence files, clears the files' failure and back-off entries, flushes the family's metrics and logs what it removed. With downloads turned off the confirm says the font won't come back. Core fonts follow the order of the font dropdown. Those with nothing on disk are listed as available to install, unless downloads are off or they have no label, so can't be chosen for a PDF. POST /fonts/{id}?scope=core downloads a family's missing files whatever their back-off, and returns its row. On Multisite only super admins can, as with delete. Deleting returns the row too, so a font that can be installed again goes back to the available group. With nothing on the site to preview, an available font's preview loads from its manifest URL. Core rows also carry the languages and scripts each font supports, worked out from the characters in its file, and a sample in its own script for fonts whose Latin letters wouldn't show what they're for. The in-use reasons come from Model_Required_Fonts::get_font_reasons(), which get_required_font_files() is now built on, so the list and the background sync share one walk of the forms, which now pages by ID as OFFSET ran its LIKE over every form it skipped. On Multisite the fonts folder is shared, so only super admins can delete core fonts, and "in use" also counts every other site's default font, locale font and PDF fonts, though a site's custom fonts are its own. Those come from Model_Required_Fonts::get_site_font_keys(), which reads a site's tables by prefix rather than switch_to_blog() so no other site's plugins or filters run. One options query per site also says whether Gravity Forms has run there (gf_db_version), so no site pays a separate table check. Only super admins walk the other sites for the usage list, as only they can delete core fonts. The form scan's LIKE compares bytes, as the column's collation made it about 6x slower. The delete check stops at the first thing using the font, so the form reads are generators. All of this is on the existing /fonts/ and /fonts/{id} routes, so there are no new endpoints to maintain. The E2E test copies a core font no other spec uses into the fonts folder, as downloads are off on the E2E site, and puts one in use with an X-GPDF-E2E-Default-Font header rather than a PDF selecting it, which would count it as required in the other specs' system reports. Co-Authored-By: Claude Opus 5.5 --- src/Controller/Controller_Custom_Fonts.php | 152 ++- src/Helper/Helper_Data.php | 76 +- src/Model/Model_Font_Downloader.php | 481 ++++++++++ src/Model/Model_Font_Languages.php | 199 ++++ src/Model/Model_Font_Sources.php | 132 ++- src/Model/Model_Install.php | 6 +- src/Model/Model_Required_Fonts.php | 282 +++++- src/assets/js/react/actions/fontManager.js | 42 +- src/assets/js/react/api/fontManager.js | 149 ++- .../react/components/FontManager/AddFont.js | 43 +- .../FontManager/AddUpdateFontFooter.js | 247 +---- .../components/FontManager/DeleteConfirm.js | 91 ++ .../FontManager/FontDeleteButton.js | 104 +++ .../components/FontManager/FontDetail.js | 129 +++ .../FontManager/FontInstallButton.js | 64 ++ .../react/components/FontManager/FontList.js | 3 - .../components/FontManager/FontListHeader.js | 27 - .../components/FontManager/FontListIcon.js | 35 - .../components/FontManager/FontListItems.js | 409 +++----- .../FontManager/FontListSkeleton.js | 22 +- .../components/FontManager/FontManager.js | 23 +- .../components/FontManager/FontManagerBody.js | 162 ++-- .../FontManager/FontManagerHeader.js | 17 +- .../components/FontManager/FontPreview.js | 287 ++++++ .../components/FontManager/FontStatus.js | 64 ++ .../react/components/FontManager/FontUsage.js | 88 ++ .../components/FontManager/FontVariant.js | 131 ++- .../FontManager/FontVariantLabel.js | 29 +- .../js/react/components/FontManager/Icon.js | 72 ++ .../react/components/FontManager/SearchBox.js | 4 +- .../components/FontManager/TemplateTooltip.js | 13 +- .../components/FontManager/UpdateFont.js | 82 +- .../js/react/reducers/fontManagerReducer.js | 214 +++-- src/assets/js/react/sagas/fontManager.js | 92 +- src/assets/js/react/sagas/index.js | 4 + .../FontManager/adjustFontListHeight.js | 19 - .../associatedFontManagerSelectBox.js | 39 +- .../FontManager/fontManagerReducer.js | 93 +- .../utilities/FontManager/toggleUpdateFont.js | 44 +- .../FontManager/_font-manager-skeleton.scss | 22 +- .../Component/FontManager/_font-manager.scss | 880 +++++++++++++++--- .../scss/Component/FontManager/_footer.scss | 146 ++- src/assets/scss/MediaQueries/_min-1200.scss | 56 -- src/assets/scss/MediaQueries/_min-961.scss | 33 +- .../FontManager/_rtl-font-manager.scss | 31 - .../scss/RTL/MediaQueries/_rtl-min-1200.scss | 45 - .../scss/RTL/MediaQueries/_rtl-min-961.scss | 22 - src/assets/scss/_base.scss | 9 + src/assets/scss/gfpdf-styles.scss | 4 - .../js-unit/react/actions/fontManager.test.js | 8 - .../components/FontManager/AddFont.test.js | 5 +- .../FontManager/AddUpdateFontFooter.test.js | 86 +- .../FontManager/DeleteConfirm.test.js | 60 ++ .../FontManager/FontDeleteButton.test.js | 79 ++ .../components/FontManager/FontDetail.test.js | 101 ++ .../FontManager/FontInstallButton.test.js | 40 + .../components/FontManager/FontList.test.js | 5 - .../FontManager/FontListHeader.test.js | 25 - .../FontManager/FontListIcon.test.js | 30 - .../FontManager/FontListItems.test.js | 401 ++++---- .../FontManager/FontManagerBody.test.js | 54 +- .../FontManager/FontPreview.test.js | 363 ++++++++ .../components/FontManager/FontStatus.test.js | 33 + .../components/FontManager/FontUsage.test.js | 39 + .../FontManager/FontVariant.test.js | 76 +- .../FontManager/FontVariantLabel.test.js | 5 +- .../FontManager/TemplateTooltip.test.js | 11 +- .../components/FontManager/UpdateFont.test.js | 6 +- .../react/reducers/fontManagerReducer.test.js | 147 +-- tests/js-unit/react/sagas/fontManager.test.js | 96 +- .../associatedFontManagerSelectBox.test.js | 26 + .../FontManager/toggleUpdateFont.test.js | 71 +- tests/js-unit/setupTests.js | 32 +- .../Test_Controller_Custom_Fonts.php | 45 +- .../Test_Controller_Font_Downloader.php | 100 ++ .../integration/Helper/Test_Helper_Data.php | 20 + .../Model/Test_Downloaded_Fonts.php | 542 +++++++++++ .../integration/Model/Test_Installer.php | 16 + .../Model/Test_Model_Font_Downloader.php | 55 ++ .../Model/Test_Model_Required_Fonts.php | 148 +++ .../core/managers/font-manager.spec.ts | 225 ++++- tools/mu-plugins/gravitypdf.php | 11 + 82 files changed, 6325 insertions(+), 2054 deletions(-) create mode 100644 src/Model/Model_Font_Languages.php create mode 100644 src/assets/js/react/components/FontManager/DeleteConfirm.js create mode 100644 src/assets/js/react/components/FontManager/FontDeleteButton.js create mode 100644 src/assets/js/react/components/FontManager/FontDetail.js create mode 100644 src/assets/js/react/components/FontManager/FontInstallButton.js delete mode 100644 src/assets/js/react/components/FontManager/FontListHeader.js delete mode 100644 src/assets/js/react/components/FontManager/FontListIcon.js create mode 100644 src/assets/js/react/components/FontManager/FontPreview.js create mode 100644 src/assets/js/react/components/FontManager/FontStatus.js create mode 100644 src/assets/js/react/components/FontManager/FontUsage.js create mode 100644 src/assets/js/react/components/FontManager/Icon.js delete mode 100644 src/assets/js/react/utilities/FontManager/adjustFontListHeight.js delete mode 100644 src/assets/scss/RTL/MediaQueries/_rtl-min-1200.scss delete mode 100644 src/assets/scss/RTL/MediaQueries/_rtl-min-961.scss create mode 100644 tests/js-unit/react/components/FontManager/DeleteConfirm.test.js create mode 100644 tests/js-unit/react/components/FontManager/FontDeleteButton.test.js create mode 100644 tests/js-unit/react/components/FontManager/FontDetail.test.js create mode 100644 tests/js-unit/react/components/FontManager/FontInstallButton.test.js delete mode 100644 tests/js-unit/react/components/FontManager/FontListHeader.test.js delete mode 100644 tests/js-unit/react/components/FontManager/FontListIcon.test.js create mode 100644 tests/js-unit/react/components/FontManager/FontPreview.test.js create mode 100644 tests/js-unit/react/components/FontManager/FontStatus.test.js create mode 100644 tests/js-unit/react/components/FontManager/FontUsage.test.js create mode 100644 tests/phpunit/integration/Model/Test_Downloaded_Fonts.php diff --git a/src/Controller/Controller_Custom_Fonts.php b/src/Controller/Controller_Custom_Fonts.php index b5b3b16c2..d8e5c0090 100644 --- a/src/Controller/Controller_Custom_Fonts.php +++ b/src/Controller/Controller_Custom_Fonts.php @@ -18,6 +18,8 @@ use GFPDF\Helper\Helper_Abstract_Options; use GFPDF\Helper\Helper_Data; use GFPDF\Model\Model_Custom_Fonts; +use GFPDF\Model\Model_Font_Downloader; +use GFPDF\Model\Model_Required_Fonts; use GFPDF\Statics\Upload_Size; use GFPDF_Vendor\GravityPdf\Upload\ErrorCode; use GFPDF_Vendor\GravityPdf\Upload\Exception as UploadException; @@ -113,8 +115,17 @@ public function register_endpoints(): void { [ [ 'methods' => WP_REST_Server::READABLE, - 'callback' => [ $this, 'get_all_items' ], + 'callback' => [ $this, 'get_scoped_items' ], 'permission_callback' => Closure::fromCallable( [ $this, 'check_permissions' ] ), + 'args' => [ + 'scope' => [ + 'description' => __( 'Which to return: custom fonts, every font the Font Manager lists, or why fonts are in use', 'gravity-pdf' ), + 'type' => 'string', + 'enum' => [ 'custom', 'all', 'usage' ], + 'default' => 'custom', + 'validate_callback' => 'rest_validate_request_arg', + ], + ], ], [ @@ -138,18 +149,25 @@ public function register_endpoints(): void { '/fonts/(?P[a-z0-9\-]+)', [ 'args' => [ - 'id' => [ + 'id' => [ 'description' => __( 'Unique identifier for the object.', 'default' ), 'type' => 'string', 'validate_callback' => [ $this->model, 'check_font_id_valid' ], 'required' => true, ], + 'scope' => [ + 'description' => __( 'Whether the ID is a custom font or a downloadable core font. POST installs a core font.', 'gravity-pdf' ), + 'type' => 'string', + 'enum' => [ 'custom', 'core' ], + 'default' => 'custom', + 'validate_callback' => 'rest_validate_request_arg', + ], ], [ 'methods' => WP_REST_Server::CREATABLE, - 'callback' => [ $this, 'update_item' ], - 'permission_callback' => Closure::fromCallable( [ $this, 'check_permissions' ] ), + 'callback' => [ $this, 'update_or_install_item' ], + 'permission_callback' => Closure::fromCallable( [ $this, 'check_item_permissions' ] ), 'args' => [ 'label' => [ 'description' => __( 'The font label used for the object', 'gravity-pdf' ), @@ -185,8 +203,8 @@ public function register_endpoints(): void { [ 'methods' => WP_REST_Server::DELETABLE, - 'callback' => [ $this, 'delete_item' ], - 'permission_callback' => Closure::fromCallable( [ $this, 'check_permissions' ] ), + 'callback' => [ $this, 'delete_scoped_item' ], + 'permission_callback' => Closure::fromCallable( [ $this, 'check_item_permissions' ] ), ], ] ); @@ -210,6 +228,44 @@ public function get_all_items(): array { return array_values( $this->model->get_custom_fonts() ); } + /** + * The fonts route's `scope`. Usage is separate from the list as it walks every form, and on Multisite every site. + * + * @return array|object|WP_Error Usage is an object, so none in use is still a JSON map + * + * @since 6.19.0 + */ + public function get_scoped_items( WP_REST_Request $request ) { + $scope = $request->get_param( 'scope' ); + if ( $scope === 'custom' ) { + return $this->get_all_items(); + } + + $downloader = $this->get_downloader(); + if ( ! $downloader instanceof Model_Font_Downloader ) { + return $downloader; + } + + return $scope === 'all' ? $downloader->get_installed_fonts() : (object) $downloader->get_font_usage(); + } + + /** + * The font route's POST: an update, or with the `core` scope an install + * + * @return array|WP_Error + * + * @since 6.19.0 + */ + public function update_or_install_item( WP_REST_Request $request ) { + if ( $request->get_param( 'scope' ) !== 'core' ) { + return $this->update_item( $request ); + } + + $downloader = $this->get_downloader(); + + return $downloader instanceof Model_Font_Downloader ? $downloader->install_font( (string) $request->get_param( 'id' ) ) : $downloader; + } + /** * Our `Create` CRUD for custom fonts * @@ -258,7 +314,7 @@ public function add_item( WP_REST_Request $request ) { FlushCache::flush( $font['id'] ); - return $font; + return $this->get_font_row( $font ); } catch ( UploadException $e ) { $message = $e->getMessage()[0] === '{' ? json_decode( $e->getMessage(), true ) : $e->getMessage(); return new WP_Error( 'font_validation_error', $message, [ 'status' => 400 ] ); @@ -377,7 +433,7 @@ public function update_item( WP_REST_Request $request ) { FlushCache::flush( $font['id'] ); - return $font; + return $this->get_font_row( $font ); } catch ( UploadException $e ) { $message = $e->getMessage()[0] === '{' ? json_decode( $e->getMessage(), true ) : $e->getMessage(); return new WP_Error( 'font_validation_error', $message, [ 'status' => 400 ] ); @@ -402,6 +458,86 @@ public function update_item( WP_REST_Request $request ) { } } + /** + * A saved font as the Font Manager lists it + * + * @since 6.19.0 + */ + protected function get_font_row( array $font ): array { + $downloader = $this->get_downloader(); + + return $downloader instanceof Model_Font_Downloader ? $downloader->get_custom_font_row( $font ) : $font; + } + + /** + * The downloader, unless an add-on has unhooked it + * + * @return Model_Font_Downloader|WP_Error + * + * @since 6.19.0 + */ + protected function get_downloader() { + $downloader = GPDFAPI::get_mvc_class( 'Model_Font_Downloader' ); + + return $downloader instanceof Model_Font_Downloader ? $downloader : new WP_Error( 'invalid_model', '', [ 'status' => 500 ] ); + } + + /** + * Changing a core font also needs the rights over the fonts folder a Multisite shares + * + * @since 6.19.0 + */ + protected function check_item_permissions( WP_REST_Request $request ): bool { + if ( ! $this->check_permissions() ) { + return false; + } + + if ( $request->get_param( 'scope' ) !== 'core' ) { + return true; + } + + $downloader = $this->get_downloader(); + + return $downloader instanceof Model_Font_Downloader && $downloader->can_change_core_fonts(); + } + + /** + * The font route's DELETE: of a custom font, or with the `core` scope a core font + * + * @return array|void|WP_Error + * + * @since 6.19.0 + */ + public function delete_scoped_item( WP_REST_Request $request ) { + if ( $request->get_param( 'scope' ) !== 'core' ) { + return $this->delete_unused_item( $request ); + } + + $downloader = $this->get_downloader(); + + return $downloader instanceof Model_Font_Downloader ? $downloader->delete_downloaded_font( (string) $request->get_param( 'id' ) ) : $downloader; + } + + /** + * Delete a custom font, refusing one in use. delete_item() doesn't, as the upgrade to 6.19 removes records whose fonts a + * source now serves, which stay in use. + * + * @return void|WP_Error + * + * @since 6.19.0 + */ + public function delete_unused_item( WP_REST_Request $request ) { + $id = $request->get_param( 'id' ); + + $required_fonts = GPDFAPI::get_mvc_class( 'Model_Required_Fonts' ); + + if ( $this->model->matches_custom_font_id( $id ) && $required_fonts instanceof Model_Required_Fonts && $required_fonts->is_font_in_use( $id ) ) { + return new WP_Error( 'font_in_use', __( 'The font is in use, so it can\'t be deleted.', 'gravity-pdf' ), [ 'status' => 409 ] ); + } + + return $this->delete_item( $request ); + } + /** * @param WP_REST_Request $request * diff --git a/src/Helper/Helper_Data.php b/src/Helper/Helper_Data.php index ea7932ba3..3acc634ab 100644 --- a/src/Helper/Helper_Data.php +++ b/src/Helper/Helper_Data.php @@ -35,6 +35,7 @@ * @property string $template_location The current path to the PDF working directory * @property string $template_location_url The current URL to the PDF working directory * @property string $template_font_location The current path to the PDF font directory + * @property string|null $template_font_location_url The default PDF font directory's URL, or null when it's been moved * @property string $template_tmp_location The current path to the PDF tmp location * @property string $mpdf_tmp_location The current path to the mPDF tmp directory (mPDF's tempDir) * @property string $multisite_template_location The current path to the multisite PDF working directory @@ -304,33 +305,82 @@ public function get_localised_script_data( Helper_Abstract_Options $options, Hel /* Font Manager */ 'fontManagerTitle' => esc_html__( 'Font Manager', 'gravity-pdf' ), - 'fontManagerSearchPlaceHolder' => esc_html__( 'Search installed fonts', 'gravity-pdf' ), - 'fontListInstalledFonts' => esc_html__( 'Installed Fonts', 'gravity-pdf' ), + 'fontManagerSearchPlaceHolder' => esc_html__( 'Search fonts', 'gravity-pdf' ), + 'fontListSize' => esc_html__( 'Size', 'gravity-pdf' ), + 'fontListInUse' => esc_html__( 'In use', 'gravity-pdf' ), + 'fontListNotInUse' => esc_html__( 'Not in use', 'gravity-pdf' ), + 'fontManagerDeleteTitle' => esc_html__( 'Delete font?', 'gravity-pdf' ), + /* translators: %s: the font name */ + 'fontManagerFontDeleted' => esc_html__( '%s deleted.', 'gravity-pdf' ), + 'fontManagerCoreHint' => esc_html__( 'Downloaded when a PDF needs them. A deleted font downloads again if it\'s needed.', 'gravity-pdf' ), + 'fontManagerCoreHintOffline' => esc_html__( 'Font downloads are turned off, so a deleted font won\'t come back on its own.', 'gravity-pdf' ), + 'fontManagerOpensNewTab' => esc_html__( '(opens in a new tab)', 'gravity-pdf' ), + 'fontsUrl' => $this->template_font_location_url ?? (string) \GPDFAPI::get_misc_class()->convert_path_to_url( $this->template_font_location ?? '' ), + /* translators: %d: the number of fonts */ + 'fontManagerFontInstalled' => esc_html__( '%d font installed', 'gravity-pdf' ), + /* translators: %d: the number of fonts */ + 'fontManagerFontsInstalled' => esc_html__( '%d fonts installed', 'gravity-pdf' ), + 'fontManagerAddNew' => esc_html__( '+ Add new font', 'gravity-pdf' ), + /* translators: %d: the number of styles */ + 'fontManagerStyle' => esc_html__( '%d style', 'gravity-pdf' ), + /* translators: %d: the number of styles */ + 'fontManagerStyles' => esc_html__( '%d styles', 'gravity-pdf' ), + 'fontManagerSetActive' => esc_html__( 'Set as active', 'gravity-pdf' ), + 'fontManagerBundledWith' => esc_html__( 'Bundled with Gravity PDF', 'gravity-pdf' ), + 'fontManagerBundledDesc' => esc_html__( 'Included with Gravity PDF, so it can\'t be deleted.', 'gravity-pdf' ), + 'fontManagerTemplateUse' => esc_html__( 'Template use:', 'gravity-pdf' ), + 'fontManagerPreview' => esc_html__( 'Preview', 'gravity-pdf' ), + 'fontManagerPreviewStyle' => esc_html__( 'Style to preview', 'gravity-pdf' ), + 'fontManagerPreviewAll' => esc_html__( 'Show all', 'gravity-pdf' ), + 'fontManagerPreviewUnsupported' => esc_html__( 'Browsers can\'t display this font file, so it can\'t be previewed.', 'gravity-pdf' ), + 'fontManagerPreviewFailed' => esc_html__( 'Couldn\'t load this style', 'gravity-pdf' ), + 'fontManagerPreviewLoad' => esc_html__( 'Load preview', 'gravity-pdf' ), + 'fontManagerPreviewUnavailable' => esc_html__( 'Previews aren\'t available because the fonts folder has no web address.', 'gravity-pdf' ), + 'fontManagerSampleText' => esc_html__( 'Sample text', 'gravity-pdf' ), + 'fontManagerPreviewText' => esc_html__( 'Sphinx of black quartz, judge my vow', 'gravity-pdf' ), + 'fontManagerPreviewUnit' => esc_html__( 'pt', 'gravity-pdf' ), + 'fontManagerFontNamePlaceholder' => esc_html__( 'e.g. Helvetica Neue, Roboto Slab, Source Sans 3', 'gravity-pdf' ), + 'fontManagerNoFile' => esc_html__( 'No .ttf file added', 'gravity-pdf' ), + 'fontManagerDropFile' => esc_html__( 'Drop .ttf to upload', 'gravity-pdf' ), + 'fontManagerUpload' => esc_html__( '+ Upload', 'gravity-pdf' ), + 'fontManagerReplace' => esc_html__( 'Replace', 'gravity-pdf' ), + /* translators: %s: the font style, e.g. Bold */ + 'fontManagerDeleteStyle' => esc_html__( 'Delete %s', 'gravity-pdf' ), 'fontUserDefinedGroup' => esc_html__( 'User-Defined Fonts', 'gravity-pdf' ), 'fontListRegular' => esc_html__( 'Regular', 'gravity-pdf' ), - 'fontListItalics' => esc_html__( 'Italics', 'gravity-pdf' ), + 'fontListItalics' => esc_html__( 'Italic', 'gravity-pdf' ), 'fontListBold' => esc_html__( 'Bold', 'gravity-pdf' ), - 'fontListBoldItalics' => esc_html__( 'Bold Italics', 'gravity-pdf' ), - 'fontManagerAddTitle' => esc_html__( 'Add Font', 'gravity-pdf' ), + 'fontListBoldItalics' => esc_html__( 'Bold Italic', 'gravity-pdf' ), + 'fontManagerAddTitle' => esc_html__( 'Add font', 'gravity-pdf' ), 'fontManagerUpdateTitle' => esc_html__( 'Update Font', 'gravity-pdf' ), - 'fontManagerAddDesc' => esc_html__( 'Install new fonts for use in your PDF documents.', 'gravity-pdf' ), 'fontManagerUpdateDesc' => esc_html__( 'Once saved, PDFs configured to use this font will have your changes applied automatically for newly-generated documents.', 'gravity-pdf' ), - 'fontManagerFontNameLabel' => esc_html__( 'Font Name', 'gravity-pdf' ), + 'fontManagerFontNameLabel' => esc_html__( 'Font name', 'gravity-pdf' ), 'fontManagerRequiredLabel' => esc_html__( '(required)', 'gravity-pdf' ), - 'fontManagerFontNameDesc' => esc_html__( 'The font name can only contain letters, numbers and spaces.', 'gravity-pdf' ), + 'fontManagerFontNameDesc' => esc_html__( 'Letters, numbers, and spaces only', 'gravity-pdf' ), 'fontManagerFontNameValidationError' => esc_html__( 'Please choose a name contains letters and/or numbers (and a space if you want it).', 'gravity-pdf' ), - 'fontManagerFontFilesLabel' => esc_html__( 'Font Files', 'gravity-pdf' ), - 'fontManagerFontFilesDesc' => esc_html__( 'Select or drag and drop your .ttf font file for the variants below. Only the Regular type is required.', 'gravity-pdf' ), + 'fontManagerFontFilesLabel' => esc_html__( 'Font files', 'gravity-pdf' ), + 'fontManagerFontFilesDesc' => esc_html__( 'Add a .ttf file for each style you want to use in your PDFs. Regular is required; Italic, Bold, and Bold Italic are optional and only used when your templates need them.', 'gravity-pdf' ), 'fontManagerFontFileRequiredRegular' => esc_html__( 'Add a .ttf font file.', 'gravity-pdf' ), 'fontManagerTemplateTooltipLabel' => esc_html__( 'View template usage', 'gravity-pdf' ), - 'fontManagerCancelButtonText' => esc_html__( '← Cancel', 'gravity-pdf' ), 'fontManagerDeleteFontConfirmation' => esc_html__( 'Are you sure you want to delete this font?', 'gravity-pdf' ), /* translators: 1: Opening tag (custom template link), 2: Opening tag (font setting link), 3: Closing tag */ 'fontManagerTemplateTooltipDesc' => esc_html__( 'Add this snippet %1$sin a custom template%3$s to selectively set the font on blocks of text. If you want to apply the font to the entire PDF, %2$suse the Font setting%3$s when configuring the PDF on the form.', 'gravity-pdf' ), - 'fontManagerAddFontAriaLabel' => esc_html__( 'Add font', 'gravity-pdf' ), 'fontManagerUpdateFontAriaLabel' => esc_html__( 'Update font', 'gravity-pdf' ), - 'fontManagerSelectFontAriaLabel' => esc_html__( 'Select font', 'gravity-pdf' ), 'fontManagerDeleteFontAriaLabel' => esc_html__( 'Delete font', 'gravity-pdf' ), + 'fontManagerTypeCustom' => esc_html__( 'Custom', 'gravity-pdf' ), + 'fontManagerTypeCore' => esc_html__( 'Core', 'gravity-pdf' ), + 'fontManagerLanguages' => esc_html__( 'Languages and scripts', 'gravity-pdf' ), + 'fontManagerTypeAvailable' => esc_html__( 'Available to install', 'gravity-pdf' ), + 'fontManagerInstall' => esc_html__( 'Install', 'gravity-pdf' ), + 'fontManagerAvailableHint' => esc_html__( 'Downloaded when a PDF needs it, or install it now to have it ready.', 'gravity-pdf' ), + /* translators: %s: the font name */ + 'fontManagerFontJustInstalled' => esc_html__( '%s installed.', 'gravity-pdf' ), + 'fontManagerTypeBundled' => esc_html__( 'Bundled', 'gravity-pdf' ), + 'fontManagerNetworkOnly' => esc_html__( 'Only a network administrator can install or delete core fonts, as every site in the network shares them.', 'gravity-pdf' ), + /* translators: %s: the font name */ + 'fontManagerCoreConfirm' => esc_html__( 'Delete %s? It downloads again when a PDF needs it.', 'gravity-pdf' ), + /* translators: %s: the font name */ + 'fontManagerCoreConfirmOffline' => esc_html__( 'Delete %s? Font downloads are turned off, so it won\'t come back on its own: copy its files into the fonts folder again to restore it.', 'gravity-pdf' ), /* Font Manager API response */ 'fontListEmpty' => esc_html__( 'Font list empty.', 'gravity-pdf' ), diff --git a/src/Model/Model_Font_Downloader.php b/src/Model/Model_Font_Downloader.php index bcab58fcb..9f3b0bc64 100644 --- a/src/Model/Model_Font_Downloader.php +++ b/src/Model/Model_Font_Downloader.php @@ -4,6 +4,7 @@ namespace GFPDF\Model; +use GFPDF\Helper\Fonts\FlushCache; use GFPDF\Helper\Helper_Abstract_Model; use GFPDF\Helper\Helper_Abstract_Options; use GFPDF\Helper\Helper_Data; @@ -11,6 +12,7 @@ use GFPDF\Helper\Mpdf\On_Demand_Font_File_Finder; use GFPDF\Statics\Git_Blob; use GFPDF_Vendor\Psr\Log\LoggerInterface; +use WP_Error; /** * @package Gravity PDF @@ -30,6 +32,18 @@ */ class Model_Font_Downloader extends Helper_Abstract_Model { + /** + * A font row's style fields, as keys to intersect a row with + * + * @since 6.19.0 + */ + protected const FONT_STYLE_KEYS = [ + 'regular' => true, + 'italics' => true, + 'bold' => true, + 'bolditalics' => true, + ]; + /** * Failed files awaiting their one retry, and those that gave up, which the admin notice lists * @@ -350,6 +364,473 @@ public function prune( ?array $keep = null ): array { return $removed; } + /** + * Every font the Font Manager lists: custom and listed bundled fonts by name, then core fonts, on disk or available to + * install, in the font dropdown's order. + * `can_delete` is whether this user may delete the font when it's unused; get_font_usage() says what's in use. + * + * @return array, base_url?: string, auto_install?: bool, can_install?: bool, languages?: string[], sample?: string, preview?: bool}> + * + * @since 6.19.0 + */ + public function get_installed_fonts(): array { + $custom = $this->font_sources->get_custom_fonts(); + $labels = $this->get_font_labels(); + $fonts = []; + + foreach ( $custom as $font ) { + $fonts[] = $this->get_custom_font_row( $font ); + } + + foreach ( $this->font_sources->get_sources() as $source ) { + if ( empty( $source['manifest'] ) ) { + foreach ( $this->font_sources->get_listed_families( $source ) as $key => $family ) { + $fonts[] = $this->get_font_row( (string) $key, $labels[ $key ] ?? (string) $key, 'bundled', $family, false, $source['dir'] ) + [ 'base_url' => PDF_PLUGIN_URL . 'fonts/' ]; + } + } + } + + usort( + $fonts, + static function ( array $a, array $b ): int { + return strcasecmp( $a['font_name'], $b['font_name'] ); + } + ); + + /* The labels are in the dropdown's order, and a family without one follows them */ + $families = array_diff_key( $this->get_core_families(), $custom ); + foreach ( array_replace( array_intersect_key( $labels, $families ), $families ) as $key => $family ) { + $row = $this->get_core_font_row( (string) $key, $family, $labels ); + if ( $row !== null ) { + $fonts[] = $row; + } + } + + return $fonts; + } + + /** + * Why each font in use can't be deleted, as phrases for the Font Manager. Walks every form, and on Multisite every + * site, so the list loads without it. + * + * @return array}> By font key + * + * @since 6.19.0 + */ + public function get_font_usage(): array { + $custom = $this->font_sources->get_custom_fonts(); + $locales = $this->font_sources->get_language_font_locales(); + $pdfs = $this->required_fonts->get_font_pdfs(); + $usage = []; + + /* Other sites only lock core fonts, which only a super admin can delete, so nobody else pays for walking them */ + foreach ( $this->required_fonts->get_font_reasons( is_multisite() && $this->can_change_core_fonts() ) as $key => $reason ) { + /* A site's custom fonts are its own, so other sites don't count */ + if ( isset( $custom[ $key ] ) ) { + $reason['sites'] = 0; + } + + $in_use = $this->get_in_use( $reason, $locales[ $key ] ?? [] ); + if ( count( $in_use ) > 0 || $reason['pdfs'] > 0 ) { + $usage[ $key ] = [ + 'reasons' => $in_use, + /* translators: %d: the number of PDFs */ + 'pdfs_label' => $reason['pdfs'] > 0 ? sprintf( _n( 'Used by %d PDF', 'Used by %d PDFs', $reason['pdfs'], 'gravity-pdf' ), $reason['pdfs'] ) : '', + 'pdfs' => $pdfs[ $key ] ?? [], + ]; + } + } + + return $usage; + } + + /** + * Every site shares the fonts folder, so only a super admin deletes core fonts on Multisite + * + * @since 6.19.0 + */ + public function can_change_core_fonts(): bool { + return ! is_multisite() || is_super_admin(); + } + + /** + * A saved custom font as the Font Manager lists it + * + * @param array $font A custom font record + * + * @return array The record with its `type`, `can_delete`, `size_label` and `file_sizes` + * + * @since 6.19.0 + */ + public function get_custom_font_row( array $font ): array { + $files = array_intersect_key( $font, self::FONT_STYLE_KEYS ); + + return array_merge( + $font, + [ + 'type' => 'custom', + 'can_delete' => true, + ], + $this->get_size_fields( $files, $this->data->template_font_location ) + ); + } + + /** + * A source's font in the shape of a custom font record + * + * @param array $files File names by style (R, B, I, BI) + * @param string $dir The folder holding the files + * + * @return array{id: string, font_name: string, type: string, regular: string, italics: string, bold: string, bolditalics: string, can_delete: bool, size_label: string, file_sizes: array} + * + * @since 6.19.0 + */ + protected function get_font_row( string $key, string $label, string $type, array $files, bool $can_delete, string $dir ): array { + $styles = [ + 'regular' => $files['R'] ?? '', + 'italics' => $files['I'] ?? '', + 'bold' => $files['B'] ?? '', + 'bolditalics' => $files['BI'] ?? '', + ]; + + return [ + 'id' => $key, + 'font_name' => $label, + 'type' => $type, + ] + $styles + [ + 'can_delete' => $can_delete, + ] + $this->get_size_fields( $styles, $dir ); + } + + /** + * A core family's Font Manager row, `available` when none of it is on disk + * + * @param array{files: array, sizes: array, bytes: int} $family + * @param array $labels Font list labels by key + * + * @return array|null Null for a family with nothing on disk that can't or shouldn't be installed + * + * @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 ] ); + } + ); + + $can_download = $this->can_download_all( $family['files'] ); + $installed = count( $on_disk ) > 0; + + /* A family without a label can't be chosen for a PDF (a fallback or barcode font), so isn't offered */ + if ( ! $installed && ( ! $can_download || ! isset( $labels[ $key ] ) ) ) { + return null; + } + + $can_change = $this->can_change_core_fonts(); + $row = $this->get_font_row( $key, $labels[ $key ] ?? $key, $installed ? 'core' : 'available', $installed ? $on_disk : $family['files'], $installed && $can_change, $this->data->template_font_location ); + + if ( ! $installed ) { + $manifest = $this->font_sources->get_manifest(); + + $row['size_label'] = (string) size_format( $family['bytes'], 1 ); + $row['file_sizes'] = array_map( + static function ( string $file ) use ( $manifest ): int { + return (int) $manifest[ $file ]['size']; + }, + array_filter( array_intersect_key( $row, self::FONT_STYLE_KEYS ) ) + ); + + /* Nothing on this site to preview yet, so the browser reads it from where it would be downloaded */ + $row['base_url'] = dirname( $manifest[ reset( $family['files'] ) ]['url'] ) . '/'; + } + + $languages = $this->font_sources->get_font_languages()[ $key ] ?? []; + + return $row + [ + 'auto_install' => $can_download, + 'can_install' => $can_change && $can_download && count( $on_disk ) < count( $family['files'] ), + 'languages' => $languages['languages'] ?? [], + 'sample' => $languages['sample'] ?? '', + 'preview' => $languages['preview'] ?? true, + ]; + } + + /** + * A core family's row as it stands now its files have changed + * + * @since 6.19.0 + */ + protected function get_current_core_font_row( string $key ): ?array { + return $this->get_core_font_row( $key, $this->get_core_families()[ $key ], $this->get_font_labels() ); + } + + /** + * A font's sizes, found by name in the folder as stored custom font paths can predate a move + * + * @param string[] $files File names or paths by style, '' for a style it doesn't have + * + * @return array{size_label: string, file_sizes: array} `file_sizes` lets the preview ask before loading a large file + * + * @since 6.19.0 + */ + protected function get_size_fields( array $files, string $dir ): array { + $sizes = []; + foreach ( array_filter( $files ) as $style => $file ) { + $path = $dir . basename( $file ); + if ( is_file( $path ) ) { + $sizes[ $style ] = (int) filesize( $path ); + } + } + + return [ + 'size_label' => count( $sizes ) > 0 ? (string) size_format( array_sum( $sizes ), 1 ) : '', + 'file_sizes' => $sizes, + ]; + } + + /** + * Delete an unused downloaded family's files whatever their hash, keeping the licence files + * + * @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 + * nothing is on disk, 409 when in use + * + * @since 6.19.0 + */ + public function delete_downloaded_font( string $key ) { + $family = $this->get_core_families()[ $key ] ?? null; + if ( $family === null || count( $family['sizes'] ) === 0 ) { + return new WP_Error( 'font_not_downloaded', __( 'The font is not downloaded.', 'gravity-pdf' ), [ 'status' => 404 ] ); + } + + $files = array_values( array_unique( $family['files'] ) ); + if ( $this->required_fonts->is_font_in_use( $key, is_multisite() ) ) { + return new WP_Error( 'font_in_use', __( 'The font is in use, so it would be downloaded again.', 'gravity-pdf' ), [ 'status' => 409 ] ); + } + + $removed = []; + $bytes = 0; + foreach ( $files as $file ) { + $this->clear_failure( $file ); + $this->delete_back_off( $file ); + + $size = $family['sizes'][ $file ] ?? null; + if ( $size === null ) { + continue; + } + + if ( ! wp_delete_file_from_directory( $this->get_font_path( $file ), $this->data->template_font_location ) ) { + $this->log->warning( 'Downloaded font file could not be removed', [ 'name' => $file ] ); + continue; + } + + $removed[] = $file; + $bytes += $size; + } + + FlushCache::flush( $key ); + + $this->log->notice( + 'Downloaded font deleted', + [ + 'font' => $key, + 'files' => $removed, + 'bytes' => $bytes, + ] + ); + + return [ + 'id' => $key, + 'removed' => $removed, + 'bytes' => $bytes, + 'font' => $this->get_current_core_font_row( $key ), + ]; + } + + /** + * Download a core family's missing files, ignoring any back-off as someone asked for it + * + * @return array|WP_Error The font's row. 404 for a family not on the manifest or replaced by a custom font, 409 with + * downloads turned off, 502 when a file couldn't be downloaded + * + * @since 6.19.0 + */ + public function install_font( string $key ) { + $family = $this->get_core_families()[ $key ] ?? null; + if ( $family === null || isset( $this->font_sources->get_custom_fonts()[ $key ] ) ) { + return new WP_Error( 'font_not_available', __( 'The font is not available to install.', 'gravity-pdf' ), [ 'status' => 404 ] ); + } + + if ( ! $this->can_download_all( $family['files'] ) ) { + return new WP_Error( 'font_downloads_off', __( 'Font downloads are turned off.', 'gravity-pdf' ), [ 'status' => 409 ] ); + } + + $failed = []; + foreach ( array_unique( $family['files'] ) as $file ) { + $this->delete_back_off( $file ); + + if ( ! $this->download( $file ) ) { + $failed[] = $file; + } + } + + if ( count( $failed ) > 0 ) { + $this->log->error( + 'Font could not be installed', + [ + 'font' => $key, + 'files' => $failed, + ] + ); + + return new WP_Error( 'font_download_failed', __( 'The font could not be downloaded. Please try again.', 'gravity-pdf' ), [ 'status' => 502 ] ); + } + + $this->log->notice( 'Font installed', [ 'font' => $key ] ); + + return $this->get_current_core_font_row( $key ); + } + + /** + * The downloadable sources' families: their files on the manifest by style (R, B, I, BI), the sizes of those on disk + * and the bytes of them all + * + * @return array, sizes: array, bytes: int}> + * + * @since 6.19.0 + */ + protected function get_core_families(): array { + $families = []; + + foreach ( $this->font_sources->get_sources() as $id => $source ) { + if ( empty( $source['manifest'] ) ) { + continue; + } + + $manifest = $this->font_sources->get_source_manifest( (string) $id ); + foreach ( $source['families'] ?? [] as $key => $family ) { + $key = (string) $key; + if ( ! empty( $families[ $key ]['sizes'] ) || ! is_array( $family ) ) { + continue; + } + + $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 ) ) { + $files[ $style ] = $file; + } + } + + $sizes = []; + $bytes = 0; + foreach ( $files as $file ) { + $bytes += (int) $manifest[ $file ]['size']; + $path = $this->get_font_path( $file ); + if ( is_file( $path ) ) { + $sizes[ $file ] = (int) filesize( $path ); + } + } + + if ( count( $files ) > 0 ) { + $families[ $key ] = [ + 'files' => $files, + 'sizes' => $sizes, + 'bytes' => $bytes, + ]; + } + } + } + + return $families; + } + + /** + * Font list labels by key + * + * @return array + * + * @since 6.19.0 + */ + protected function get_font_labels(): array { + $labels = []; + foreach ( (array) $this->options->get_installed_fonts() as $group ) { + foreach ( is_array( $group ) ? $group : [] as $key => $label ) { + if ( is_string( $label ) && ! isset( $labels[ $key ] ) ) { + $labels[ (string) $key ] = $label; + } + } + } + + return $labels; + } + + /** + * A font's get_font_reasons() entry as phrases for the Font Manager, other than its PDFs, which are linked to + * + * @param array $reasons A get_font_reasons() entry + * @param string[] $locales The locales it's a language font for + * + * @return string[] + * + * @since 6.19.0 + */ + protected function get_in_use( array $reasons, array $locales = [] ): array { + $phrases = []; + + if ( $reasons['default'] ) { + $phrases[] = __( 'Default font', 'gravity-pdf' ); + } + + if ( $reasons['language'] ) { + $names = array_map( [ $this, 'get_language_name' ], $locales ); + /* translators: %s: the site's languages, e.g. ไทย */ + $phrases[] = count( $names ) > 0 ? sprintf( __( 'Site language (%s)', 'gravity-pdf' ), implode( ', ', $names ) ) : __( 'Site language', 'gravity-pdf' ); + } + + if ( $reasons['source'] ) { + $phrases[] = __( 'Required by an add-on', 'gravity-pdf' ); + } + + if ( $reasons['sites'] > 0 ) { + /* translators: %d: the number of sites */ + $phrases[] = sprintf( _n( 'Used on %d other site', 'Used on %d other sites', $reasons['sites'], 'gravity-pdf' ), $reasons['sites'] ); + } + + return $phrases; + } + + /** + * A locale's native name when WordPress has the translations list cached, else the locale + * + * @since 6.19.0 + */ + protected function get_language_name( string $locale ): string { + $translations = get_site_transient( 'available_translations' ); + $name = is_array( $translations ) ? ( $translations[ $locale ]['native_name'] ?? '' ) : ''; + + return is_string( $name ) && $name !== '' ? $name : $locale; + } + + /** + * Whether every file would download again if deleted + * + * @param string[] $files + * + * @since 6.19.0 + */ + protected function can_download_all( array $files ): bool { + foreach ( $files as $file ) { + if ( ! $this->is_auto_install_enabled( $file ) ) { + return false; + } + } + + return true; + } + /** * @param array{url: string, size: int, sha: string} $entry * diff --git a/src/Model/Model_Font_Languages.php b/src/Model/Model_Font_Languages.php new file mode 100644 index 000000000..48f6d8751 --- /dev/null +++ b/src/Model/Model_Font_Languages.php @@ -0,0 +1,199 @@ + By font key + * + * @since 6.19.0 + */ + public function get_font_languages(): array { + $latin = __( 'Latin', 'gravity-pdf' ); + $greek = __( 'Greek', 'gravity-pdf' ); + $cyrillic = __( 'Cyrillic', 'gravity-pdf' ); + $armenian = __( 'Armenian', 'gravity-pdf' ); + $georgian = __( 'Georgian', 'gravity-pdf' ); + $hebrew = __( 'Hebrew', 'gravity-pdf' ); + $arabic = __( 'Arabic', 'gravity-pdf' ); + $persian = __( 'Persian', 'gravity-pdf' ); + $urdu = __( 'Urdu', 'gravity-pdf' ); + $syriac = __( 'Syriac', 'gravity-pdf' ); + $thaana = __( 'Thaana', 'gravity-pdf' ); + $thai = __( 'Thai', 'gravity-pdf' ); + $lao = __( 'Lao', 'gravity-pdf' ); + $cherokee = __( 'Cherokee', 'gravity-pdf' ); + $ucas = __( 'Canadian Aboriginal', 'gravity-pdf' ); + $tifinagh = __( 'Tifinagh', 'gravity-pdf' ); + $burmese = __( 'Burmese', 'gravity-pdf' ); + $tibetan = __( 'Tibetan', 'gravity-pdf' ); + $sinhala = __( 'Sinhala', 'gravity-pdf' ); + $korean = __( 'Korean', 'gravity-pdf' ); + $ethiopic = __( 'Ethiopic', 'gravity-pdf' ); + $gothic = __( 'Gothic', 'gravity-pdf' ); + $coptic = __( 'Coptic', 'gravity-pdf' ); + $italic = __( 'Old Italic', 'gravity-pdf' ); + $ugaritic = __( 'Ugaritic', 'gravity-pdf' ); + $phoenic = __( 'Phoenician', 'gravity-pdf' ); + $linear_b = __( 'Linear B', 'gravity-pdf' ); + $cypriot = __( 'Cypriot', 'gravity-pdf' ); + $tai_le = __( 'Tai Le', 'gravity-pdf' ); + $bugis = __( 'Buginese', 'gravity-pdf' ); + $vai = __( 'Vai', 'gravity-pdf' ); + $runic = __( 'Runic', 'gravity-pdf' ); + $devan = __( 'Devanagari', 'gravity-pdf' ); + $bengali = __( 'Bengali', 'gravity-pdf' ); + $gurmukhi = __( 'Gurmukhi', 'gravity-pdf' ); + $gujarati = __( 'Gujarati', 'gravity-pdf' ); + $oriya = __( 'Oriya', 'gravity-pdf' ); + $tamil = __( 'Tamil', 'gravity-pdf' ); + $telugu = __( 'Telugu', 'gravity-pdf' ); + $kannada = __( 'Kannada', 'gravity-pdf' ); + $malayalm = __( 'Malayalam', 'gravity-pdf' ); + $khmer = __( 'Khmer', 'gravity-pdf' ); + + $dejavu_sans = [ $latin, $greek, $cyrillic, $armenian, $georgian, $hebrew, $arabic, $lao, __( "N'Ko", 'gravity-pdf' ), $tifinagh, $ucas ]; + $dejavu_serif = [ $latin, $greek, $cyrillic, $armenian, $georgian ]; + $burmese_font = [ + 'languages' => [ $burmese ], + 'sample' => 'မင်္ဂလာပါ', + ]; + + return [ + 'dejavusanscondensed' => [ 'languages' => $dejavu_sans ], + 'dejavusans' => [ 'languages' => $dejavu_sans ], + 'dejavuserifcondensed' => [ 'languages' => $dejavu_serif ], + 'dejavuserif' => [ 'languages' => $dejavu_serif ], + 'dejavusansmono' => [ 'languages' => [ $latin, $greek, $cyrillic, $armenian, $georgian, $arabic, $lao ] ], + 'freesans' => [ 'languages' => [ $latin, $greek, $cyrillic, $armenian, $georgian, $hebrew, $syriac, $devan, $bengali, $gurmukhi, $malayalm, $cherokee, $ucas, $vai, $tifinagh, $ugaritic, $phoenic ] ], + 'freeserif' => [ 'languages' => [ $latin, $greek, $cyrillic, $armenian, $georgian, $hebrew, $arabic, $thaana, $devan, $bengali, $gurmukhi, $gujarati, $oriya, $tamil, $malayalm, $sinhala, $thai, $ethiopic, $cherokee, $tai_le, $bugis, $gothic, $coptic ] ], + 'freemono' => [ 'languages' => [ $latin, $greek, $cyrillic, $armenian, $georgian, $hebrew, $arabic, $cherokee, $runic, $italic ] ], + 'mph2bdamase' => [ 'languages' => [ $latin, $greek, $cyrillic, $armenian, $georgian, $hebrew, $arabic, $thaana, $cherokee, __( 'Limbu', 'gravity-pdf' ), $tai_le, $bugis, $linear_b, $italic, $gothic, $ugaritic, $phoenic, $cypriot, $coptic, $tifinagh ] ], + 'quivira' => [ 'languages' => [ $latin, $greek, $cyrillic, $armenian, $georgian, $hebrew, $thai, $cherokee, $ucas, __( 'Ogham', 'gravity-pdf' ), $runic, __( 'Tagalog', 'gravity-pdf' ), $vai, $italic, $gothic, $phoenic, $coptic, $tifinagh ] ], + 'sun-exta' => [ + 'languages' => [ __( 'Chinese', 'gravity-pdf' ), __( 'Japanese', 'gravity-pdf' ), $korean, $latin, $greek, $cyrillic, $armenian, $georgian, $hebrew, $arabic, $syriac, $thaana, $devan, $bengali, $gurmukhi, $gujarati, $oriya, $tamil, $telugu, $kannada, $malayalm, $thai, $lao, $tibetan, $burmese, $ethiopic, $cherokee, $ucas, $khmer, __( 'Mongolian', 'gravity-pdf' ) ], + 'sample' => '你好,世界 こんにちは 안녕하세요', + /* Its vhea table claims more metrics than it has glyphs, so browsers reject the file after downloading 22 MB */ + 'preview' => false, + ], + 'unbatang' => [ + 'languages' => [ $korean ], + 'sample' => '안녕하세요', + ], + 'lohitkannada' => [ + 'languages' => [ $kannada ], + 'sample' => 'ನಮಸ್ಕಾರ', + ], + 'pothana2000' => [ + 'languages' => [ $telugu ], + 'sample' => 'నమస్కారం', + ], + 'xbriyaz' => [ + 'languages' => [ $arabic, $persian, $urdu ], + 'sample' => 'مرحبا بالعالم', + ], + 'lateef' => [ + 'languages' => [ $arabic, $persian, $urdu, __( 'Sindhi', 'gravity-pdf' ) ], + 'sample' => 'مرحبا بالعالم', + ], + 'kfgqpcuthmantahanaskh' => [ + 'languages' => [ __( 'Quranic Arabic', 'gravity-pdf' ) ], + 'sample' => 'بسم الله الرحمن الرحيم', + ], + 'estrangeloedessa' => [ + 'languages' => [ $syriac ], + 'sample' => 'ܫܠܡܐ', + ], + 'kaputaunicode' => [ + 'languages' => [ $sinhala ], + 'sample' => 'ආයුබෝවන්', + ], + 'abyssinicasil' => [ + 'languages' => [ __( 'Amharic', 'gravity-pdf' ), __( 'Tigrinya', 'gravity-pdf' ), __( "Ge'ez", 'gravity-pdf' ) ], + 'sample' => 'ሰላም', + ], + 'aboriginalsans' => [ + 'languages' => [ $cherokee, __( 'Cree', 'gravity-pdf' ), __( 'Inuktitut', 'gravity-pdf' ), __( 'Ojibwe', 'gravity-pdf' ) ], + 'sample' => 'ᎣᏏᏲ ᐊᐃ', + ], + 'jomolhari' => [ + 'languages' => [ $tibetan, __( 'Dzongkha', 'gravity-pdf' ) ], + 'sample' => 'བཀྲ་ཤིས་བདེ་ལེགས།', + ], + 'sundaneseunicode' => [ + 'languages' => [ __( 'Sundanese', 'gravity-pdf' ) ], + 'sample' => 'ᮃ ᮄ ᮅ ᮊ ᮋ', + ], + 'taiheritagepro' => [ + 'languages' => [ __( 'Tai Dam', 'gravity-pdf' ) ], + 'sample' => 'ꪀ ꪁ ꪂ ꪃ ꪄ', + ], + 'aegyptus' => [ + 'languages' => [ __( 'Ancient Egyptian', 'gravity-pdf' ) ], + 'sample' => '𓀀 𓁐 𓃀 𓆣 𓋹', + ], + 'akkadian' => [ + 'languages' => [ __( 'Akkadian', 'gravity-pdf' ), __( 'Sumerian', 'gravity-pdf' ) ], + 'sample' => '𒀀 𒀭 𒁲 𒂗 𒆠', + ], + 'aegean' => [ + 'languages' => [ $greek, $linear_b, $cypriot, $phoenic, $ugaritic, $italic ], + 'sample' => '𐀀 𐀁 𐀂 𐀃 𐀄', + ], + 'lannaalif' => [ + 'languages' => [ __( 'Northern Thai', 'gravity-pdf' ) ], + 'sample' => 'ᨠ ᨡ ᨢ ᨣ ᨤ', + ], + 'daibannasilbook' => [ + 'languages' => [ __( 'Tai Lü', 'gravity-pdf' ) ], + 'sample' => 'ᦀ ᦁ ᦂ ᦃ ᦄ', + ], + 'garuda' => [ + 'languages' => [ $thai ], + 'sample' => 'สวัสดี', + ], + 'khmeros' => [ + 'languages' => [ $khmer ], + 'sample' => 'សួស្តី', + ], + 'dhyana' => [ + 'languages' => [ $lao ], + 'sample' => 'ສະບາຍດີ', + ], + 'tharlon' => $burmese_font, + 'padaukbook' => $burmese_font, + 'ayar' => $burmese_font, + /* Zawgyi orders vowels differently to Unicode, so a word would come out wrong */ + 'zawgyi-one' => [ + 'languages' => [ $burmese ], + 'sample' => 'က ခ ဂ ဃ င', + ], + 'taameydavidclm' => [ + 'languages' => [ $hebrew ], + 'sample' => 'שלום עולם', + ], + ]; + } +} diff --git a/src/Model/Model_Font_Sources.php b/src/Model/Model_Font_Sources.php index 48af64c9d..730166388 100644 --- a/src/Model/Model_Font_Sources.php +++ b/src/Model/Model_Font_Sources.php @@ -109,6 +109,12 @@ class Model_Font_Sources extends Helper_Abstract_Model { */ protected $language_font_keys; + /** + * @var array|null Locales by font key, once get_language_font_locales() maps them + * @since 6.19.0 + */ + protected $language_font_locales; + /** * Every source's downloadable files by name, once get_manifest() reads them * @@ -117,6 +123,12 @@ class Model_Font_Sources extends Helper_Abstract_Model { */ protected $manifest; + /** + * @var array|null What each core font supports, once read + * @since 6.19.0 + */ + protected $font_languages; + /** * Each source's manifest by ID, once get_source_manifest() reads it * @@ -160,6 +172,17 @@ public function get_sources(): array { return $this->sources; } + /** + * The custom fonts by ID + * + * @return array + * + * @since 6.19.0 + */ + public function get_custom_fonts(): array { + return $this->custom_fonts->get_custom_fonts(); + } + /** * Whether a font source provides the mPDF font key * @@ -303,14 +326,30 @@ public function get_font_data(): array { return $this->font_data; } + /** + * What each core font supports, by font key + * + * @return array + * + * @since 6.19.0 + */ + public function get_font_languages(): array { + if ( $this->font_languages === null ) { + $this->font_languages = ( new Model_Font_Languages() )->get_font_languages(); + } + + return $this->font_languages; + } + /** * Forget the font data and language fonts, e.g. once a language pack installs * * @since 6.19.0 */ public function flush(): void { - $this->font_data = null; - $this->language_font_keys = null; + $this->font_data = null; + $this->language_font_keys = null; + $this->language_font_locales = null; delete_transient( self::LANGUAGE_FONT_KEYS ); } @@ -335,20 +374,7 @@ public function get_language_font_keys(): array { return $this->language_font_keys; } - $mapper = $this->get_language_mapper(); - $locales = array_filter( array_merge( (array) get_available_languages(), [ $locale ] ), 'is_string' ); - $keys = []; - - foreach ( array_unique( $locales ) as $language ) { - /* Adobe CJK fonts are off in Gravity PDF's config */ - $options = $mapper->getLanguageOptions( str_replace( '_', '-', $language ), false ); - $key = is_array( $options ) && is_string( $options[1] ?? null ) ? $options[1] : ''; - if ( $key !== '' ) { - $keys[] = $key; - } - } - - $this->language_font_keys = array_values( array_unique( $keys ) ); + $this->language_font_keys = array_map( 'strval', array_keys( $this->get_language_font_locales() ) ); set_transient( self::LANGUAGE_FONT_KEYS, @@ -362,6 +388,50 @@ public function get_language_font_keys(): array { return $this->language_font_keys; } + /** + * The installed languages and the locale mapped to their mPDF font keys, once per request + * + * @return array Locales by font key + * + * @since 6.19.0 + */ + public function get_language_font_locales(): array { + if ( $this->language_font_locales !== null ) { + return $this->language_font_locales; + } + + $this->language_font_locales = $this->map_language_font_locales( + array_merge( (array) get_available_languages(), [ get_locale() ] ) + ); + + return $this->language_font_locales; + } + + /** + * Map locales to the mPDF font keys they need, skipping those the default font covers + * + * @param mixed[] $locales + * + * @return array Locales by font key + * + * @since 6.19.0 + */ + public function map_language_font_locales( array $locales ): array { + $mapper = $this->get_language_mapper(); + $keys = []; + + foreach ( array_unique( array_filter( $locales, 'is_string' ) ) as $language ) { + /* Adobe CJK fonts are off in Gravity PDF's config */ + $options = $mapper->getLanguageOptions( str_replace( '_', '-', $language ), false ); + $key = is_array( $options ) && is_string( $options[1] ?? null ) ? $options[1] : ''; + if ( $key !== '' ) { + $keys[ $key ][] = $language; + } + } + + return $keys; + } + /** * 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 22.9 MB mid-render. @@ -554,8 +624,7 @@ public function register_font_directories( $config ): array { * @since 6.19.0 */ public function add_font_list_groups( $fonts ): array { - $fonts = is_array( $fonts ) ? $fonts : []; - $custom_fonts = $this->custom_fonts->get_custom_fonts(); + $fonts = is_array( $fonts ) ? $fonts : []; foreach ( $this->get_sources() as $source ) { if ( empty( $source['group'] ) ) { @@ -563,10 +632,8 @@ public function add_font_list_groups( $fonts ): array { } $list = []; - foreach ( $source['families'] as $key => $family ) { - if ( ( $family['listed'] ?? true ) && ! isset( $custom_fonts[ $key ] ) ) { - $list[ $key ] = $family['name'] ?? $key; - } + foreach ( $this->get_listed_families( $source ) as $key => $family ) { + $list[ $key ] = $family['name'] ?? $key; } $group = $fonts[ $source['group'] ] ?? []; @@ -576,6 +643,27 @@ public function add_font_list_groups( $fonts ): array { return $fonts; } + /** + * A source's families that users can choose: not hidden, and not replaced by a custom font of the same key + * + * @param array $source A get_sources() value + * + * @return array + * + * @since 6.19.0 + */ + public function get_listed_families( array $source ): array { + $custom_fonts = $this->custom_fonts->get_custom_fonts(); + + return array_filter( + $source['families'] ?? [], + static function ( $family, $key ) use ( $custom_fonts ): bool { + return is_array( $family ) && ( $family['listed'] ?? true ) && ! isset( $custom_fonts[ $key ] ); + }, + ARRAY_FILTER_USE_BOTH + ); + } + /** * Remove the custom font records a source now serves (keyed by one of its families, every file a known copy). * Edited records stay and keep overriding. diff --git a/src/Model/Model_Install.php b/src/Model/Model_Install.php index 68c56e4a5..5ee3180a3 100644 --- a/src/Model/Model_Install.php +++ b/src/Model/Model_Install.php @@ -198,7 +198,11 @@ public function setup_template_location() { $this->data->template_location_url = trailingslashit( apply_filters( 'gfpdf_template_location_uri', $template_url, $working_folder, $upload_dir_url ) ); /* needs to be accessible from the web */ /* See https://docs.gravitypdf.com/developers/filters/gfpdf_font_location/ for more details about this filter */ - $this->data->template_font_location = trailingslashit( apply_filters( 'gfpdf_font_location', $this->data->template_location . 'fonts/', $working_folder, $upload_dir ) ); /* can be in a directory not accessible via the web */ + $default_fonts = $this->data->template_location . 'fonts/'; + $this->data->template_font_location = trailingslashit( apply_filters( 'gfpdf_font_location', $default_fonts, $working_folder, $upload_dir ) ); /* can be in a directory not accessible via the web */ + + /* Null once the folder is moved, as only the Font Manager needs that path mapped to a URL */ + $this->data->template_font_location_url = $this->data->template_font_location === $default_fonts ? $this->data->template_location_url . 'fonts/' : null; /* See https://docs.gravitypdf.com/developers/filters/gfpdf_tmp_location/ for more details about this filter */ $this->data->template_tmp_location = trailingslashit( apply_filters( 'gfpdf_tmp_location', $this->data->template_location . 'tmp/', $working_folder, $upload_dir_url ) ); /* encouraged to move this to a directory not accessible via the web */ diff --git a/src/Model/Model_Required_Fonts.php b/src/Model/Model_Required_Fonts.php index d5c3705ae..b17218bb5 100644 --- a/src/Model/Model_Required_Fonts.php +++ b/src/Model/Model_Required_Fonts.php @@ -55,10 +55,10 @@ class Model_Required_Fonts extends Helper_Abstract_Model { protected $log; /** - * @var string[]|null + * @var array>|null * @since 6.19.0 */ - protected $selected_font_keys; + protected $font_pdfs; /** * Work out the required fonts from the font sources and the saved settings, logging forms it can't read @@ -77,7 +77,7 @@ public function __construct( Model_Font_Sources $font_sources, Helper_Abstract_O * @since 6.19.0 */ public function flush(): void { - $this->selected_font_keys = null; + $this->font_pdfs = null; } /** @@ -114,15 +114,9 @@ public function get_font_files( $key ): array { * @since 6.19.0 */ public function get_required_font_files(): array { - $keys = array_merge( - [ $this->normalise_font( $this->options->get_option( 'default_font', '' ) ) ], - $this->font_sources->get_language_font_keys(), - $this->get_selected_font_keys() - ); - $files = []; - foreach ( array_unique( $keys ) as $key ) { - $files = array_merge( $files, $this->get_font_files( $key ) ); + foreach ( array_keys( $this->get_font_reasons() ) as $key ) { + $files = array_merge( $files, $this->get_font_files( (string) $key ) ); } foreach ( $this->font_sources->get_sources() as $id => $source ) { @@ -134,6 +128,106 @@ public function get_required_font_files(): array { return array_values( array_unique( $files ) ); } + /** + * Why each required font key is required: the default font, a site language's, selected by that many PDFs, in a + * required source and, with $network, used by that many other sites + * + * @return array + * + * @since 6.19.0 + */ + public function get_font_reasons( bool $network = false ): array { + $none = [ + 'default' => false, + 'language' => false, + 'pdfs' => 0, + 'source' => false, + 'sites' => 0, + ]; + $reasons = $this->get_settings_font_reasons(); + + foreach ( array_map( 'count', $this->get_font_pdfs() ) as $key => $count ) { + $reasons[ $key ]['pdfs'] = $count; + } + + foreach ( $network ? $this->get_other_site_font_counts() : [] as $key => $count ) { + $reasons[ $key ]['sites'] = $count; + } + + return array_map( + static function ( array $reason ) use ( $none ): array { + return array_merge( $none, $reason ); + }, + $reasons + ); + } + + /** + * Whether anything requires the font key, so deleting it would break or re-download it + * + * @since 6.19.0 + */ + public function is_font_in_use( string $key, bool $network = false ): bool { + if ( isset( $this->get_settings_font_reasons()[ $key ] ) || isset( $this->font_pdfs[ $key ] ) ) { + return true; + } + + if ( $this->font_pdfs === null && $this->yields_font( $this->each_pdf_font(), $key ) ) { + return true; + } + + foreach ( $network ? $this->get_other_site_ids() : [] as $site_id ) { + if ( $this->yields_font( $this->each_site_font_key( $site_id ), $key ) ) { + return true; + } + } + + return false; + } + + /** + * Whether a generator keyed by font key reaches the font, reading no further than it + * + * @since 6.19.0 + */ + protected function yields_font( \Generator $fonts, string $key ): bool { + foreach ( $fonts as $font => $unused ) { + if ( $font === $key ) { + return true; + } + } + + return false; + } + + /** + * The reasons that need no form read: the default font, a site language's and a required source's + * + * @return array> + * + * @since 6.19.0 + */ + protected function get_settings_font_reasons(): array { + $reasons = []; + + $default = $this->normalise_font( $this->options->get_option( 'default_font', '' ) ); + if ( $default !== '' ) { + $reasons[ $default ]['default'] = true; + } + + foreach ( $this->font_sources->get_language_font_keys() as $key ) { + $reasons[ $key ]['language'] = true; + } + + foreach ( $this->font_sources->get_sources() as $source ) { + foreach ( empty( $source['required'] ) ? [] : array_keys( $source['families'] ?? [] ) as $key ) { + $reasons[ $key ]['source'] = true; + } + } + + return $reasons; + } + /** * The registered fonts among a PDF's settings, so template font pickers count too * @@ -267,60 +361,172 @@ public function get_form_pdfs( $display_meta, int $form_id ): array { } /** - * The fonts selected in every active form's PDFs + * The active forms' PDFs that select each font, read once per request * - * @return string[] + * @return array> PDFs by font key + * + * @since 6.19.0 + */ + public function get_font_pdfs(): array { + if ( $this->font_pdfs !== null ) { + return $this->font_pdfs; + } + + $pdfs = []; + + foreach ( $this->each_pdf_font() as $font => list( $row, $pdf_id, $pdf ) ) { + $pdfs[ $font ][] = [ + 'form' => (string) ( $row['title'] ?? '' ), + 'name' => is_string( $pdf['name'] ?? null ) ? $pdf['name'] : '', + 'url' => admin_url( sprintf( 'admin.php?page=gf_edit_forms&view=settings&subview=PDF&id=%d&pid=%s', (int) $row['form_id'], rawurlencode( $pdf_id ) ) ), + ]; + } + + $this->font_pdfs = $pdfs; + + return $pdfs; + } + + /** + * Each font selected by each PDF of a site's active forms, read lazily so a caller can stop at the one it wants + * + * @param string $prefix Another site's table prefix, or '' for this site + * + * @return \Generator Font key => form row, PDF ID and PDF settings * * @since 6.19.0 */ - protected function get_selected_font_keys(): array { - if ( $this->selected_font_keys === null ) { - $keys = []; - - /* A page at a time, so a site with many forms doesn't hold every form's meta at once */ - $offset = 0; - do { - $page = $this->get_pdf_form_meta( $offset, $this->form_page ); - foreach ( $page as $row ) { - foreach ( $this->get_form_pdfs( $row['display_meta'], (int) $row['form_id'] ) as $pdf ) { - $keys = array_merge( $keys, $this->get_pdf_fonts( $pdf ) ); + protected function each_pdf_font( string $prefix = '' ): \Generator { + /* A page at a time, so a site with many forms doesn't hold every form's meta at once */ + $after = 0; + do { + $page = $this->get_pdf_form_meta( $after, $this->form_page, $prefix ); + foreach ( $page as $row ) { + $after = (int) $row['form_id']; + foreach ( $this->get_form_pdfs( $row['display_meta'], (int) $row['form_id'] ) as $pdf_id => $pdf ) { + foreach ( $this->get_pdf_fonts( $pdf ) as $font ) { + yield $font => [ $row, (string) $pdf_id, $pdf ]; } } + } + + $full = count( $page ) === $this->form_page; + } while ( $full ); + } + + /** + * How many other sites of the network use each font. Read without switch_to_blog(), so no other site's plugins or + * filters run. + * + * @return array + * + * @since 6.19.0 + */ + protected function get_other_site_font_counts(): array { + $counts = []; + + foreach ( $this->get_other_site_ids() as $site_id ) { + foreach ( $this->get_site_font_keys( $site_id ) as $key ) { + $counts[ $key ] = ( $counts[ $key ] ?? 0 ) + 1; + } + } + + return $counts; + } + + /** + * @return int[] Every other site of the network, or none when not a Multisite + * + * @since 6.19.0 + */ + protected function get_other_site_ids(): array { + if ( ! is_multisite() ) { + return []; + } - $offset += $this->form_page; - $full = count( $page ) === $this->form_page; - } while ( $full ); + return array_map( + 'intval', + get_sites( + [ + 'fields' => 'ids', + 'number' => 0, + 'site__not_in' => [ get_current_blog_id() ], + ] + ) + ); + } - $this->selected_font_keys = array_values( array_unique( $keys ) ); + /** + * The fonts a site uses (its default font, locale and PDF fonts), read from its tables by prefix rather than by + * switching to it + * + * @return string[] Font keys + * + * @since 6.19.0 + */ + public function get_site_font_keys( int $site_id ): array { + return array_map( 'strval', array_keys( iterator_to_array( $this->each_site_font_key( $site_id ) ) ) ); + } + + /** + * Each font key a site uses, the settings' before its forms' so a caller can stop without reading them + * + * @return \Generator Keyed by font key, which may repeat + * + * @since 6.19.0 + */ + protected function each_site_font_key( int $site_id ): \Generator { + global $wpdb; + + $prefix = $wpdb->get_blog_prefix( $site_id ); + $options = $wpdb->get_results( "SELECT option_name, option_value FROM {$prefix}options WHERE option_name IN ( 'gfpdf_settings', 'gf_db_version', 'WPLANG' )", OBJECT_K ); //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $options = wp_list_pluck( (array) $options, 'option_value' ); + + $settings = maybe_unserialize( $options['gfpdf_settings'] ?? '' ); + $default = is_array( $settings ) ? ( $settings['default_font'] ?? '' ) : ''; + $locales = $this->font_sources->map_language_font_locales( [ $options['WPLANG'] ?? '' ] ); + + foreach ( array_merge( array_map( 'strval', array_keys( $locales ) ), [ $default ] ) as $key ) { + $key = $this->normalise_font( $key ); + if ( $key !== '' ) { + yield $key => true; + } } - return $this->selected_font_keys; + /* Gravity Forms records its version as it creates a site's tables, so a site without one has no forms */ + if ( isset( $options['gf_db_version'] ) ) { + yield from $this->each_pdf_font( $prefix ); + } } /** - * A page of the stored meta of the active forms with PDFs, read raw so Gravity Forms doesn't build their fields + * A page of the stored meta of the active forms with PDFs, read raw so Gravity Forms doesn't build their fields. + * Pages by ID, as OFFSET would run the LIKE over every form it skips. The LIKE compares bytes, about 6x faster than + * the column's collation. + * + * @param string $prefix Another site's table prefix, or '' for this site * * @return array[] Rows of `form_id` and `display_meta` * * @since 6.19.0 */ - protected function get_pdf_form_meta( int $offset, int $limit ): array { + protected function get_pdf_form_meta( int $after_id, int $limit, string $prefix = '' ): array { global $wpdb; - $meta_table = GFFormsModel::get_meta_table_name(); - $form_table = GFFormsModel::get_form_table_name(); + $meta_table = $prefix === '' ? GFFormsModel::get_meta_table_name() : $prefix . 'gf_form_meta'; + $form_table = $prefix === '' ? GFFormsModel::get_form_table_name() : $prefix . 'gf_form'; /* The table names come from Gravity Forms */ //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $wpdb->get_results( //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( - "SELECT meta.form_id, meta.display_meta FROM {$meta_table} meta + "SELECT meta.form_id, meta.display_meta, form.title FROM {$meta_table} meta INNER JOIN {$form_table} form ON form.id = meta.form_id - WHERE form.is_active = 1 AND form.is_trash = 0 AND meta.display_meta LIKE %s - ORDER BY meta.form_id LIMIT %d OFFSET %d", + WHERE form.is_active = 1 AND form.is_trash = 0 AND meta.form_id > %d AND meta.display_meta LIKE CAST( %s AS BINARY ) + ORDER BY meta.form_id LIMIT %d", + $after_id, '%' . $wpdb->esc_like( '"gfpdf_form_settings"' ) . '%', - $limit, - $offset + $limit ), ARRAY_A ); diff --git a/src/assets/js/react/actions/fontManager.js b/src/assets/js/react/actions/fontManager.js index 965e98aea..3e857ebfc 100644 --- a/src/assets/js/react/actions/fontManager.js +++ b/src/assets/js/react/actions/fontManager.js @@ -10,15 +10,19 @@ export const EDIT_FONT_SUCCESS = 'EDIT_FONT_SUCCESS'; export const EDIT_FONT_ERROR = 'EDIT_FONT_ERROR'; export const VALIDATION_ERROR = 'VALIDATION_ERROR'; export const DELETE_VARIANT_ERROR = 'DELETE_VARIANT_ERROR'; +export const GET_FONT_USAGE_SUCCESS = 'GET_FONT_USAGE_SUCCESS'; +export const GET_FONT_USAGE_ERROR = 'GET_FONT_USAGE_ERROR'; export const DELETE_FONT = 'DELETE_FONT'; export const DELETE_FONT_SUCCESS = 'DELETE_FONT_SUCCESS'; export const DELETE_FONT_ERROR = 'DELETE_FONT_ERROR'; +export const INSTALL_FONT = 'INSTALL_FONT'; +export const INSTALL_FONT_SUCCESS = 'INSTALL_FONT_SUCCESS'; +export const INSTALL_FONT_ERROR = 'INSTALL_FONT_ERROR'; export const CLEAR_ADD_FONT_MSG = 'CLEAR_ADD_FONT_MSG'; export const CLEAR_DROPZONE_ERROR = 'CLEAR_DROPZONE_ERROR'; export const RESET_SEARCH_RESULT = 'RESET_SEARCH_RESULT'; export const SEARCH_FONT_LIST = 'SEARCH_FONT_LIST'; export const SELECT_FONT = 'SELECT_FONT'; -export const MOVE_SELECTED_FONT_TO_TOP = 'MOVE_SELECTED_FONT_TO_TOP'; /** * @package Gravity PDF @@ -105,15 +109,33 @@ export const deleteVariantError = (fontVariant) => { * Delete font request * * @param { string } id + * @param { string } fontType The row's `type`, which picks the endpoint * - * @return {{ payload: string, type: string }} action object + * @return {{ payload: string, fontType: string, type: string }} action object * * @since 6.0 */ -export const deleteFont = (id) => { +export const deleteFont = (id, fontType) => { return { type: DELETE_FONT, payload: id, + fontType, + }; +}; + +/** + * Download a core font + * + * @param { string } id + * + * @return {{ payload: string, type: string }} action object + * + * @since 6.19.0 + */ +export const installFont = (id) => { + return { + type: INSTALL_FONT, + payload: id, }; }; @@ -190,17 +212,3 @@ export const selectFont = (fontId) => { payload: fontId, }; }; - -/** - * Move selected font to top - * - * @param { string } fontId - * - * @return {{ payload: string, type: string }} action object - */ -export const moveSelectedFontToTop = (fontId) => { - return { - type: MOVE_SELECTED_FONT_TO_TOP, - payload: fontId, - }; -}; diff --git a/src/assets/js/react/api/fontManager.js b/src/assets/js/react/api/fontManager.js index 394911c0e..f80c63609 100644 --- a/src/assets/js/react/api/fontManager.js +++ b/src/assets/js/react/api/fontManager.js @@ -2,6 +2,8 @@ import { serialize } from 'object-to-formdata'; /* APIs */ import { api, getJsonString } from './api'; +/* Utilities */ +import { updateURLParameter } from '../../admin/settings/common/updateURLParameter'; /** * @package Gravity PDF @@ -21,34 +23,84 @@ import { api, getJsonString } from './api'; */ /** - * Fetch API request to obtain custom font list (GET) + * @typedef {FontItem} InstalledFont + * @property {string} type - `custom`, `core` or `bundled` + * @property {boolean} can_delete - Whether this user can delete it when it's unused + * @property {boolean} [auto_install] - Core fonts: whether it downloads again on its own + * @property {boolean} [can_install] - Core fonts: whether this user can download its missing files + */ + +/** + * The parts of a response the sagas read * - * @return {{ body: Array, text: string, status: number, ok: boolean }} Installed custom fonts + * @param {Response} response * - * @since 6.0 + * @return {{ body: *, text: string, status: number, ok: boolean }} The parsed response + * + * @since 6.19.0 */ -export const apiGetCustomFontList = async () => { - const url = GFPDF.restUrl + 'fonts/'; - - const response = await api(url, { - method: 'GET', - headers: { - 'X-WP-Nonce': GFPDF.restNonce, - Accept: 'application/json', - }, - }); - +const parseResponse = async (response) => { const text = await response.text(); - const body = getJsonString(text); return { - body, + body: getJsonString(text), text, status: response.status, ok: response.ok, }; }; +/** + * A fonts endpoint URL with its scope + * + * @param {string} id + * @param {string} scope + * + * @return {string} The URL + * + * @since 6.19.0 + */ +const fontsUrl = (id, scope) => + updateURLParameter(GFPDF.restUrl + 'fonts/' + id, 'scope', scope); + +/** + * Fetch API GET request to the fonts endpoint + * + * @param {string} scope + * + * @return {{ body: *, text: string, status: number, ok: boolean }} The parsed response + * + * @since 6.19.0 + */ +const getFonts = async (scope) => + parseResponse( + await api(fontsUrl('', scope), { + method: 'GET', + headers: { + 'X-WP-Nonce': GFPDF.restNonce, + Accept: 'application/json', + }, + }) + ); + +/** + * Fetch API request to obtain every font the Font Manager lists (GET) + * + * @return {{ body: Array, text: string, status: number, ok: boolean }} Custom, core and bundled fonts + * + * @since 6.0 + */ +export const apiGetCustomFontList = () => getFonts('all'); + +/** + * Fetch API request for why fonts are in use, which is slower than the list as it reads every form (GET) + * + * @return {{ body: Object>, text: string, status: number, ok: boolean }} Reasons by font ID + * + * @since 6.19.0 + */ +export const apiGetFontUsage = () => getFonts('usage'); + /** * Fetch API request to add new font (POST) * @@ -71,15 +123,7 @@ export const apiAddFont = async (font) => { body: formData, }); - const text = await response.text(); - const body = getJsonString(text); - - return { - body, - text, - status: response.status, - ok: response.ok, - }; + return parseResponse(response); }; /** @@ -107,33 +151,52 @@ export const apiEditFont = async ({ id, font }) => { body: formData, }); - const text = await response.text(); - const body = getJsonString(text); - - return { - body, - text, - status: response.status, - ok: response.ok, - }; + return parseResponse(response); }; /** - * Fetch API request to delete existing font (DELETE) + * Send a bodyless request about a font * - * @param {string} id - ID of the font to delete + * @param {string} method + * @param {string} url * - * @return {Promise} Raw fetch Response + * @return {{ body: Object, text: string, status: number, ok: boolean }} The response * - * @since 6.0 + * @since 6.19.0 */ -export const apiDeleteFont = (id) => { - const url = GFPDF.restUrl + 'fonts/' + id; - - return api(url, { - method: 'DELETE', +const sendFontRequest = async (method, url) => { + const response = await api(url, { + method, headers: { 'X-WP-Nonce': GFPDF.restNonce, + Accept: 'application/json', }, }); + + return parseResponse(response); }; + +/** + * Fetch API request to download a core font (POST) + * + * @param {string} id + * + * @return {{ body: Object, text: string, status: number, ok: boolean }} The response, with the font's row or why it failed + * + * @since 6.19.0 + */ +export const apiInstallFont = (id) => + sendFontRequest('POST', fontsUrl(id, 'core')); + +/** + * Fetch API request to delete existing font (DELETE) + * + * @param {string} id - ID of the font to delete + * @param {string} fontType - The row's `type` + * + * @return {{ body: Object, text: string, status: number, ok: boolean }} The response, with the server's reason when it refuses + * + * @since 6.0 + */ +export const apiDeleteFont = (id, fontType) => + sendFontRequest('DELETE', fontsUrl(id, fontType)); diff --git a/src/assets/js/react/components/FontManager/AddFont.js b/src/assets/js/react/components/FontManager/AddFont.js index 42bd9f9a1..d8a4bb502 100644 --- a/src/assets/js/react/components/FontManager/AddFont.js +++ b/src/assets/js/react/components/FontManager/AddFont.js @@ -26,9 +26,6 @@ import AddUpdateFontFooter from './AddUpdateFontFooter'; * @param { boolean } props.validateRegular * @param { Object } props.msg * @param { boolean } props.loading - * @param { string } props.tabIndexFontName - * @param { string } props.tabIndexFontFiles - * @param { string } props.tabIndexFooterButtons * * @return {JSX.Element} AddFont component * @@ -40,22 +37,18 @@ export const AddFont = ({ onHandleUpload, onHandleDeleteFontStyle, onHandleSubmit, + onHandleCancelAddFont, fontStyles, validateLabel, validateRegular, msg, loading, - tabIndexFontName, - tabIndexFontFiles, - tabIndexFooterButtons, }) => { return (
-
+

{GFPDF.fontManagerAddTitle}

-

{GFPDF.fontManagerAddDesc}

- -

- {GFPDF.fontManagerFontNameDesc} -

- onHandleInputChange(e, 'addFont')} - tabIndex={tabIndexFontName} /> +

+ {GFPDF.fontManagerFontNameDesc} +

+
{!validateLabel && ( @@ -107,17 +100,17 @@ export const AddFont = ({ onHandleUpload={onHandleUpload} onHandleDeleteFontStyle={onHandleDeleteFontStyle} msg={msg} - tabIndex={tabIndexFontFiles} - /> - - + +
); }; @@ -133,14 +126,12 @@ AddFont.propTypes = { onHandleUpload: PropTypes.func.isRequired, onHandleDeleteFontStyle: PropTypes.func.isRequired, onHandleSubmit: PropTypes.func.isRequired, + onHandleCancelAddFont: PropTypes.func, validateLabel: PropTypes.bool.isRequired, validateRegular: PropTypes.bool.isRequired, fontStyles: PropTypes.object.isRequired, msg: PropTypes.object.isRequired, loading: PropTypes.bool.isRequired, - tabIndexFontName: PropTypes.string.isRequired, - tabIndexFontFiles: PropTypes.string.isRequired, - tabIndexFooterButtons: PropTypes.string.isRequired, }; export default AddFont; diff --git a/src/assets/js/react/components/FontManager/AddUpdateFontFooter.js b/src/assets/js/react/components/FontManager/AddUpdateFontFooter.js index 002b96078..5c2f17233 100644 --- a/src/assets/js/react/components/FontManager/AddUpdateFontFooter.js +++ b/src/assets/js/react/components/FontManager/AddUpdateFontFooter.js @@ -1,15 +1,10 @@ /* Dependencies */ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; import { sprintf } from 'sprintf-js'; /* Components */ +import FontDeleteButton from './FontDeleteButton'; import Spinner from '../Spinner'; -/* Redux actions */ -import { - selectFont as selectFontAction, - deleteFont as deleteFontAction, -} from '../../actions/fontManager'; import TemplateTooltip from './TemplateTooltip'; /** @@ -24,13 +19,12 @@ import TemplateTooltip from './TemplateTooltip'; * * @param state * @param id + * @param formId + * @param type * @param disabled * @param onHandleCancelEditFont - * @param onHandleCancelEditFontKeypress - * @param success - * @param error + * @param msg * @param loading - * @param tabIndex * * @since 6.0 */ @@ -43,95 +37,12 @@ export class AddUpdateFontFooter extends Component { static propTypes = { state: PropTypes.string, id: PropTypes.string, + formId: PropTypes.string, type: PropTypes.string, disabled: PropTypes.bool, onHandleCancelEditFont: PropTypes.func, - onHandleCancelEditFontKeypress: PropTypes.func, - selectedFont: PropTypes.string.isRequired, - selectFont: PropTypes.func.isRequired, - deleteFont: PropTypes.func, msg: PropTypes.object.isRequired, loading: PropTypes.bool.isRequired, - tabIndex: PropTypes.string.isRequired, - }; - - /** - * Handle the functionality to select and set default font type to be used in PDFs - * (Under 'update font' panel) - * - * @param { string } fontId - * @param { string } selectedFont - * - * @since 6.0 - */ - handleSelectFont = (fontId, selectedFont) => { - const { selectFont } = this.props; - - if (fontId === selectedFont) { - return selectFont(''); - } - - selectFont(fontId); - }; - - /** - * Handle the functionality to select and set default font type to be used in PDFs - * (Under 'update font' panel - Keyboard press) - * - * @param { KeyboardEvent } e - * @param { string } fontId - * @param { string } selectedFont - * - * @since 6.0 - */ - handleSelectFontKeypress = (e, fontId, selectedFont) => { - const enter = 'Enter'; - const space = ' '; - const { selectFont } = this.props; - - if (e.key === enter || e.key === space) { - if (fontId === selectedFont) { - return selectFont(''); - } - - selectFont(fontId); - } - }; - - /** - * Handle request of font deletion (Under 'update font' panel) - * - * @param { string } fontId - * - * @since 6.0 - */ - handleDeleteFont = (fontId) => { - /* Fire a native window alert box to confirm deletion request */ - if (window.confirm(GFPDF.fontManagerDeleteFontConfirmation)) { - /* Call redux action deleteFont */ - this.props.deleteFont(fontId); - } - }; - - /** - * Handle request of font deletion (Under 'update font' panel - Keyboard press) - * - * @param { KeyboardEvent } e - * @param { string } fontId - * - * @since 6.0 - */ - handleDeleteFontKeypress = (e, fontId) => { - const enter = 'Enter'; - const space = ' '; - - if (e.key === enter || e.key === space) { - /* Fire a native window alert box to confirm deletion request */ - if (window.confirm(GFPDF.fontManagerDeleteFontConfirmation)) { - /* Call redux action deleteFont */ - this.props.deleteFont(fontId); - } - } }; render() { @@ -139,15 +50,12 @@ export class AddUpdateFontFooter extends Component { state, type, id, + formId, disabled, onHandleCancelEditFont, - onHandleCancelEditFontKeypress, - selectedFont, msg: { success, error }, loading, - tabIndex, } = this.props; - const cancelButton = document.querySelector('.footer button.cancel'); const errorFontList = error && error.fontList; const successAddFont = success && success.addFont; const showSuccessAddFont = @@ -164,8 +72,6 @@ export class AddUpdateFontFooter extends Component { '', '' ); - const selectedBoxStyle = - id !== '' && id === selectedFont ? ' checked' : ' uncheck'; /* Display error message for uploading invalid font file */ const displayInvalidFileErrorMessage = errorAddFont && errorFontValidation; @@ -173,83 +79,8 @@ export class AddUpdateFontFooter extends Component { const displayGenericErrorMessage = errorAddFont && !errorFontValidation; return ( -