Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/learning-management-system/addons/certificate/CertificateAddon.php
+++ b/learning-management-system/addons/certificate/CertificateAddon.php
@@ -110,6 +110,8 @@
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
add_filter( 'masteriyo_register_post_types', array( $this, 'register_post_types' ) );
add_filter( 'masteriyo_admin_submenus', array( $this, 'add_submenus' ) );
+ add_action( 'masteriyo_activation', array( $this, 'clear_gutenberg_certs_cache' ) );
+ add_action( 'save_post_mto-certificate', array( $this, 'clear_gutenberg_certs_cache' ) );
add_action( 'masteriyo_single_course_sidebar_content_after_progress', array( $this, 'render_certificate_share_for_single_course_page' ) );
add_action( 'masteriyo_template_course_inside_progress', array( $this, 'render_certificate_share_for_single_course_page' ), 1, 1 );
@@ -121,6 +123,7 @@
add_filter( 'query_vars', array( $this, 'add_certificate_share_query_vars' ) );
add_action( 'template_redirect', array( $this, 'handle_certificate_share_preview' ), 10 );
+ add_action( 'masteriyo_pdfdraft_cert_email_fallback', array( $this, 'handle_pdfdraft_cert_email_fallback' ), 10, 2 );
}
/**
@@ -178,6 +181,11 @@
return;
}
+ if ( 'pdfdraft' === $certificate->get_content_format() ) {
+ $this->serve_pdfdraft_share_preview( $certificate, $course_id, $user_id );
+ return;
+ }
+
$certificate_pdf = new CertificatePDF( $course_id, $user_id, $certificate_html_content );
if ( ! $certificate_pdf || is_wp_error( $certificate_pdf ) ) {
@@ -189,6 +197,114 @@
}
/**
+ * Serve a PDFDraft certificate share preview as an HTML page.
+ *
+ * @since x.x.x
+ *
+ * @param MasteriyoAddonsCertificateModelsCertificate $certificate
+ * @param int $course_id
+ * @param int $user_id
+ */
+ protected function serve_pdfdraft_share_preview( $certificate, $course_id, $user_id ) {
+ $rendered_html = $certificate->get_rendered_html( 'edit' );
+
+ if ( empty( $rendered_html ) ) {
+ wp_die( esc_html__( 'This certificate has not been published yet. Please contact the site administrator.', 'learning-management-system' ) );
+ }
+
+ $certificate_pdf = new CertificatePDF( $course_id, $user_id, '', $certificate->get_id() );
+ $html = preg_replace( '/<scriptb[^>]*>[sS]*?</script>/i', '', $certificate_pdf->process_html_for_download( $rendered_html ) );
+
+ $json = json_decode( $certificate->get_html_content(), true );
+ $layout = isset( $json['settings']['layout'] ) ? $json['settings']['layout'] : array();
+
+ $width_in = (float) ( $layout['width'] ?? 11 );
+ $height_in = (float) ( $layout['height'] ?? 8.5 );
+ $unit = $layout['unit'] ?? 'in';
+ $dpi = 96;
+ $to_px = array(
+ 'in' => $dpi,
+ 'cm' => $dpi / 2.54,
+ 'mm' => $dpi / 25.4,
+ 'px' => 1,
+ );
+ $mult = $to_px[ $unit ] ?? $dpi;
+ $canvas_w = round( $width_in * $mult );
+ $canvas_h = round( $height_in * $mult );
+
+ $preview_font_links = '';
+ if ( isset( $json['pages'] ) ) {
+ $generic_fonts = array( 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'inherit', 'initial', 'unset', 'inter' );
+ $families = array();
+ array_walk_recursive(
+ $json['pages'],
+ function( $value, $key ) use ( &$families, $generic_fonts ) {
+ if ( 'fontFamily' === $key && is_string( $value ) && ! empty( $value ) ) {
+ $clean = trim( $value, " "'t" );
+ if ( ! in_array( strtolower( $clean ), $generic_fonts, true ) ) {
+ $families[ $clean ] = true;
+ }
+ }
+ }
+ );
+ foreach ( array_keys( $families ) as $family ) {
+ $encoded = rawurlencode( $family );
+ $preview_font_links .= '<link href="https://fonts.googleapis.com/css2?family=' . esc_attr( $encoded ) . ':wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet">' . "nttt"; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet
+ }
+ }
+
+ while ( ob_get_level() > 0 ) {
+ ob_end_clean();
+ }
+
+ header( 'Content-Type: text/html; charset=utf-8' );
+ // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
+ ?>
+ <!DOCTYPE html>
+ <html lang="<?php echo esc_attr( get_locale() ); ?>">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title><?php esc_html_e( 'Certificate Preview', 'learning-management-system' ); ?></title>
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet"> <?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- standalone HTML page, wp_enqueue_style() not applicable. ?>
+ <?php echo $preview_font_links; ?>
+ <style>
+ <?php echo $certificate_pdf->prepare_pdfdraft_css(); ?>
+ * { box-sizing: border-box; }
+ html, body {
+ margin: 0; padding: 0;
+ width: 100%; height: 100%;
+ overflow: auto;
+ background: #525659;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 16px;
+ }
+ .masteriyo-cert-wrap {
+ width: <?php echo (int) $canvas_w; ?>px;
+ height: <?php echo (int) $canvas_h; ?>px;
+ flex-shrink: 0;
+ box-shadow: 0 4px 24px rgba(0,0,0,0.5);
+ }
+ .masteriyo-cert-wrap > * {
+ width: 100% !important;
+ height: 100% !important;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="masteriyo-cert-wrap"><?php echo $html; ?></div>
+ </body>
+ </html>
+ <?php
+ // phpcs:enable
+ die();
+ }
+
+ /**
* Render certificate share button in single course page.
*
* @since 1.13.3
@@ -393,12 +509,87 @@
wp_die( esc_html__( 'Please complete the course to download the certificate.', 'learning-management-system' ) );
}
+ // PDFDraft certificates: generate PDF in the browser using PDFExporter.
+ if ( 'pdfdraft' === $certificate->get_content_format() ) {
+ $this->serve_pdfdraft_client_download( $certificate, $course );
+ return;
+ }
+
$certificate_pdf = new CertificatePDF( $course->get_id(), get_current_user_id(), $certificate->get_html_content() );
$certificate_pdf->serve_download();
}
}
/**
+ * Serve a pdfdraft certificate download page that generates the PDF client-side.
+ *
+ * PHP resolves all merge tags in the certificate JSON. The bundled
+ * masteriyo-pdfdraftCertDownload.js passes the resolved JSON to PDFExporter
+ * to produce the PDF in the student's browser.
+ *
+ * @since x.x.x
+ *
+ * @param MasteriyoAddonsCertificateModelsCertificate $certificate
+ * @param MasteriyoModelsCourse $course
+ */
+ protected function serve_pdfdraft_client_download( $certificate, $course ) {
+ $json = json_decode( $certificate->get_html_content(), true );
+
+ if ( empty( $json ) || ! isset( $json['pages'] ) ) {
+ wp_die( esc_html__( 'This certificate has not been published yet. Please contact the site administrator.', 'learning-management-system' ) );
+ }
+
+ $certificate_pdf = new CertificatePDF( $course->get_id(), get_current_user_id(), '', $certificate->get_id() );
+ $resolved_json = $certificate_pdf->resolve_pdfdraft_json_for_download( $json );
+
+ $student = masteriyo_get_user( get_current_user_id() );
+ $filename = sanitize_file_name(
+ sprintf(
+ '%s-%s-certificate.pdf',
+ $student && ! is_wp_error( $student ) ? $student->get_display_name() : 'student',
+ $course->get_name()
+ )
+ );
+
+ $script_url = masteriyo_is_production()
+ ? plugins_url( 'assets/js/build/masteriyo-pdfdraftCertDownload.js', MASTERIYO_PLUGIN_FILE )
+ : 'http://localhost:3000/dist/masteriyo-pdfdraftCertDownload.js';
+
+ $cert_data = array(
+ 'filename' => $filename,
+ 'json' => $resolved_json,
+ );
+
+ while ( ob_get_level() > 0 ) {
+ ob_end_clean();
+ }
+
+ header( 'Content-Type: text/html; charset=utf-8' );
+ // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
+ ?>
+ <!DOCTYPE html>
+ <html lang="<?php echo esc_attr( get_locale() ); ?>">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title><?php echo esc_html( $course->get_name() ); ?> — <?php esc_html_e( 'Certificate', 'learning-management-system' ); ?></title>
+ <style>
+ body { margin: 0; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: Arial, sans-serif; }
+ #masteriyo-cert-status { background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.1); padding: 32px 48px; text-align: center; color: #333; font-size: 16px; max-width: 480px; }
+ </style>
+ </head>
+ <body>
+ <div id="masteriyo-cert-status"><?php esc_html_e( 'Generating your certificate, please wait…', 'learning-management-system' ); ?></div>
+ <script>window.masteriyo_cert_download = <?php echo wp_json_encode( $cert_data ); ?>;</script>
+ <script src="<?php echo esc_url( $script_url ); ?>"></script> <?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- standalone HTML page, wp_enqueue_script() not applicable. ?>
+ </body>
+ </html>
+ <?php
+ // phpcs:enable
+ die();
+ }
+
+ /**
* Return true if the action schedule is enabled for Email.
*
* @since 1.13.0
@@ -568,7 +759,7 @@
array(
'backend' => array(
'data' => array(
- 'allowedBlockTypes' => array(
+ 'allowedBlockTypes' => array(
'core/paragraph',
'core/image',
'core/heading',
@@ -592,9 +783,11 @@
'masteriyo/student-name',
'masteriyo/course-completion-date',
),
- 'editorStyles' => function_exists( 'get_block_editor_theme_styles' ) ? get_block_editor_theme_styles() : (object) array(),
- 'editorSettings' => $editor_settings,
- 'certificate_samples' => masteriyo_get_certificate_templates(),
+ 'editorStyles' => function_exists( 'get_block_editor_theme_styles' ) ? get_block_editor_theme_styles() : (object) array(),
+ 'editorSettings' => $editor_settings,
+ 'certificate_samples' => masteriyo_get_certificate_templates(),
+ 'pdfdraft_assets_base_url' => plugins_url( 'addons/certificate/assets/', Constants::get( 'MASTERIYO_PLUGIN_FILE' ) ),
+ 'hasGutenbergCerts' => $this->has_gutenberg_certificates(),
),
),
)
@@ -754,6 +947,214 @@
if ( $controller ) {
$controller->register_routes();
}
+
+ register_rest_route(
+ 'masteriyo/pro/v1',
+ '/certificate-pdf-data',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'rest_get_certificate_pdf_data' ),
+ 'permission_callback' => function() {
+ return is_user_logged_in();
+ },
+ 'args' => array(
+ 'course_id' => array(
+ 'required' => true,
+ 'type' => 'integer',
+ 'minimum' => 1,
+ 'sanitize_callback' => 'absint',
+ ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ 'masteriyo/pro/v1',
+ '/certificate-pdf-email',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'rest_upload_certificate_pdf_email' ),
+ 'permission_callback' => function() {
+ return is_user_logged_in();
+ },
+ 'args' => array(
+ 'course_id' => array(
+ 'required' => true,
+ 'type' => 'integer',
+ 'minimum' => 1,
+ 'sanitize_callback' => 'absint',
+ ),
+ 'pdf_base64' => array(
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * REST handler: return the resolved PDFDraft certificate JSON for client-side PDF generation.
+ *
+ * @since x.x.x
+ *
+ * @param WP_REST_Request $request
+ * @return WP_REST_Response|WP_Error
+ */
+ public function rest_get_certificate_pdf_data( WP_REST_Request $request ) {
+ $course_id = (int) $request->get_param( 'course_id' );
+ $user_id = get_current_user_id();
+
+ $certificate_id = masteriyo_get_course_certificate_id( $course_id );
+ if ( ! $certificate_id ) {
+ return new WP_Error( 'no_certificate', __( 'No certificate for this course.', 'learning-management-system' ), array( 'status' => 404 ) );
+ }
+
+ $certificate = masteriyo_get_certificate( $certificate_id );
+ if ( ! $certificate || is_wp_error( $certificate ) || 'pdfdraft' !== $certificate->get_content_format() ) {
+ return new WP_Error( 'not_pdfdraft', __( 'Certificate is not a PDFDraft certificate.', 'learning-management-system' ), array( 'status' => 400 ) );
+ }
+
+ $course = masteriyo_get_course( $course_id );
+ if ( ! $course ) {
+ return new WP_Error( 'no_course', __( 'Course not found.', 'learning-management-system' ), array( 'status' => 404 ) );
+ }
+
+ if ( ! masteriyo_is_current_user_admin() && ! masteriyo_is_current_user_instructor() ) {
+ if ( ! masteriyo_user_has_completed_course( $course, $user_id ) ) {
+ return new WP_Error( 'not_completed', __( 'You must complete the course to download its certificate.', 'learning-management-system' ), array( 'status' => 403 ) );
+ }
+ }
+
+ $raw_json = json_decode( $certificate->get_html_content(), true );
+ if ( empty( $raw_json ) || ! isset( $raw_json['pages'] ) ) {
+ return new WP_Error( 'invalid_json', __( 'Certificate JSON could not be resolved.', 'learning-management-system' ), array( 'status' => 500 ) );
+ }
+
+ $pdf = new CertificatePDF( $course_id, $user_id, '', $certificate_id );
+ $json = $pdf->resolve_pdfdraft_json_for_download( $raw_json );
+
+ $student = masteriyo_get_user( $user_id );
+ $full_name = $student ? trim( $student->get_first_name() . ' ' . $student->get_last_name() ) : '';
+ if ( ! $full_name && $student ) {
+ $full_name = $student->get_display_name();
+ }
+ $filename = sanitize_file_name(
+ sprintf( '%s - %s.pdf', $course->get_name(), $full_name ? $full_name : __( 'Certificate', 'learning-management-system' ) )
+ );
+
+ return rest_ensure_response(
+ array(
+ 'filename' => $filename,
+ 'json' => $json,
+ )
+ );
+ }
+
+ /**
+ * REST handler: receive base64-encoded PDF from browser, store it, and send the completion email with attachment.
+ *
+ * @since x.x.x
+ *
+ * @param WP_REST_Request $request
+ * @return WP_REST_Response|WP_Error
+ */
+ public function rest_upload_certificate_pdf_email( WP_REST_Request $request ) {
+ $course_id = (int) $request->get_param( 'course_id' );
+ $user_id = get_current_user_id();
+
+ $transient_key = 'masteriyo_pdfdraft_cert_email_' . $course_id . '_' . $user_id;
+ $pending = get_transient( $transient_key );
+
+ if ( ! is_array( $pending ) ) {
+ return new WP_Error( 'no_pending_email', __( 'No pending certificate email for this course.', 'learning-management-system' ), array( 'status' => 404 ) );
+ }
+
+ $pdf_data = base64_decode( $request->get_param( 'pdf_base64' ), true );
+ if ( ! $pdf_data ) {
+ return new WP_Error( 'invalid_pdf', __( 'Invalid PDF data.', 'learning-management-system' ), array( 'status' => 400 ) );
+ }
+
+ if ( 0 !== strncmp( $pdf_data, '%PDF-', 5 ) ) {
+ return new WP_Error( 'invalid_pdf', __( 'Uploaded file is not a valid PDF.', 'learning-management-system' ), array( 'status' => 400 ) );
+ }
+ if ( strlen( $pdf_data ) > 20 * MB_IN_BYTES ) {
+ return new WP_Error( 'pdf_too_large', __( 'The certificate PDF exceeds the maximum allowed size.', 'learning-management-system' ), array( 'status' => 400 ) );
+ }
+
+ $upload_dir = wp_upload_dir();
+ $cert_dir = trailingslashit( $upload_dir['basedir'] ) . 'masteriyo/temp-certs/';
+ wp_mkdir_p( $cert_dir );
+
+ $pdf_path = $cert_dir . 'cert-' . $course_id . '-' . $user_id . '-' . time() . '-' . wp_generate_password( 8, false ) . '.pdf';
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ if ( false === file_put_contents( $pdf_path, $pdf_data ) ) {
+ return new WP_Error( 'save_failed', __( 'Could not save PDF file.', 'learning-management-system' ), array( 'status' => 500 ) );
+ }
+
+ // Update transient with the PDF path so trigger() can find it.
+ set_transient( $transient_key, array_merge( $pending, array( 'pdf_path' => $pdf_path ) ), 10 * MINUTE_IN_SECONDS );
+
+ // Cancel the fallback cron — the PDF is ready.
+ $scheduled = wp_next_scheduled( 'masteriyo_pdfdraft_cert_email_fallback', array( $course_id, $user_id ) );
+ if ( $scheduled ) {
+ wp_unschedule_event( $scheduled, 'masteriyo_pdfdraft_cert_email_fallback', array( $course_id, $user_id ) );
+ }
+
+ // Re-trigger the completion email — trigger() will find the PDF path in the transient.
+ $course_progress = masteriyo_get_course_progress_by_user_and_course( $user_id, $course_id );
+ if ( ! $course_progress ) {
+ delete_transient( $transient_key );
+ @unlink( $pdf_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ return new WP_Error( 'no_progress', __( 'Course progress not found.', 'learning-management-system' ), array( 'status' => 404 ) );
+ }
+
+ $email = new MasteriyoEmailsStudentCourseCompletionEmailToStudent();
+ if ( $email->is_enabled() ) {
+ $email->trigger( $course_progress );
+ }
+
+ // Safety: clean up temp file if trigger() did not delete it.
+ if ( file_exists( $pdf_path ) ) {
+ @unlink( $pdf_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ }
+
+ return rest_ensure_response( array( 'success' => true ) );
+ }
+
+ /**
+ * Fallback cron: send the completion email without a PDF attachment if the browser
+ * never uploaded the PDF within the deferred window.
+ *
+ * @since x.x.x
+ *
+ * @param int $course_id Course ID.
+ * @param int $user_id Student user ID.
+ */
+ public function handle_pdfdraft_cert_email_fallback( $course_id, $user_id ) {
+ $transient_key = 'masteriyo_pdfdraft_cert_email_' . $course_id . '_' . $user_id;
+
+ if ( ! get_transient( $transient_key ) ) {
+ return; // Email already sent by the browser-upload path.
+ }
+
+ delete_transient( $transient_key );
+
+ $course_progress = masteriyo_get_course_progress_by_user_and_course( $user_id, $course_id );
+ if ( ! $course_progress ) {
+ return;
+ }
+
+ $email = new MasteriyoEmailsStudentCourseCompletionEmailToStudent();
+ if ( ! $email->is_enabled() ) {
+ return;
+ }
+
+ // Send without deferring again — bypasses the defer logic.
+ add_filter( 'masteriyo_defer_pdfdraft_cert_email', '__return_false' );
+ $email->trigger( $course_progress );
+ remove_filter( 'masteriyo_defer_pdfdraft_cert_email', '__return_false' );
}
/**
@@ -781,10 +1182,31 @@
* @return array
*/
public function add_submenus( $submenus ) {
+ if ( $this->has_gutenberg_certificates() ) {
+ return masteriyo_parse_args(
+ $submenus,
+ array(
+ 'certificates' => array(
+ 'page_title' => esc_html__( 'Certificates', 'learning-management-system' ),
+ 'menu_title' => esc_html__( 'Certificates', 'learning-management-system' ),
+ 'position' => 40,
+ 'capability' => 'edit_certificates',
+ ),
+ 'certificates-v2' => array(
+ 'page_title' => esc_html__( 'Certificate V2', 'learning-management-system' ),
+ 'menu_title' => '↳ ' . esc_html__( 'Certificate V2', 'learning-management-system' ),
+ 'position' => 41,
+ 'capability' => 'edit_certificates',
+ 'hide' => true,
+ ),
+ )
+ );
+ }
+
return masteriyo_parse_args(
$submenus,
array(
- 'certificates' => array(
+ 'certificates-v2' => array(
'page_title' => esc_html__( 'Certificates', 'learning-management-system' ),
'menu_title' => esc_html__( 'Certificates', 'learning-management-system' ),
'position' => 40,
@@ -793,4 +1215,54 @@
)
);
}
+
+ /**
+ * Invalidate the cached Gutenberg-certificate flag.
+ *
+ * @since x.x.x
+ */
+ public function clear_gutenberg_certs_cache() {
+ delete_transient( 'masteriyo_has_gutenberg_certs' );
+ }
+
+ /**
+ * Check whether any certificate uses the old Gutenberg format.
+ *
+ * @since x.x.x
+ *
+ * @return bool
+ */
+ private function has_gutenberg_certificates() {
+ $cached = get_transient( 'masteriyo_has_gutenberg_certs' );
+
+ if ( false !== $cached ) {
+ return (bool) $cached;
+ }
+
+ $posts = get_posts(
+ array(
+ 'post_type' => 'mto-certificate',
+ 'post_status' => array( 'publish', 'draft' ),
+ 'posts_per_page' => 1,
+ 'fields' => 'ids',
+ 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ 'relation' => 'OR',
+ array(
+ 'key' => '_masteriyo_content_format',
+ 'value' => 'gutenberg',
+ 'compare' => '=',
+ ),
+ array(
+ 'key' => '_masteriyo_content_format',
+ 'compare' => 'NOT EXISTS',
+ ),
+ ),
+ )
+ );
+
+ $result = ! empty( $posts );
+ set_transient( 'masteriyo_has_gutenberg_certs', $result ? 1 : 0, HOUR_IN_SECONDS );
+
+ return $result;
+ }
}
--- a/learning-management-system/addons/certificate/Models/Certificate.php
+++ b/learning-management-system/addons/certificate/Models/Certificate.php
@@ -49,14 +49,16 @@
* @var array
*/
protected $data = array(
- 'name' => '',
- 'slug' => '',
- 'date_created' => null,
- 'date_modified' => null,
- 'status' => 'draft',
- 'html_content' => '',
- 'parent_id' => 0,
- 'author_id' => 0,
+ 'name' => '',
+ 'slug' => '',
+ 'date_created' => null,
+ 'date_modified' => null,
+ 'status' => 'draft',
+ 'html_content' => '',
+ 'parent_id' => 0,
+ 'author_id' => 0,
+ 'content_format' => 'gutenberg',
+ 'rendered_html' => '',
);
/**
@@ -378,4 +380,51 @@
public function set_author_id( $author_id ) {
$this->set_prop( 'author_id', absint( $author_id ) );
}
+
+ /**
+ * Get content format ('gutenberg' or 'pdfdraft').
+ *
+ * @since x.x.x
+ *
+ * @param string $context
+ * @return string
+ */
+ public function get_content_format( $context = 'view' ) {
+ return $this->get_prop( 'content_format', $context );
+ }
+
+ /**
+ * Set content format.
+ *
+ * @since x.x.x
+ *
+ * @param string $format 'gutenberg' or 'pdfdraft'.
+ */
+ public function set_content_format( $format ) {
+ $allowed = array( 'gutenberg', 'pdfdraft' );
+ $this->set_prop( 'content_format', in_array( $format, $allowed, true ) ? $format : 'gutenberg' );
+ }
+
+ /**
+ * Get rendered HTML snapshot (used for PDF generation in pdfdraft format).
+ *
+ * @since x.x.x
+ *
+ * @param string $context
+ * @return string
+ */
+ public function get_rendered_html( $context = 'view' ) {
+ return $this->get_prop( 'rendered_html', $context );
+ }
+
+ /**
+ * Set rendered HTML snapshot.
+ *
+ * @since x.x.x
+ *
+ * @param string $rendered_html
+ */
+ public function set_rendered_html( $rendered_html ) {
+ $this->set_prop( 'rendered_html', $rendered_html );
+ }
}
--- a/learning-management-system/addons/certificate/PDF/CertificatePDF.php
+++ b/learning-management-system/addons/certificate/PDF/CertificatePDF.php
@@ -2,7 +2,7 @@
/**
* Certificate PDF builder class.
*
- * @since 1.13.0
+ * @since 2.3.7
*/
namespace MasteriyoAddonsCertificatePDF;
@@ -13,13 +13,16 @@
use MpdfMpdf;
use MpdfHTMLParserMode;
use MpdfOutputDestination;
+use chillerlanQRCodeQRCode;
+use chillerlanQRCodeQROptions;
use MasteriyoAddonsCertificateModelsSetting;
+use MasteriyoQueryCourseProgressQuery;
class CertificatePDF {
/**
* The Mpdf instance.
*
- * @since 1.13.0
+ * @since 2.3.7
*
* @var MpdfMpdf
*/
@@ -28,7 +31,7 @@
/**
* Course ID.
*
- * @since 1.13.0
+ * @since 2.3.7
*
* @var integer
*/
@@ -37,7 +40,7 @@
/**
* Student ID.
*
- * @since 1.13.0
+ * @since 2.3.7
*
* @var integer
*/
@@ -46,7 +49,7 @@
/**
* Certificate template html.
*
- * @since 1.13.0
+ * @since 2.3.7
*
* @var string
*/
@@ -69,116 +72,190 @@
/**
* True if the certificate preview is being generated. Otherwise false.
*
- * @since 1.13.0
+ * @since 2.4.4
*
* @var boolean
*/
protected $preview = false;
/**
+ * Certificate ID (optional, used for pdfdraft format).
+ *
+ * @since x.x.x
+ *
+ * @var integer|null
+ */
+ protected $certificate_id = null;
+
+ /**
+ * Preview HTML override — set when generating a one-time preview from the designer.
+ * When set, prepare_pdf_pdfdraft() uses this instead of the DB-stored rendered_html.
+ *
+ * @since x.x.x
+ *
+ * @var string|null
+ */
+ protected $preview_rendered_html = null;
+
+ /**
* Constructor.
*
- * @since 1.13.0
+ * @since 2.3.7
*
* @param integer $course_id
* @param integer $student_id
* @param string $template
+ * @param integer|null $certificate_id Optional. Required for pdfdraft format.
*/
- public function __construct( $course_id, $student_id, $template ) {
+ public function __construct( $course_id, $student_id, $template, $certificate_id = null ) {
$this->set_course_id( $course_id );
$this->set_student_id( $student_id );
$this->set_template( $template );
+ $this->certificate_id = $certificate_id ? absint( $certificate_id ) : null;
}
/**
- * Initialize mpdf.
+ * Extract page dimensions from a pdfdraft certificate's JSON layout, returned
+ * as an array suitable for passing to init_mpdf().
+ *
+ * @since x.x.x
*
- * @since 1.13.0
+ * @param MasteriyoAddonsCertificateModelsCertificate $certificate
+ * @return array{format: float[]}
*/
- public function init_mpdf() {
+ protected function extract_pdfdraft_layout( $certificate ): array {
+ $json = json_decode( $certificate->get_html_content(), true );
+ $layout = $json['settings']['layout'] ?? array();
+
+ $width = (float) ( $layout['width'] ?? 11 );
+ $height = (float) ( $layout['height'] ?? 8.5 );
+ $unit = $layout['unit'] ?? 'in';
+ $orient = $layout['orientation'] ?? 'landscape';
+
+ $to_mm = array(
+ 'in' => 25.4,
+ 'cm' => 10.0,
+ 'mm' => 1.0,
+ 'px' => 0.2646,
+ );
+ $mult = $to_mm[ $unit ] ?? 25.4;
+
+ // Return [width_mm, height_mm] as-is — do NOT set mPDF orientation
+ // separately, as it would swap the dimensions and cause layout issues.
+ return array(
+ 'format' => array( round( $width * $mult, 2 ), round( $height * $mult, 2 ) ),
+ );
+ }
+
+ public function init_mpdf( array $page_format = array() ) {
if ( $this->mpdf instanceof Mpdf ) {
return;
}
$upload_dir = wp_upload_dir();
- $font_dirs = ( new MpdfConfigConfigVariables() )->getDefaults()['fontDir'];
- $font_dirs[] = $upload_dir['basedir'] . '/masteriyo/certificate-fonts';
+ $font_dirs = ( new MpdfConfigConfigVariables() )->getDefaults()['fontDir'];
+ $font_dirs = array_merge( $font_dirs, array( $upload_dir['basedir'] . '/masteriyo/certificate-fonts' ) );
$default_font_config = ( new MpdfConfigFontVariables() )->getDefaults();
$fontdata = $default_font_config['fontdata'];
-
- $this->mpdf = new Mpdf(
- array(
- 'tempDir' => masteriyo_get_temp_dir() . '/mpdf',
- 'fontDir' => $font_dirs,
- 'margin_left' => 0,
- 'margin_right' => 0,
- 'margin_top' => 0,
- 'margin_bottom' => 0,
- 'default_font' => 'Arial, sans-serif',
- 'autoScriptToLang' => true,
- 'autoLangToFont' => true,
- 'fontdata' => $fontdata + array(
- 'cinzel' => array(
- 'R' => 'Cinzel-VariableFont_wght.ttf',
- ),
- 'dejavusanscondensed' => array(
- 'R' => 'DejaVuSansCondensed.ttf',
- 'B' => 'DejaVuSansCondensed-Bold.ttf',
- ),
- 'dmsans' => array(
- 'R' => 'DMSans-Regular.ttf',
- 'B' => 'DMSans-Bold.ttf',
- 'I' => 'DMSans-Italic.ttf',
- ),
- 'greatvibes' => array(
- 'R' => 'GreatVibes-Regular.ttf',
- ),
- 'grenzegotisch' => array(
- 'R' => 'GrenzeGotisch-VariableFont_wght.ttf',
- ),
- 'librebaskerville' => array(
- 'R' => 'LibreBaskerville-Regular.ttf',
- 'B' => 'LibreBaskerville-Bold.ttf',
- 'I' => 'LibreBaskerville-Italic.ttf',
- ),
- 'lora' => array(
- 'R' => 'Lora-VariableFont_wght.ttf',
- 'I' => 'Lora-Italic-VariableFont_wght.ttf',
- ),
- 'poppins' => array(
- 'R' => 'Poppins-Regular.ttf',
- 'B' => 'Poppins-Bold.ttf',
- 'I' => 'Poppins-Italic.ttf',
- ),
- 'roboto' => array(
- 'R' => 'Roboto-Regular.ttf',
- 'B' => 'Roboto-Bold.ttf',
- 'I' => 'Roboto-Italic.ttf',
- ),
- 'abhayalibre' => array(
- 'R' => 'AbhayaLibre-Regular.ttf',
- 'B' => 'AbhayaLibre-Bold.ttf',
- ),
- 'adinekirnberg' => array(
- 'R' => 'AdineKirnberg.ttf',
- ),
- 'alexbrush' => array(
- 'R' => 'AlexBrush-Regular.ttf',
- ),
- 'allura' => array(
- 'R' => 'Allura-Regular.ttf',
- ),
- ),
- )
+ $fontdata = $fontdata + array(
+ 'cinzel' => array(
+ 'R' => 'Cinzel-VariableFont_wght.ttf',
+ ),
+ 'dejavusanscondensed' => array(
+ 'R' => 'DejaVuSansCondensed.ttf',
+ 'B' => 'DejaVuSansCondensed-Bold.ttf',
+ ),
+ 'dmsans' => array(
+ 'R' => 'DMSans-Regular.ttf',
+ 'B' => 'DMSans-Bold.ttf',
+ 'I' => 'DMSans-Italic.ttf',
+ ),
+ 'greatvibes' => array(
+ 'R' => 'GreatVibes-Regular.ttf',
+ ),
+ 'grenzegotisch' => array(
+ 'R' => 'GrenzeGotisch-VariableFont_wght.ttf',
+ ),
+ 'librebaskerville' => array(
+ 'R' => 'LibreBaskerville-Regular.ttf',
+ 'B' => 'LibreBaskerville-Bold.ttf',
+ 'I' => 'LibreBaskerville-Italic.ttf',
+ ),
+ 'lora' => array(
+ 'R' => 'Lora-VariableFont_wght.ttf',
+ 'I' => 'Lora-Italic-VariableFont_wght.ttf',
+ ),
+ 'poppins' => array(
+ 'R' => 'Poppins-Regular.ttf',
+ 'B' => 'Poppins-Bold.ttf',
+ 'I' => 'Poppins-Italic.ttf',
+ ),
+ 'roboto' => array(
+ 'R' => 'Roboto-Regular.ttf',
+ 'B' => 'Roboto-Bold.ttf',
+ 'I' => 'Roboto-Italic.ttf',
+ ),
+ 'abhayalibre' => array(
+ 'R' => 'AbhayaLibre-Regular.ttf',
+ 'B' => 'AbhayaLibre-Bold.ttf',
+ ),
+ 'adinekirnberg' => array(
+ 'R' => 'AdineKirnberg.ttf',
+ ),
+ 'alexbrush' => array(
+ 'R' => 'AlexBrush-Regular.ttf',
+ ),
+ 'allura' => array(
+ 'R' => 'Allura-Regular.ttf',
+ ),
+ 'notosansdevanagariextracondensedthin' => array(
+ 'R' => 'NotoSansDevanagari_ExtraCondensed-Thin.ttf',
+ ),
);
+ if ( function_exists( 'set_exception_handler' ) ) {
+ set_exception_handler(
+ function ( $e ) {
+ wp_die(
+ /* translators: %s: Error Message */
+ sprintf( esc_html__( 'Critical Error: %s', 'learning-management-system' ), esc_html( $e->getMessage() ) ),
+ esc_html__( 'Critical Error', 'learning-management-system' ),
+ array( 'response' => 400 )
+ );
+ }
+ );
+ }
+ $mpdf_config = array(
+ 'tempDir' => masteriyo_get_temp_dir() . '/mpdf',
+ 'fontDir' => $font_dirs,
+ 'margin_left' => 0,
+ 'margin_right' => 0,
+ 'margin_top' => 0,
+ 'margin_bottom' => 0,
+ 'default_font' => 'Arial, sans-serif',
+ 'autoScriptToLang' => false,
+ 'autoLangToFont' => true,
+ 'fontdata' => $fontdata + masteriyo_get_font_configurations(),
+ );
+
+ // Merge in page dimensions when provided (pdfdraft format).
+ // Do NOT set 'orientation' alongside an explicit [w, h] format array —
+ // mPDF swaps the dimensions when orientation='L', which would make a
+ // landscape canvas overflow to page 2 (blank page 1 bug).
+ // Passing the correct pixel dimensions as the format is sufficient.
+ if ( ! empty( $page_format['format'] ) ) {
+ $mpdf_config['format'] = $page_format['format'];
+ }
+
+ $this->mpdf = new Mpdf( apply_filters( 'masteriyo_mpdf_config', $mpdf_config ) );
+
$this->mpdf->setMBencoding( 'UTF-8' );
/**
* Filters mpdf debug mode for making certificate PDF file.
*
- * @since 1.13.0
+ * @since 2.3.7
*
* @param boolean $bool
* @param MpdfMpdf $mpdf
@@ -188,7 +265,7 @@
/**
* Filters mpdf image debug mode for making certificate PDF file.
*
- * @since 1.13.0
+ * @since 2.3.7
*
* @param boolean $bool
* @param MpdfMpdf $mpdf
@@ -198,7 +275,7 @@
/**
* Filters Mpdf class instance used for making certificate PDF file.
*
- * @since 1.13.0
+ * @since 2.3.7
*
* @param boolean $bool
* @param MpdfMpdf $mpdf
@@ -209,9 +286,9 @@
/**
* Prepare PDF.
*
- * @since 1.13.0
+ * @since 2.3.7
*
- * @since 1.13.0 Added $is_preview argument.
+ * @since 2.4.4 Added $is_preview argument.
*
* @param string $template The certificate template.
* @param boolean $is_preview
@@ -219,14 +296,32 @@
* @return true|WP_Error
*/
public function prepare_pdf( $is_preview = false ) {
- $this->init_mpdf();
$this->set_is_preview( $is_preview );
- /**
- * Added setting to enable HTTPS instead of HTTP for certificate image URLs.
- *
- * @since 1.13.0
- */
+ if ( null !== $this->preview_rendered_html ) {
+ $layout = array();
+ if ( $this->certificate_id ) {
+ $preview_cert = masteriyo_get_certificate( $this->certificate_id );
+ if ( $preview_cert ) {
+ $layout = $this->extract_pdfdraft_layout( $preview_cert );
+ }
+ }
+ $this->init_mpdf( $layout );
+ return $this->prepare_pdf_pdfdraft( null, $is_preview );
+ }
+
+ if ( $this->certificate_id ) {
+ $certificate = masteriyo_get_certificate( $this->certificate_id );
+ if ( $certificate && 'pdfdraft' === $certificate->get_content_format() ) {
+ $this->init_mpdf( $this->extract_pdfdraft_layout( $certificate ) );
+ return $this->prepare_pdf_pdfdraft( $certificate, $is_preview );
+ }
+ }
+
+ // Existing Gutenberg pipeline — init with defaults as before.
+ $this->init_mpdf();
+
+ // Existing Gutenberg pipeline — unchanged.
$use_ssl_verified = masteriyo_bool_to_string( Setting::get( 'use_ssl_verified' ) );
if ( 'yes' === $use_ssl_verified ) {
@@ -254,9 +349,868 @@
}
/**
+ * Prepare PDF for pdfdraft format certificates.
+ *
+ * @since x.x.x
+ *
+ * @param MasteriyoAddonsCertificateModelsCertificate $certificate
+ * @param boolean $is_preview
+ * @return true|WP_Error
+ */
+ protected function prepare_pdf_pdfdraft( $certificate, $is_preview ) {
+ $html = $this->preview_rendered_html ?? ( $certificate ? $certificate->get_rendered_html( 'edit' ) : '' );
+
+ if ( empty( $html ) ) {
+ return new WP_Error(
+ 'masteriyo_no_rendered_html',
+ __( 'Certificate has not been saved yet. Please open and save the certificate in the editor before generating a PDF.', 'learning-management-system' )
+ );
+ }
+
+ $html = $this->replace_pdfdraft_merge_tags( $html, $is_preview );
+
+ $html = preg_replace( '/s+onw+="[^"]*"/i', '', $html );
+ $html = preg_replace( "/s+onw+='[^']*'/i", '', $html );
+ $html = preg_replace( '/s+contenteditable="[^"]*"/i', '', $html );
+
+ try {
+ $this->add_html( $html );
+ $this->mpdf->WriteHTML( $this->prepare_pdfdraft_css(), HTMLParserMode::HEADER_CSS );
+ $this->mpdf->WriteHTML( $this->prepare_html() );
+ } catch ( Exception $e ) {
+ return new WP_Error(
+ 'masteriyo_mpdf_error',
+ sprintf(
+ /* translators: %s: error message */
+ __( 'PDF generation failed: %s', 'learning-management-system' ),
+ $e->getMessage()
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Build the merge tag replacements array for PDFDraft certificates.
+ *
+ * @since x.x.x
+ *
+ * @param boolean $is_preview
+ * @return array Map of {{tag}} => resolved value.
+ */
+ public function build_pdfdraft_replacements( $is_preview = false ) {
+ $course = masteriyo_get_course( $this->get_course_id() );
+ $student = masteriyo_get_user( $this->get_student_id() );
+
+ if ( $is_preview || ! $course || ! $student || is_wp_error( $student ) ) {
+ $replacements = array(
+ '{{masteriyo:student_name_full}}' => __( 'Student Full Name', 'learning-management-system' ),
+ '{{masteriyo:student_name_first}}' => __( 'First Name', 'learning-management-system' ),
+ '{{masteriyo:student_name_last}}' => __( 'Last Name', 'learning-management-system' ),
+ '{{masteriyo:course_title}}' => $course ? $course->get_name() : __( 'Course Title', 'learning-management-system' ),
+ '{{masteriyo:completion_date}}' => date_i18n( get_option( 'date_format' ) ),
+ '{{masteriyo:start_date}}' => date_i18n( get_option( 'date_format' ) ),
+ '{{masteriyo:instructor_name}}' => __( 'Instructor Name', 'learning-management-system' ),
+ '{{masteriyo:co_instructors}}' => __( 'Co-Instructor Name', 'learning-management-system' ),
+ '{{masteriyo:course_duration}}' => __( '10 Hours', 'learning-management-system' ),
+ '{{masteriyo:grade}}' => '100%',
+ '{{masteriyo:verification_code}}' => 'XXXX-XXXX-XXXX',
+ '{{masteriyo:qr_code}}' => '',
+ '{{masteriyo:current_date}}' => date_i18n( get_option( 'date_format' ) ),
+ '{{masteriyo:current_time}}' => date_i18n( get_option( 'time_format' ) ),
+ '{{masteriyo:current_timestamp}}' => date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
+ '{{masteriyo:site_name}}' => get_bloginfo( 'name' ),
+ '{{user.display_name}}' => __( 'Student Name', 'learning-management-system' ),
+ '{{user.first_name}}' => __( 'First Name', 'learning-management-system' ),
+ '{{user.last_name}}' => __( 'Last Name', 'learning-management-system' ),
+ '{{user.email}}' => 'student@example.com',
+ '{{user.login}}' => __( 'username', 'learning-management-system' ),
+ '{{user.id}}' => '1',
+ '{{user.url}}' => home_url(),
+ '{{user.description}}' => __( 'Student bio', 'learning-management-system' ),
+ '{{user.registered}}' => date_i18n( get_option( 'date_format' ) ),
+ '{{user.avatar}}' => '',
+ '{{user.roles}}' => __( 'Student', 'learning-management-system' ),
+ '{{site.name}}' => get_bloginfo( 'name' ),
+ '{{site.tagline}}' => get_bloginfo( 'description' ),
+ '{{site.url}}' => get_site_url(),
+ '{{site.home_url}}' => home_url(),
+ '{{site.admin_email}}' => get_option( 'admin_email' ),
+ '{{site.current_date}}' => date_i18n( get_option( 'date_format' ) ),
+ '{{site.current_time}}' => date_i18n( get_option( 'time_format' ) ),
+ '{{site.wp_version}}' => get_bloginfo( 'version' ),
+ '{{site.language}}' => get_bloginfo( 'language' ),
+ '{{post.title}}' => $course ? $course->get_name() : __( 'Course Title', 'learning-management-system' ),
+ '{{post.author_name}}' => __( 'Instructor Name', 'learning-management-system' ),
+ );
+ } else {
+ $course_id = $this->get_course_id();
+ $student_id = $this->get_student_id();
+
+ $completion_date = '';
+ $start_date = '';
+ $progress_query = new CourseProgressQuery(
+ array(
+ 'course_id' => $course_id,
+ 'user_id' => $student_id,
+ 'per_page' => 1,
+ )
+ );
+ $course_progress = current( $progress_query->get_course_progress() );
+ if ( $course_progress ) {
+ $completion_date = $course_progress->get_completed_at()
+ ? date_i18n( get_option( 'date_format' ), $course_progress->get_completed_at()->getOffsetTimestamp() )
+ : '';
+ $start_date = $course_progress->get_started_at()
+ ? date_i18n( get_option( 'date_format' ), $course_progress->get_started_at()->getOffsetTimestamp() )
+ : '';
+ }
+
+ $instructor = masteriyo_get_user( $course->get_author_id() );
+ $instructor_name = '';
+ if ( $instructor && ! is_wp_error( $instructor ) ) {
+ $instructor_full = trim( $instructor->get_first_name() . ' ' . $instructor->get_last_name() );
+ $instructor_name = $instructor_full ?: $instructor->get_display_name();
+ }
+
+ $verification_code = $course_id . '-' . masteriyo_get_course_certificate_id( $course_id ) . '-' . $student_id;
+
+ $wp_user = get_user_by( 'id', $this->get_student_id() );
+
+ $student_full_name = trim( $student->get_first_name() . ' ' . $student->get_last_name() );
+ if ( ! $student_full_name ) {
+ $student_full_name = $student->get_display_name();
+ }
+
+ $replacements = array(
+ '{{masteriyo:student_name_full}}' => $student_full_name,
+ '{{masteriyo:student_name_first}}' => $student->get_first_name() ? $student->get_first_name() : $student->get_display_name(),
+ '{{masteriyo:student_name_last}}' => $student->get_last_name(),
+ '{{masteriyo:course_title}}' => $course->get_name(),
+ '{{masteriyo:completion_date}}' => $completion_date,
+ '{{masteriyo:start_date}}' => $start_date,
+ '{{masteriyo:instructor_name}}' => $instructor_name,
+ '{{masteriyo:co_instructors}}' => '',
+ '{{masteriyo:course_duration}}' => masteriyo_minutes_to_time_length_string( $course->get_duration() ),
+ '{{masteriyo:grade}}' => '',
+ '{{masteriyo:verification_code}}' => $verification_code,
+ '{{masteriyo:qr_code}}' => $this->get_pdfdraft_qr_code_html( $verification_code ),
+ '{{masteriyo:current_date}}' => date_i18n( get_option( 'date_format' ) ),
+ '{{masteriyo:current_time}}' => date_i18n( get_option( 'time_format' ) ),
+ '{{masteriyo:current_timestamp}}' => date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
+ '{{masteriyo:site_name}}' => get_bloginfo( 'name' ),
+ '{{user.display_name}}' => $student->get_display_name(),
+ '{{user.first_name}}' => $student->get_first_name() ? $student->get_first_name() : $student->get_display_name(),
+ '{{user.last_name}}' => $student->get_last_name(),
+ '{{user.email}}' => $wp_user ? $wp_user->user_email : '',
+ '{{user.login}}' => $wp_user ? $wp_user->user_login : '',
+ '{{user.id}}' => (string) $this->get_student_id(),
+ '{{user.url}}' => $wp_user ? $wp_user->user_url : '',
+ '{{user.description}}' => $wp_user ? get_user_meta( $wp_user->ID, 'description', true ) : '',
+ '{{user.registered}}' => $wp_user ? date_i18n( get_option( 'date_format' ), strtotime( $wp_user->user_registered ) ) : '',
+ '{{user.avatar}}' => $wp_user ? get_avatar( $wp_user->ID, 64 ) : '',
+ '{{user.roles}}' => $wp_user ? implode( ', ', (array) $wp_user->roles ) : '',
+ '{{site.name}}' => get_bloginfo( 'name' ),
+ '{{site.tagline}}' => get_bloginfo( 'description' ),
+ '{{site.url}}' => get_site_url(),
+ '{{site.home_url}}' => home_url(),
+ '{{site.admin_email}}' => get_option( 'admin_email' ),
+ '{{site.current_date}}' => date_i18n( get_option( 'date_format' ) ),
+ '{{site.current_time}}' => date_i18n( get_option( 'time_format' ) ),
+ '{{site.wp_version}}' => get_bloginfo( 'version' ),
+ '{{site.language}}' => get_bloginfo( 'language' ),
+ '{{post.title}}' => $course->get_name(),
+ '{{post.author_name}}' => $instructor_name,
+ );
+ }
+
+ /**
+ * Filter the merge tag replacements for pdfdraft certificates.
+ *
+ * @since x.x.x
+ *
+ * @param array $replacements Map of merge tag => replacement value.
+ * @param boolean $is_preview
+ * @param MasteriyoCourseCourse|null $course
+ * @param MasteriyoModelsUser|null $student
+ */
+ return apply_filters( 'masteriyo_pdfdraft_certificate_merge_tags', $replacements, $is_preview, $course ?? null, $student ?? null );
+ }
+
+ /**
+ * Replace merge tags in the rendered HTML snapshot.
+ *
+ * @since x.x.x
+ *
+ * @param string $html Rendered HTML snapshot.
+ * @param boolean $is_preview Whether this is a preview.
+ * @return string
+ */
+ protected function replace_pdfdraft_merge_tags( $html, $is_preview ) {
+ $replacements = $this->build_pdfdraft_replacements( $is_preview );
+ $html = $this->replace_pdfdraft_data_merge_tag_elements( $html, $replacements );
+ $html_value_tags = $this->get_pdfdraft_html_value_tags();
+ $escaped = array();
+ foreach ( $replacements as $tag => $value ) {
+ $escaped[ $tag ] = in_array( $tag, $html_value_tags, true ) ? $value : esc_html( (string) $value );
+ }
+
+ return str_replace( array_keys( $escaped ), array_values( $escaped ), $html );
+ }
+
+ /**
+ * Merge tags whose resolved value is intentional, server-generated HTML
+ * markup (rather than plain text) and must therefore NOT be HTML-escaped.
+ *
+ * Every other merge value is user-controlled and is escaped before being
+ * placed into the rendered certificate HTML.
+ *
+ * @since x.x.x
+ *
+ * @return string[]
+ */
+ protected function get_pdfdraft_html_value_tags() {
+ /**
+ * Filters the merge tags treated as raw HTML in pdfdraft certificates.
+ *
+ * @since x.x.x
+ *
+ * @param string[] $tags Merge tags whose value is trusted HTML.
+ */
+ return apply_filters(
+ 'masteriyo_pdfdraft_certificate_html_value_tags',
+ array( '{{user.avatar}}', '{{masteriyo:qr_code}}' )
+ );
+ }
+
+ /**
+ * Resolve merge tags in a PDFDraft JSON for client-side PDF generation.
+ *
+ * @since x.x.x
+ *
+ * @param array $json Decoded certificate JSON.
+ * @param boolean $is_preview
+ * @return array
+ */
+ public function resolve_pdfdraft_json_for_download( array $json, $is_preview = false ): array {
+ $replacements = $this->build_pdfdraft_replacements( $is_preview );
+
+ $json_str = wp_json_encode( $json );
+ if ( ! $json_str ) {
+ return $json;
+ }
+
+ foreach ( $replacements as $tag => $value ) {
+ if ( ! is_string( $value ) ) {
+ continue;
+ }
+ $encoded = wp_json_encode( wp_check_invalid_utf8( $value, true ) );
+ if ( ! is_string( $encoded ) || strlen( $encoded ) < 2 ) {
+ continue;
+ }
+ $safe = substr( $encoded, 1, strlen( $encoded ) - 2 );
+ $json_str = str_replace( $tag, $safe, $json_str );
+ }
+
+ $resolved = json_decode( $json_str, true );
+ if ( ! is_array( $resolved ) ) {
+ return $json;
+ }
+
+ if ( isset( $resolved['pages'] ) && is_array( $resolved['pages'] ) ) {
+ $resolved['pages'] = $this->convert_pdfdraft_element_types( $resolved['pages'], $replacements );
+ }
+
+ if ( isset( $resolved['pages'] ) ) {
+ $resolved['fonts'] = $this->enrich_pdfdraft_fonts(
+ $resolved['pages'],
+ is_array( $resolved['fonts'] ?? null ) ? $resolved['fonts'] : array()
+ );
+ }
+
+ $resolved = $this->inline_pdfdraft_remote_images( $resolved );
+
+ return $resolved;
+ }
+
+ /**
+ * Inline remote (http/https) image URLs in a pdfdraft design as base64 data
+ * URIs, so the client-side PDFExporter never fetches cross-origin images
+ * (avoids canvas tainting when a CDN/CloudFront response is missing CORS
+ * headers, which silently drops images from the generated PDF).
+ *
+ * @since x.x.x
+ *
+ * @param array $json Decoded certificate design.
+ * @return array
+ */
+ public function inline_pdfdraft_remote_images( array $json ): array {
+ if ( isset( $json['pages'] ) && is_array( $json['pages'] ) ) {
+ foreach ( $json['pages'] as $page_id => $page ) {
+ if ( isset( $page['children'] ) && is_array( $page['children'] ) ) {
+ $json['pages'][ $page_id ]['children'] = $this->inline_pdfdraft_image_nodes( $page['children'] );
+ }
+ }
+ }
+
+ if ( isset( $json['settings']['background'] ) && is_array( $json['settings']['background'] ) ) {
+ $bg = $json['settings']['background'];
+
+ if ( ! empty( $bg['image'] ) && is_string( $bg['image'] ) ) {
+ $bg['image'] = $this->maybe_inline_remote_image( $bg['image'] );
+ }
+
+ if ( isset( $bg['imageProps'] ) && is_array( $bg['imageProps'] ) ) {
+ foreach ( array( 'src', 'originalSrc' ) as $key ) {
+ if ( ! empty( $bg['imageProps'][ $key ] ) && is_string( $bg['imageProps'][ $key ] ) ) {
+ $bg['imageProps'][ $key ] = $this->maybe_inline_remote_image( $bg['imageProps'][ $key ] );
+ }
+ }
+ }
+
+ $json['settings']['background'] = $bg;
+ }
+
+ return $json;
+ }
+
+ /**
+ * Recursively inline image-element src/originalSrc within a node list.
+ *
+ * @since x.x.x
+ *
+ * @param array $nodes
+ * @return array
+ */
+ protected function inline_pdfdraft_image_nodes( array $nodes ): array {
+ foreach ( $nodes as $i => $node ) {
+ if ( ! is_array( $node ) ) {
+ continue;
+ }
+
+ if ( isset( $node['type'], $node['props'] ) && 'image' === $node['type'] && is_array( $node['props'] ) ) {
+ foreach ( array( 'src', 'originalSrc' ) as $key ) {
+ if ( ! empty( $node['props'][ $key ] ) && is_string( $node['props'][ $key ] ) ) {
+ $node['props'][ $key ] = $this->maybe_inline_remote_image( $node['props'][ $key ] );
+ }
+ }
+ }
+
+ if ( isset( $node['children'] ) && is_array( $node['children'] ) ) {
+ $node['children'] = $this->inline_pdfdraft_image_nodes( $node['children'] );
+ }
+
+ $nodes[ $i ] = $node;
+ }
+
+ return $nodes;
+ }
+
+ /**
+ * Determine whether a remote URL is safe to fetch server-side (SSRF guard).
+ *
+ * Images served from this site (own media library) are always allowed, even
+ * when the host resolves to a private IP (load balancers / local dev). Any
+ * other host must resolve exclusively to public IP addresses; loopback,
+ * private, link-local (incl. 169.254.169.254 cloud metadata) and reserved
+ * ranges are rejected.
+ *
+ * @since x.x.x
+ *
+ * @param string $url
+ * @return bool
+ */
+ protected function is_safe_remote_image_url( $url ) {
+ $parsed = wp_parse_url( $url );
+
+ if ( empty( $parsed['scheme'] ) || ! in_array( strtolower( $parsed['scheme'] ), array( 'http', 'https' ), true ) ) {
+ return false;
+ }
+ if ( empty( $parsed['host'] ) ) {
+ return false;
+ }
+
+ $host = strtolower( trim( $parsed['host'], '.[]' ) );
+ $site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
+
+ if ( $host && $host === $site_host ) {
+ return true;
+ }
+
+ $ips = array();
+ if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
+ $ips[] = $host;
+ } else {
+ $records = function_exists( 'dns_get_record' ) ? @dns_get_record( $host, DNS_A | DNS_AAAA ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ if ( is_array( $records ) ) {
+ foreach ( $records as $record ) {
+ if ( ! empty( $record['ip'] ) ) {
+ $ips[] = $record['ip'];
+ }
+ if ( ! empty( $record['ipv6'] ) ) {
+ $ips[] = $record['ipv6'];
+ }
+ }
+ }
+ if ( empty( $ips ) ) {
+ $resolved = gethostbyname( $host );
+ if ( $resolved && $resolved !== $host ) {
+ $ips[] = $resolved;
+ }
+ }
+ }
+
+ if ( empty( $ips ) ) {
+ return false;
+ }
+
+ foreach ( $ips as $ip ) {
+ if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Convert a remote http(s) image URL to a base64 data URI (cached per URL via
+ * a request-static map and a day-long transient). Returns the URL unchanged
+ * for data:/relative URLs or on fetch failure.
+ *
+ * @since x.x.x
+ *
+ * @param string $url
+ * @return string
+ */
+ protected function maybe_inline_remote_image( $url ) {
+ if ( ! is_string( $url ) || '' === $url ) {
+ return $url;
+ }
+ if ( 0 === strpos( $url, 'data:' ) ) {
+ return $url;
+ }
+ if ( 0 !== strpos( $url, 'http://' ) && 0 !== strpos( $url, 'https://' ) ) {
+ return $url;
+ }
+
+ if ( ! $this->is_safe_remote_image_url( $url ) ) {
+ return $url;
+ }
+
+ static $cache = array();
+ if ( isset( $cache[ $url ] ) ) {
+ return $cache[ $url ];
+ }
+
+ $transient_key = 'masteriyo_cert_img_b64_' . md5( $url );
+ $cached = get_transient( $transient_key );
+ if ( is_string( $cached ) && '' !== $cached ) {
+ $cache[ $url ] = $cached;
+ return $cached;
+ }
+
+ static $fetch_count = 0;
+ if ( $fetch_count >= 50 ) {
+ $cache[ $url ] = $url;
+ return $url;
+ }
+ ++$fetch_count;
+
+ $response = wp_remote_get(
+ $url,
+ array(
+ 'timeout' => 10,
+ 'redirection' => 2,
+ 'reject_unsafe_urls' => true,
+ 'sslverify' => (bool) Setting::get( 'use_ssl_verified' ),
+ )
+ );
+
+ if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
+ $cache[ $url ] = $url;
+ return $url;
+ }
+
+ $body = wp_remote_retrieve_body( $response );
+ if ( '' === $body ) {
+ $cache[ $url ] = $url;
+ return $url;
+ }
+
+ $mime = wp_remote_retrieve_header( $response, 'content-type' );
+ if ( ! is_string( $mime ) || 0 !== strpos( $mime, 'image/' ) ) {
+ $mime = 'image/png';
+ }
+
+ $data_uri = 'data:' . $mime . ';base64,' . base64_encode( $body );
+ $cache[ $url ] = $data_uri;
+ set_transient( $transient_key, $data_uri, DAY_IN_SECONDS );
+
+ return $data_uri;
+ }
+
+ /**
+ * Collect font-family values from all page nodes into the fonts map.
+ *
+ * @since x.x.x
+ * @param array $pages PDFDraft pages array.
+ * @param array $fonts Existing fonts map.
+ * @return array
+ */
+ protected function enrich_pdfdraft_fonts( array $pages, array $fonts ) {
+ $this->collect_fonts_from_nodes( $pages, $fonts );
+ return $fonts;
+ }
+
+ /**
+ * Recursively walk PDFDraft node tree collecting font-family values.
+ *
+ * @since x.x.x
+ * @param mixed $data Current node or subtree.
+ * @param array $fonts Fonts map passed by reference.
+ */
+ protected function collect_fonts_from_nodes( $data, array &$fonts ) {
+ if ( ! is_array( $data ) ) {
+ return;
+ }
+
+ if ( isset( $data['type'], $data['id'] ) ) {
+ $family = $data['style']['fontFamily'] ?? '';
+ if ( $family ) {
+ $this->add_font_to_fonts_map( (string) $family, $fonts );
+ }
+
+ $global_family = $data['props']['globalStyle']['fontFamily'] ?? '';
+ if ( $global_family ) {
+ $this->add_font_to_fonts_map( (string) $global_family, $fonts );
+ }
+
+ $content = $data['props']['content'] ?? '';
+ if ( is_string( $content ) && false !== strpos( $content, 'font-family' ) ) {
+ preg_match_all( "/font-family:s*['"]?([^;'"]+)/i", $content, $matches );
+ foreach ( $matches[1] ?? array() as $raw ) {
+ $this->add_font_to_fonts_map( trim( $raw, " t," ), $fonts );
+ }
+ }
+ }
+
+ foreach ( $data as $value ) {
+ if ( is_array( $value ) ) {
+ $this->collect_fonts_from_nodes( $value, $fonts );
+ }
+ }
+ }
+
+ /**
+ * Add a single font family to the fonts map if not already present.
+ *
+ * @since x.x.x
+ * @param string $family Raw font-family string.
+ * @param array $fonts Fonts map passed by reference.
+ */
+ protected function add_font_to_fonts_map( string $family, array &$fonts ) {
+ $family = trim( $family, " "'t" );
+ $generic = array( 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'inherit', 'initial', 'unset' );
+
+ if ( empty( $family ) || isset( $fonts[ $family ] ) || in_array( strtolower( $family ), $generic, true ) ) {
+ return;
+ }
+
+ $encoded = rawurlencode( $family );
+ $fonts[ $family ] = array(
+ 'id' => $family,
+ 'family' => $family,
+ 'variants' => array( 100, 200, 300, 400, 500, 600, 700, 800, 900 ),
+ 'subsets' => array( 'latin' ),
+ 'url' => "https://fonts.googleapis.com/css2?family={$encoded}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap",
+ );
+ }
+
+ /**
+ * Convert non-native PDFDraft element types to 'text' recursively.
+ *
+ * @since x.x.x
+ *
+ * @param mixed $node Current node in the JSON tree.
+ * @param array $replacements Resolved {{tag}} => value map.
+ * @return mixed
+ */
+ protected function convert_pdfdraft_element_types( $node, array $replacements ) {
+ if ( ! is_array( $node ) ) {
+ return $node;
+ }
+
+ if ( isset( $node['type'], $node['id'] ) && is_string( $node['type'] ) ) {
+ $type = $node['type'];
+
+ if ( 0 === strpos( $type, 'masteriyo__' ) ) {
+ $node['type'] = 'text';
+
+ $tiptap_html = $node['props']['content'] ?? '';
+ if ( $tiptap_html ) {
+ if ( empty( $node['style']['fontFamily'] ) && false !== strpos( $tiptap_html, 'font-family' ) ) {
+ preg_match( '/font-family:s*([^;]+)/i', $tiptap_html, $fm );
+ if ( ! empty( $fm[1] ) ) {
+ $node['style']['fontFamily'] = trim( $fm[1], " "'t" );
+ }
+ }
+ if ( empty( $node['style']['fontSize'] ) && false !== strpos( $tiptap_html, 'font-size' ) ) {
+ preg_match( '/font-size:s*([^;]+)/i', $tiptap_html, $fs );
+ if ( ! empty( $fs[1] ) ) {
+ $node['style']['fontSize'] = trim( $fs[1], " "'t" );
+ }
+ }
+ if ( empty( $node['style']['fontWeight'] ) && false !== strpos( $tiptap_html, 'font-weight' ) ) {
+ preg_match( '/font-weight:s*([^;]+)/i', $tiptap_html, $fw );
+ if ( ! empty( $fw[1] ) ) {
+ $node['style']['fontWeight'] = trim( $fw[1], " "'t" );
+ }
+ }
+ }
+
+ if ( ! empty( $node['style']['fontFamily'] ) ) {
+ if ( ! isset( $node['props']['globalStyle'] ) || ! is_array( $node['props']['globalStyle'] ) ) {
+ $node['props']['globalStyle'] = array();
+ }
+ if ( empty( $node['props']['globalStyle']['fontFamily'] ) ) {
+ $node['props']['globalStyle']['fontFamily'] = $node['style']['fontFamily'];
+ }
+ }
+ if ( ! empty( $node['style']['fontSize'] ) ) {
+ if ( ! isset( $node['props']['globalStyle'] ) || ! is_array( $node['props']['globalStyle'] ) ) {
+ $node['props']['globalStyle'] = array();
+ }
+ if ( empty( $node['props']['globalStyle']['fontSize'] ) ) {
+ $font_size_num = (float) preg_replace( '/[^0-9.]/', '', (string) $node['style']['fontSize'] );
+ if ( $font_size_num > 0 ) {
+ $node['props']['globalStyle']['fontSize'] = $font_size_num;
+ }
+ }
+ }
+ if ( ! empty( $node['style']['fontWeight'] ) ) {
+ if ( ! isset( $node['props']['globalStyle'] ) || ! is_array( $node['props']['globalStyle'] ) ) {
+ $node['props']['globalStyle'] = array();
+ }
+ if ( empty( $node['props']['globalStyle']['fontWeight'] ) ) {
+ $node['props']['globalStyle']['fontWeight'] = $node['style']['fontWeight'];
+ }
+ }
+
+ if ( ! empty( $node['props']['field'] ) ) {
+ $tag = '{{' . $node['props']['field'] . '}}';
+ $resolved = $replacements[ $tag ] ?? ( $node['props']['content'] ?? '' );
+ $node['props']['content'] = $resolved;
+ $node['content'] = $resolved;
+ } elseif ( ! empty( $node['props']['content'] ) ) {
+ $node['content'] = $node['props'][