File: /home/globfdxw/www/wp-content/plugins/wpforms-pdf/src/PDF/Renderer.php
<?php
// Added to the PDF addon composer.json.
// phpcs:ignore Generic.Commenting.DocComment.MissingShort
/** @noinspection PhpComposerExtensionStubsInspection */
namespace WPFormsPDF\PDF;
use WPForms\SmartTags\SmartTag\SmartTag;
use WPForms\Emails\Notifications;
use WPForms\Helpers\File;
use WPFormsPDF\Notifications\Fields\Helpers;
use WPFormsPDF\Storage;
use WPFormsPDF\Templates\Templates;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;
/**
* Renderer class.
*
* @since 1.0.0
*/
class Renderer {
/**
* Default settings.
*
* @since 1.0.0
*
* @var array
*/
private const DEFAULT_SETTINGS = [
'template' => 'notification-modern',
'theme' => 'creamsicle',
'pdf_id' => 0,
'is_preview' => false,
];
/**
* Available fonts.
*
* @since 1.2.0
*
* @var array
*/
public const FONTS = [
'default' => '',
'serif' => '"Literata", serif',
'sans-serif' => '"Inter", sans-serif',
'noto-sans' => '"NotoSans", sans-serif',
'noto-sans-arabic' => '"NotoSansArabic", sans-serif',
'noto-sans-hebrew' => '"NotoSansHebrew", sans-serif',
'noto-sans-sc' => '"NotoSansSC", sans-serif',
'noto-sans-tc' => '"NotoSansTC", sans-serif',
'noto-sans-devanagari' => '"NotoSansDevanagari", sans-serif',
'noto-sans-jp' => '"NotoSansJP", sans-serif',
'noto-sans-kr' => '"NotoSansKR", sans-serif',
];
/**
* 1x1 transparent PNG image.
*
* @since 1.0.0
*
* @var string
*/
private const TRANSPARENT_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==';
/**
* Supported image types.
*
* @since 1.0.0
*
* @var array
*/
private const IMAGE_TYPES = [
'svg' => 'image/svg+xml',
'png' => 'image/png',
'gif' => 'image/gif',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
];
/**
* Settings.
*
* @since 1.0.0
*
* @var array
*/
private $settings = [];
/**
* Templates class instance.
*
* @since 1.0.0
*
* @var Templates
*/
private $templates_obj;
/**
* Template data.
*
* @since 1.0.0
*
* @var array
*/
private $template;
/**
* Current entry fields data.
*
* @since 1.0.0
*
* @var array
*/
private $entry_fields;
/**
* Current entry ID.
*
* @since 1.0.0
*
* @var int
*/
private $entry_id;
/**
* Current form data.
*
* @since 1.0.0
*
* @var array
*/
private $form_data;
/**
* Current PDF images data.
*
* @since 1.0.0
*
* @var array
*/
private $images;
/**
* Class constructor.
*
* @since 1.0.0
*
* @param string $template Template slug.
* @param string $theme Theme slug.
* @param array $form_data Form data.
* @param array $entry_fields Entry fields.
*
* @return void
*/
public function __construct( string $template = '', string $theme = '', array $form_data = [], array $entry_fields = [] ) {
$this->templates_obj = wpforms_pdf()->templates;
$this->hooks();
if ( empty( $template ) || empty( $theme ) || empty( $form_data ) ) {
return;
}
$this->init( $template, $theme, $form_data, $entry_fields );
}
/**
* Initialize class.
*
* @since 1.0.0
*
* @param string $template Template slug.
* @param string $theme Theme slug.
* @param array $form_data Form data.
* @param array $entry_fields Entry fields.
*
* @return Renderer
*/
public function init( string $template, string $theme, array $form_data, array $entry_fields ): self {
$this->settings = [
'template' => $template,
'theme' => $theme,
];
$this->form_data = $form_data;
$this->entry_fields = $entry_fields;
$this->settings = wp_parse_args( $this->settings, self::DEFAULT_SETTINGS );
$this->init_template();
return $this;
}
/**
* Register hooks.
*
* @since 1.0.0
*/
private function hooks(): void {
add_filter( 'wpforms_smarttags_process_value', [ $this, 'filter_smarttags_process_value' ], 10, 7 );
}
/**
* Initialize certain PDF from the form settings.
*
* @since 1.0.0
*
* @param int $pdf_id PDF id.
* @param array $form_data Form data.
* @param array $entry_fields Entry fields.
* @param int $entry_id Entry id.
* @param bool $is_preview Is preview.
*
* @return Renderer
*/
public function init_pdf_settings( int $pdf_id, array $form_data, array $entry_fields, int $entry_id = 0, bool $is_preview = false ): self {
$pdf = $form_data['settings']['pdfs'][ $pdf_id ] ?? [];
$theme_slug = $pdf['theme'] ?? '';
$template_slug = $pdf['template_style'] ?? '';
$this->settings = [
'template' => $template_slug,
'theme' => $theme_slug,
'pdf_id' => $pdf_id,
'is_preview' => $is_preview,
'orientation' => $pdf['orientation'] ?? 'portrait',
'paper_size' => $pdf['paper_size'] ?? 'A4',
];
$this->form_data = $form_data;
$this->entry_fields = $entry_fields;
$this->entry_id = $entry_id;
$this->settings = wp_parse_args( $this->settings, self::DEFAULT_SETTINGS );
$this->images = [];
$this->init_template();
return $this;
}
/**
* Initialize template.
*
* @since 1.0.0
*
* @return void
*/
private function init_template(): void {
// Get template data.
$this->template = $this->templates_obj->get_template( $this->settings['template'], $this->settings['theme'] );
// Default template.
if ( empty( $this->template ) ) {
$this->template = $this->templates_obj->get_template( Templates::DEFAULT_TEMPLATE, $this->settings['theme'] );
}
$this->apply_pdf_settings();
// Update template data.
$this->init_template_appearance();
$this->init_template_text();
}
/**
* Initialize a template appearance section.
*
* @since 1.0.0
*
* @return void
*/
private function init_template_appearance(): void {
$this->template['appearance']['page_background_image'] = $this->get_background_image();
// Available fonts.
$fonts = self::FONTS;
// Update font to actual CSS value.
$font_slug = $this->template['appearance']['font'] ?? 'sans-serif';
$this->template['appearance']['font_slug'] = $font_slug;
$this->template['appearance']['font'] = $fonts[ $font_slug ] ?? $fonts['sans-serif'];
$this->template['appearance']['logo_url'] = $this->get_image( $this->template['appearance']['logo_url'] ?? '' );
}
/**
* Initialize a template text section.
*
* @since 1.0.0
*
* @return void
*/
private function init_template_text(): void {
$this->template['text'] = $this->template['text'] ?? [];
if ( isset( $this->template['text']['badge_show'] ) ) {
$this->template['text']['badge_image'] = $this->get_badge_image();
}
if ( isset( $this->template['text']['signature_url'] ) ) {
$this->template['text']['signature_url'] = $this->get_image( $this->template['text']['signature_url'] );
}
}
/**
* Apply PDF settings to the template.
*
* @since 1.0.0
*
* @return void
*/
private function apply_pdf_settings(): void {
$pdf_settings = $this->get_pdf_settings();
if ( empty( $pdf_settings ) || empty( $this->template ) ) {
return;
}
$template = $this->template;
$prefix = $template['category'] === 'notification' ? 'notification_' : 'general_';
// Apply appearance settings.
foreach ( $template['appearance'] as $key => $value ) {
$appearance_key = $prefix . $key;
$appearance_key = isset( $pdf_settings[ $appearance_key ] ) ? $appearance_key : $key;
$template['appearance'][ $key ] = $pdf_settings[ $appearance_key ] ?? $template['appearance'][ $key ];
}
// Apply text settings.
$template['text'] = $this->apply_template_settings( $template['text'], $pdf_settings );
$this->template = $template;
}
/**
* Get PDF settings.
*
* @since 1.0.0
*
* @return array
*/
private function get_pdf_settings(): array {
if ( ! isset( $this->settings['pdf_id'] ) ) {
return [];
}
return $this->form_data['settings']['pdfs'][ $this->settings['pdf_id'] ?? null ] ?? [];
}
/**
* Apply template settings.
*
* @since 1.0.0
*
* @param array $template_section Template section.
* @param array $pdf_settings PDF settings.
*
* @return array
*/
private function apply_template_settings( array $template_section, array $pdf_settings ): array {
// Apply template section settings.
foreach ( $template_section as $key => $value ) {
$template_section[ $key ] = $pdf_settings[ $key ] ?? $value;
}
return $template_section;
}
/**
* Get rendered HTML.
*
* @since 1.0.0
*
* @param array $texts Texts.
*
* @return string
*/
public function render_html( array $texts = [] ): string {
$is_preview = ! empty( $this->settings['is_preview'] );
// Process content.
$processed_texts = $this->process_texts( $texts );
if ( ! $is_preview ) {
$processed_texts['content'] = $this->normalize_repeater_label_colspan( $processed_texts['content'] );
$processed_texts['content'] = $this->reverse_layout_rtl_columns( $processed_texts['content'] );
}
// Render template.
$body = wpforms_render(
$this->get_template_location(),
[
'appearance' => $this->get_appearance_settings(),
'theme' => $this->get_theme_colors(),
'content' => $processed_texts['content'],
'texts' => $processed_texts,
'is_preview' => $this->settings['is_preview'],
'base_url' => $this->get_pdf_base_url(),
],
true
);
$purpose = ! $is_preview ? 'pdf_tuning' : 'html';
$pdf_tuning_css = ! $is_preview ? $this->get_css_file_content( $purpose ) : '';
$is_preview_class = $this->settings['is_preview'] ? ' preview' : '';
$rtl_class = $this->is_rtl() ? ' rtl' : '';
// Render final HTML.
$html = wpforms_render(
$this->get_template_location( 'html' ),
[
'fonts_css' => $this->get_fonts_css_file_content( $purpose ),
'html_css' => $this->get_css_file_content(),
'pdf_tuning_css' => $pdf_tuning_css,
'body' => $body,
'body_class' => $this->settings['orientation'] . ' ' . $this->settings['paper_size'] . $is_preview_class . $rtl_class,
],
true
);
// Process rendered HTML.
if ( ! $is_preview ) {
$html = $this->process_rendered_html( $html );
$html = $this->process_rtl_text( $html );
$this->update_images_data( $html );
}
// Output the final HTML to the file for debugging.
if ( wpforms_pdf()->helpers->is_debug() ) {
File::put_contents( Storage::get_dir() . '/debug.html', $html );
}
return $html;
}
/**
* Whether the document uses RTL font.
*
* @since 1.2.0
*
* @return bool
*/
private function is_rtl(): bool {
$font_is_rtl = isset( $this->template['appearance']['font_slug'] ) && in_array( $this->template['appearance']['font_slug'], [ 'noto-sans-hebrew', 'noto-sans-arabic' ], true );
// Also respect the site-level RTL setting in WordPress.
return $font_is_rtl || is_rtl();
}
/**
* Process RTL text for PDF rendering.
*
* PDF renderers often don't properly handle the Unicode Bidirectional Algorithm
* for RTL languages like Hebrew and Arabic. This method pre-processes the text
* by reversing RTL characters while preserving LTR segments (numbers, English text).
*
* @since 1.2.0
*
* @param string $html HTML content to process.
*
* @return string Processed HTML with corrected RTL text.
*/
private function process_rtl_text( string $html ): string {
if ( ! $this->is_rtl() ) {
return $html;
}
$pattern = '/>(\s*)([^<]+)(\s*)</u';
return preg_replace_callback(
$pattern,
function ( $matches ) {
[, $leading_space, $text, $trailing_space ] = $matches;
// Process the text if it contains RTL characters.
$processed_text = $this->reverse_rtl_text( $text );
return '>' . $leading_space . $processed_text . $trailing_space . '<';
},
$html
);
}
/**
* Normalize repeater label cells with colspan across the entire document.
*
* This converts:
* <tr><td class="field-repeater-name field-name" colspan="2">...</td></tr>
* to:
* <tr><td class="field-repeater-name field-name">...</td><td></td></tr>
*
* @since 1.2.0
*
* @param string $html HTML content to process.
*
* @return string
*/
private function normalize_repeater_label_colspan( string $html ): string { // phpcs:ignore Generic.Metrics.NestingLevel.MaxExceeded
list( $dom, $xpath ) = Helpers::load_dom( $html );
$all_rows = $xpath->query( '//tr' );
if ( ! $all_rows || $all_rows->length === 0 ) {
return $html;
}
$is_rtl = $this->is_rtl();
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
foreach ( $all_rows as $tr_all ) {
foreach ( iterator_to_array( $tr_all->childNodes ) as $child ) {
if ( ! $child instanceof DOMElement || strtolower( $child->tagName ) !== 'td' || $child->parentNode !== $tr_all ) {
continue;
}
$class_attr = ' ' . trim( $child->getAttribute( 'class' ) ) . ' ';
$has_repeater_class = (bool) preg_match( '/\sfield-repeater-name\s+field-name\s/', $class_attr );
if ( ! $has_repeater_class || ! $child->hasAttribute( 'colspan' ) ) {
continue;
}
$colspan = (int) $child->getAttribute( 'colspan' );
if ( $colspan < 1 ) {
continue;
}
$child->removeAttribute( 'colspan' );
for ( $c = 1; $c < $colspan; $c++ ) {
$empty_td = $dom->createElement( 'td' );
// Insert empty TD elements before the repeater label for RTL language and Notification Compact template due to specific his structure.
if ( $is_rtl && $this->settings['template'] === 'notification-compact' ) {
$child->parentNode->insertBefore( $empty_td, $child );
continue;
}
$child->parentNode->insertBefore( $empty_td, $child->nextSibling );
}
}
}
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
return Helpers::save_html( $dom );
}
/**
* Reverse layout columns for RTL languages.
*
* @since 1.2.0
*
* @param string $html HTML content to process.
*
* @return string
*/
private function reverse_layout_rtl_columns( string $html ): string { // phpcs:ignore Generic.Metrics.NestingLevel.MaxExceeded
if ( ! $this->is_rtl() ) {
return $html;
}
list( $dom, $xpath ) = Helpers::load_dom( $html );
$tables = $xpath->query( "//table[contains(concat(' ', normalize-space(@class), ' '), ' wpforms-layout-table-row ')]" );
if ( ! $tables || $tables->length === 0 ) {
return $html;
}
foreach ( $tables as $table ) {
$rows = $table->getElementsByTagName( 'tr' );
foreach ( $rows as $tr ) {
if ( ! $tr ) {
continue;
}
$tds = [];
// Collect direct TD children only to avoid moving nested table cells.
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
foreach ( iterator_to_array( $tr->childNodes ) as $child ) {
if ( ! $child instanceof DOMElement || strtolower( $child->tagName ) !== 'td' || $child->parentNode !== $tr ) {
continue;
}
$tds[] = $child;
}
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( count( $tds ) < 2 ) {
continue; // Nothing to reverse.
}
// Append TDs back in reverse order.
for ( $i = count( $tds ) - 1; $i >= 0; $i-- ) {
$tr->appendChild( $tds[ $i ] );
}
}
}
return Helpers::save_html( $dom );
}
/**
* Reverse RTL text while preserving LTR segments.
*
* @since 1.2.0
*
* @param string $text Text to process.
*
* @return string Processed text.
*/
private function reverse_rtl_text( string $text ): string {
// Check if the text contains Hebrew or Arabic characters.
if ( ! preg_match( '/[\p{Hebrew}\p{Arabic}]/u', $text ) ) {
return $text;
}
// Split text into grapheme clusters (handles multibyte characters correctly).
preg_match_all( '/./us', $text, $chars );
// Reverse the entire text.
$reversed = array_reverse( $chars[0] );
$text = implode( '', $reversed );
// Split by spaces to process words.
$words = explode( ' ', $text );
foreach ( $words as $i => $word ) {
// If a word doesn't contain RTL characters, reverse it back to LTR.
if ( ! preg_match( '/[\p{Hebrew}\p{Arabic}]/u', $word ) ) {
$words[ $i ] = $this->reverse_string( $word );
}
}
return implode( ' ', $words );
}
/**
* Reverse a string while handling multibyte characters.
*
* @since 1.2.0
*
* @param string $str String to reverse.
*
* @return string Reversed string.
*/
private function reverse_string( string $str ): string {
preg_match_all( '/./us', $str, $chars );
return implode( '', array_reverse( $chars[0] ) );
}
/**
* Process rendered HTML.
*
* @since 1.0.0
*
* @param string $html HTML content.
*
* @return string
* @noinspection HtmlUnknownTarget
*/
private function process_rendered_html( string $html ): string {
$emojis = [
'star' => '⭐',
'heart' => '❤️',
'thumb' => '👍',
'smiley' => '🙂',
];
$emojis_replacement = [];
foreach ( $emojis as $key => $value ) {
$emojis_replacement[] = sprintf(
'<span><img src="%1$sassets/images/emoji/%2$s.png" alt="" /></span>',
WPFORMS_PDF_URL,
$key
);
}
// Replace emojis with images.
$html = str_replace( array_values( $emojis ), $emojis_replacement, $html );
$search = [
'font-family: !important;', // Empty font-family.
// Chinese/Japanese punctuation characters.
'。',
',',
'、',
'?',
'!',
';',
':',
'・',
'(',
')',
'【',
'】',
'{',
'}',
'「',
'」',
'『',
'』',
'〜',
'…',
];
$replacement = [
'', // Remove the empty font-family.
// Add space after Chinese/Japanese punctuation characters.
'。 ',
', ',
'、 ',
'? ',
'! ',
'; ',
': ',
'・ ',
' (',
') ',
' 【',
'】 ',
' {',
'} ',
' 「',
'」 ',
' 『',
'』 ',
' 〜 ',
'… ',
];
$html = str_replace( $search, $replacement, $html );
// Fix for DomPDF: unwrap long field content from table cells for notification templates.
$html = $this->unwrap_long_fields( $html );
return $html;
}
/**
* Unwrap long field content from table cells for notification templates.
*
* DomPDF cannot split table cell content across pages (dompdf/dompdf#98).
* This method delegates to field-specific unwrap methods when the HTML
* contains matching markers and the template is a notification type.
*
* @since 1.4.0
*
* @param string $html HTML content.
*
* @return string Modified HTML.
*/
private function unwrap_long_fields( string $html ): string {
if ( strpos( $this->get_base_template_slug(), 'notification' ) !== 0 ) {
return $html;
}
$unwrap_methods = [
'wpforms-order-summary-container' => 'unwrap_order_summary_smart_tag',
'field-content' => 'unwrap_content_field',
];
foreach ( $unwrap_methods as $marker => $method ) {
if ( strpos( $html, $marker ) !== false ) {
$html = $this->$method( $html );
}
}
return $html;
}
/**
* Unwrap the order summary smart-tag table cell into a div.
*
* Finds the `<tr>` row containing the order summary and delegates
* to unwrap_table_row(). Covers both `{order_summary}` smart tag
* (tr.smart-tag) and `{all_fields}` (tr.field-payment-total).
*
* @since 1.4.0
*
* @param string $html HTML content.
*
* @return string Modified HTML.
*/
private function unwrap_order_summary_smart_tag( string $html ): string {
[ $dom, $xpath ] = Helpers::load_dom( $html );
// Find the table row that contains the order summary.
// Covers both {order_summary} smart tag (tr.smart-tag) and {all_fields} (tr.field-payment-total).
$smart_tag_row = $xpath->query( '//tr[contains(@class,"smart-tag") or contains(@class,"field-payment-total")][.//div[contains(@class,"wpforms-order-summary-container")]]' )->item( 0 );
if ( ! $smart_tag_row ) {
return $html;
}
$this->unwrap_table_row( $dom, $xpath, $smart_tag_row, 'smart-tag', 'margin-top: 15px;' );
return Helpers::save_html( $dom );
}
/**
* Unwrap content field table cells into divs for DomPDF compatibility.
*
* Finds all `<tr class="field-content">` rows and extracts their value
* cells into `<div>` elements that DomPDF can split across pages.
* Processes rows in reverse order to preserve DOM positions.
*
* @since 1.4.0
*
* @param string $html HTML content.
*
* @return string Modified HTML.
*/
private function unwrap_content_field( string $html ): string {
[ $dom, $xpath ] = Helpers::load_dom( $html );
// Find all content field rows.
$content_rows = $xpath->query( '//tr[contains(@class,"field-content")]' );
if ( ! $content_rows || $content_rows->length === 0 ) {
return $html;
}
// Process rows in reverse order to preserve DOM positions.
for ( $i = $content_rows->length - 1; $i >= 0; $i-- ) {
$this->unwrap_table_row( $dom, $xpath, $content_rows->item( $i ), 'field-content field-content-unwrapped', '' );
}
return Helpers::save_html( $dom );
}
/**
* Unwrap a single table row into a div.
*
* Extracts a row from its wrapping table and replaces it with a `<div>`.
* Sibling rows before and after are preserved in separate tables.
*
* @see unwrap_long_fields() For the DomPDF limitation context.
*
* @since 1.4.0
*
* @param DOMDocument $dom DOMDocument instance.
* @param DOMXPath $xpath DOMXPath instance.
* @param DOMNode $row Table row to unwrap.
* @param string $div_class CSS class for the wrapper div.
* @param string $div_style Inline style for the wrapper div.
*
* @return void
*/
private function unwrap_table_row( DOMDocument $dom, DOMXPath $xpath, DOMNode $row, string $div_class, string $div_style ): void {
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
// Get the wrapping <table> element.
$table = $row->parentNode;
// Walk up past <tbody> if present.
if ( $table && strtolower( $table->nodeName ) === 'tbody' ) {
$table = $table->parentNode;
}
if ( ! $table || ! $table->parentNode || strtolower( $table->nodeName ) !== 'table' ) {
return;
}
// Get the <td> with field content.
$td = Helpers::get_value_cell( $xpath, $row );
if ( ! $td ) {
return;
}
// Create a wrapper div with the field content.
$div = $dom->createElement( 'div' );
$div->setAttribute( 'class', $div_class );
if ( $div_style !== '' ) {
$div->setAttribute( 'style', $div_style );
}
while ( $td->firstChild ) {
$div->appendChild( $td->firstChild );
}
// Collect sibling element rows after the target row.
$after_rows = $this->collect_next_element_siblings( $row );
// Remove the target row from the table.
$row->parentNode->removeChild( $row );
$table_parent = $table->parentNode;
// Insert the div after the table.
$table_parent->insertBefore( $div, $table->nextSibling );
// Move rows after the target row into a new table.
if ( ! empty( $after_rows ) ) {
$new_table = $table->cloneNode( false );
$new_tbody = $dom->createElement( 'tbody' );
$new_table->appendChild( $new_tbody );
foreach ( $after_rows as $after_row ) {
$new_tbody->appendChild( $after_row );
}
$table_parent->insertBefore( $new_table, $div->nextSibling );
}
// Remove the original table if it has no rows left.
if ( $xpath->query( './/tr', $table )->length === 0 ) {
$table_parent->removeChild( $table );
}
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
}
/**
* Collect all next element siblings of a DOM node.
*
* @since 1.4.0
*
* @param DOMNode $node Starting node.
*
* @return DOMNode[] Array of element sibling nodes.
*/
private function collect_next_element_siblings( DOMNode $node ): array {
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$elements = [];
$sibling = $node->nextSibling;
while ( $sibling ) {
$next = $sibling->nextSibling;
if ( $sibling->nodeType === XML_ELEMENT_NODE ) {
$elements[] = $sibling;
}
$sibling = $next;
}
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
return $elements;
}
/**
* Get appearance settings for the template.
*
* @since 1.0.0
*
* @return array
*/
private function get_appearance_settings(): array {
$appearance = $this->template['appearance'];
if ( ! $this->settings['is_preview'] ) {
return $appearance;
}
$as_is_keys = [ 'logo_id', 'logo_url', 'logo_position', 'logo_size', 'page_background_image', 'container_shadow' ];
foreach ( $appearance as $key => $value ) {
if ( in_array( $key, $as_is_keys, true ) ) {
continue;
}
$appearance[ $key ] = "var( --wpforms-appearance-$key, var( --wpforms-appearance-$key-default, $value ) )";
}
return $appearance;
}
/**
* Get theme colors for the template.
*
* @since 1.0.0
*
* @return array
*/
private function get_theme_colors(): array {
$colors = $this->template['theme']['colors'];
if ( ! $this->settings['is_preview'] ) {
return $colors;
}
$colors = $this->get_unsaved_theme_colors();
foreach ( $colors as $key => $value ) {
$colors[ $key ] = "var( --wpforms-theme-color-$key, $value )";
}
return $colors;
}
/**
* Get theme colors for the template.
*
* @since 1.0.0
*
* @return array
*/
private function get_unsaved_theme_colors(): array {
$colors = $this->template['theme']['colors'];
$pdf = $this->get_pdf_settings();
foreach ( $colors as $key => $value ) {
$colors[ $key ] = $pdf[ 'theme_color_' . $key ] ?? $value;
}
return $colors;
}
/**
* Process texts.
*
* @since 1.0.0
*
* @param array $texts Texts.
*
* @return array
*/
private function process_texts( array $texts ): array {
// Get template texts.
$template_text = $this->template['text'];
$template_text = wp_parse_args( $texts, $template_text );
$all_fields = $template_text['all_fields'] ?? $this->render_all_fields();
$context = $this->settings['is_preview'] ? 'wpforms-pdf-preview-process-texts' : 'wpforms-pdf-process-texts';
// Process texts.
foreach ( $template_text as $key => $value ) {
if ( $this->settings['is_preview'] && strpos( $key, '_color' ) !== false ) {
$template_text[ $key ] = "var( --wpforms-text-color-$key, $value )";
continue;
}
$template_text[ $key ] = wpforms_process_smart_tags( wp_unslash( $value ), $this->form_data, $this->entry_fields, $this->entry_id, $context );
$template_text[ $key ] = str_replace( '{all_fields}', $all_fields, $template_text[ $key ] );
}
$template_text['content'] = $template_text['content'] ?? $all_fields;
return $template_text;
}
/**
* Get rendered {all_fields} for a given email template.
*
* @since 1.0.0
*
* @param string $template Template slug.
*
* @return string
*/
public function render_all_fields( string $template = '' ): string {
$template = empty( $template ) ? $this->template['email_style'] : $template;
// Create a new email.
$emails = ( new Notifications() )->init( $template, 'pdf' );
$emails->__set( 'form_data', $this->form_data );
$emails->__set( 'fields', $this->entry_fields );
// Render all fields.
$all_fields = $emails->get_processed_field_values();
return '<table class="all_fields">' . $all_fields . '</table>';
}
/**
* Get a base template slug.
*
* @since 1.0.0
*
* @param string $template Template slug. Optional.
*
* @return string
*/
private function get_base_template_slug( string $template = '' ): string {
$template = empty( $template ) ? $this->settings['template'] : $template;
return ! empty( $this->template['baseTemplate'] ) ? $this->template['baseTemplate'] : $template;
}
/**
* Get a template location.
*
* @since 1.0.0
*
* @param string $template Template slug. Optional.
*
* @return string
*/
private function get_template_location( string $template = '' ): string {
$template = $template !== 'html' ? $this->get_base_template_slug( $template ) : $template;
return sprintf(
'%1$stemplates/pdf/%2$s',
WPFORMS_PDF_PATH,
$template
);
}
/**
* Get a template CSS file path.
*
* @since 1.0.0
*
* @param string $purpose CSS purpose `html` or `pdf_tuning`. Defaults to `html`.
*
* @return string
* @noinspection PhpSameParameterValueInspection
*/
private function get_css_file( string $purpose = '' ): string {
$template = $this->get_base_template_slug();
$sub_dir = $purpose === 'pdf_tuning' ? '/tuning' : '';
return sprintf(
'%1$sassets/css/pdf%2$s/%3$s%4$s.css',
WPFORMS_PDF_PATH,
$sub_dir,
$template,
wpforms_get_min_suffix()
);
}
/**
* Get a font CSS file path.
*
* @since 1.2.0
*
* @param string $purpose CSS purpose `html` or `pdf_tuning`. Defaults to `html`.
*
* @return string
* @noinspection PhpSameParameterValueInspection
*/
private function get_fonts_css_file( string $purpose = '' ): string {
$sub_dir = $purpose === 'pdf_tuning' ? '/tuning' : '';
return sprintf(
'%1$sassets/css/pdf%2$s/fonts%3$s.css',
WPFORMS_PDF_PATH,
$sub_dir,
wpforms_get_min_suffix()
);
}
/**
* Get template CSS file content.
*
* @since 1.0.0
*
* @param string $purpose CSS purpose `html` or `pdf_tuning`. Defaults to `html`.
*
* @return string
* @noinspection PhpSameParameterValueInspection
*/
private function get_css_file_content( string $purpose = 'html' ): string {
$css_content = File::get_contents( $this->get_css_file( $purpose ) );
if ( empty( $css_content ) ) {
return '';
}
return str_replace( '{WPFORMS_PDF_URL}', $this->get_pdf_base_url(), $css_content );
}
/**
* Get fonts CSS file content.
*
* @since 1.2.0
*
* @param string $purpose CSS purpose `html` or `pdf_tuning`. Defaults to `html`.
*
* @return string
* @noinspection PhpSameParameterValueInspection
*/
private function get_fonts_css_file_content( string $purpose = 'html' ): string {
$css_content = File::get_contents( $this->get_fonts_css_file( $purpose ) );
if ( empty( $css_content ) ) {
return '';
}
return str_replace( '{WPFORMS_PDF_URL}', $this->get_pdf_base_url(), $css_content );
}
/**
* Get PDF base URL.
*
* @since 1.0.0
*
* @return string
*/
private function get_pdf_base_url(): string {
return empty( $this->settings['is_preview'] ) ? '' : WPFORMS_PDF_URL;
}
/**
* Get image content.
*
* @since 1.0.0
*
* @param string $location Image location.
* It could be a URL or a path to an image located in the `assets/images` folder.
*
* @return string
*/
private function get_image( string $location ): string {
if ( empty( $location ) ) {
return '';
}
[ $location_norm, $site_url ] = $this->normalize_urls( [ $location, site_url() ] );
$upload_dir = wpforms_pdf()->helpers->get_wp_upload_dir();
// If the image doesn't start with the full local path to addon
// or the full URL to the default logos, assume this is the image in the `assets /images/` directory.
if (
strpos( $location, WPFORMS_PDF_PATH ) === false &&
strpos( $location_norm, $site_url ) === false
) {
$location = WPFORMS_PDF_PATH . 'assets/images/' . $location;
}
// Convert the image URL to the full local path.
$location = str_replace(
[ $upload_dir['url'], WPFORMS_PDF_URL ],
[ $upload_dir['dir'], WPFORMS_PDF_PATH ],
$location
);
return $this->get_image_data( $location );
}
/**
* Get data:image string from given image location.
*
* @since 1.0.0
*
* @param string $location Image location.
*
* @return string
*/
private function get_image_data( string $location ): string {
$image_ext = pathinfo( $location, PATHINFO_EXTENSION );
$image_content = File::get_contents( $location );
if ( empty( $image_content ) ) {
return '';
}
// Skip unsupported image types.
if ( ! isset( self::IMAGE_TYPES[ $image_ext ] ) ) {
return '';
}
// Add colors to SVG image.
if ( $image_ext === 'svg' ) {
$image_content = $this->templates_obj->replace_colors( $image_content, $this->get_colors_for_image( $location ) );
}
$type = self::IMAGE_TYPES[ $image_ext ];
if ( ! empty( $this->settings['is_preview'] ) ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return "data:$type;base64," . base64_encode( $image_content );
}
// Add an image to the `images` array in case it is an actual PDF generation process.
$upload_dir = wpforms_pdf()->helpers->get_wp_upload_dir();
$short_location = str_replace( [ WPFORMS_PDF_PATH, $upload_dir['dir'] ], '', $location );
$this->images[ $short_location ] = $image_ext !== 'svg' ? base64_encode( $image_content ) : $image_content; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
// Return a short path to the image.
return $short_location;
}
/**
* Get colors for the image.
*
* @since 1.0.0
*
* @param string $location Image location.
*
* @return array
*/
private function get_colors_for_image( string $location ): array {
$colors = $this->template['theme']['colors'];
if ( $this->settings['is_preview'] ) {
$colors = $this->get_unsaved_theme_colors();
}
if ( strpos( $location, 'background' ) === false ) {
return $colors;
}
// Adjust theme colors according to the custom colors defined in the template.
$colors['background'] = $this->templates_obj->replace_colors( $this->template['appearance']['page_background_color'], $colors );
$colors['background_light'] = $this->templates_obj->replace_colors( $this->template['appearance']['page_background_color_end'], $colors );
return $colors;
}
/**
* Get normalized URLs.
* For cases when the image was uploaded before enabling https on the server.
*
* @since 1.0.0
*
* @param array $urls URLs.
*
* @noinspection HttpUrlsUsage
*/
private function normalize_urls( array $urls ): array {
foreach ( $urls as $key => $url ) {
$urls[ $key ] = str_replace( 'http://', 'https://', $url );
}
return $urls;
}
/**
* Get badge image content.
*
* @since 1.0.0
*
* @return string
*/
private function get_badge_image(): string {
$style = $this->template['style'] ?? '';
return $this->get_image( "badges/$style.svg" );
}
/**
* Get background image content.
*
* @since 1.0.0
*
* @return string
*/
private function get_background_image(): string {
$location = $this->get_background_image_location();
// If there is no background image, return transparent PNG.
// This is required for PDF export.
if ( empty( $location ) ) {
return self::TRANSPARENT_PNG;
}
return $this->get_image( $location );
}
/**
* Get a background image file location.
*
* @since 1.0.0
*
* @param string $image Image file name, without `.svg`. Optional.
*
* @return string
* @noinspection PhpSameParameterValueInspection
*/
private function get_background_image_location( string $image = '' ): string {
$image = empty( $image ) ? $this->template['appearance']['page_background_image'] : $image;
$orientation = $this->settings['orientation'] ?? '';
$orientation = $orientation === 'landscape' ? 'landscape' : 'portrait';
$file = wp_normalize_path( WPFORMS_PDF_PATH . "assets/images/background/$orientation/$image" );
$svg_file = $file . '.svg';
if ( File::exists( $svg_file ) ) {
return $svg_file;
}
$png_file = $file . '.png';
if ( ! File::exists( $png_file ) ) {
return '';
}
// Return URL to the PNG file.
return esc_url( str_replace( WPFORMS_PDF_PATH, WPFORMS_PDF_URL, $png_file ) );
}
/**
* Get images data.
*
* @since 1.0.0
*
* @return array
*/
public function get_images_data(): array {
return $this->images;
}
/**
* Update images data by finding all img tags in HTML.
*
* @since 1.0.0
*
* @param string $html HTML content.
*
* @return void
*/
private function update_images_data( string $html ): void {
// Skip if HTML is empty.
if ( empty( $html ) ) {
return;
}
// Create a new DOMDocument.
$dom = new DOMDocument();
// Suppress warnings from malformed HTML.
libxml_use_internal_errors( true );
// Load HTML content.
$dom->loadHTML( $html );
// Reset errors.
libxml_clear_errors();
// Get all image tags.
$images = $dom->getElementsByTagName( 'img' );
// Skip if no images found.
if ( $images->length === 0 ) {
return;
}
$upload_dir = wpforms_pdf()->helpers->get_wp_upload_dir();
$site_url = site_url();
// Process each image.
foreach ( $images as $image ) {
$this->update_images_data_process_image( $image, $upload_dir, $site_url );
}
}
/**
* Process a single image from the DOM.
*
* @since 1.0.0
*
* @param DOMElement $image Image DOM element.
* @param array $upload_dir WordPress upload directory information.
* @param string $site_url Site URL.
*
* @return void
*/
private function update_images_data_process_image( DOMElement $image, array $upload_dir, string $site_url ): void {
// Get image source.
$src = $image->getAttribute( 'src' );
// Skip if the source is empty or already processed.
if ( empty( $src ) || isset( $this->images[ $src ] ) || strpos( $src, 'data:' ) ) {
return;
}
// Convert URL to a local path.
$local_path = str_replace( $upload_dir['url'], $upload_dir['dir'], $src );
// Also try with site URL.
if ( ! file_exists( $local_path ) ) {
$local_path = str_replace( $site_url, ABSPATH, $src );
}
$local_path = wp_normalize_path( $local_path );
// Skip if a file doesn't exist.
if ( ! File::exists( $local_path ) ) {
return;
}
// Get file extension.
$image_ext = pathinfo( $local_path, PATHINFO_EXTENSION );
// Skip unsupported image types.
if ( ! isset( self::IMAGE_TYPES[ $image_ext ] ) ) {
return;
}
// Get image content.
$image_content = File::get_contents( $local_path );
// Skip if the content is empty.
if ( empty( $image_content ) ) {
return;
}
// Add an image to the image array.
$this->images[ $src ] = $image_ext !== 'svg' ? base64_encode( $image_content ) : $image_content; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}
/**
* Filter the smart tag value.
*
* @since 1.0.0
*
* @param string|mixed $value Smart Tag value.
* @param string $tag_name Smart tag name.
* @param array $form_data Form data.
* @param array $fields List of fields.
* @param string $entry_id Entry ID.
* @param SmartTag $smart_tag_object The smart tag object or the Generic object.
* @param string $context Context.
*
* @return string|null
* @noinspection PhpUnusedParameterInspection
*/
public static function filter_smarttags_process_value( $value, string $tag_name, array $form_data, array $fields, string $entry_id, SmartTag $smart_tag_object, string $context ): ?string {
// Don't replace the `field_id` smart tag in the PDF preview texts.
if ( $context === 'wpforms-pdf-preview-process-texts' && $tag_name === 'field_id' ) {
return null;
}
return $value;
}
}