HEX
Server: LiteSpeed
System: Linux server315.web-hosting.com 4.18.0-553.54.1.lve.el8.x86_64 #1 SMP Wed Jun 4 13:01:13 UTC 2025 x86_64
User: globfdxw (6114)
PHP: 8.1.34
Disabled: NONE
Upload Files
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;
	}
}