ContentRenderer
3 days ago
class-html2text-exception.php
11 months ago
class-html2text.php
11 months ago
class-renderer.php
3 days ago
index.php
11 months ago
interface-css-inliner.php
11 months ago
template-canvas.css
3 months ago
template-canvas.php
3 months ago
class-html2text.php
438 lines
| 1 | <?php |
| 2 | declare( strict_types = 1 ); |
| 3 | namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer; |
| 4 | if (!defined('ABSPATH')) exit; |
| 5 | class Html2Text { |
| 6 | public static function default_options(): array { |
| 7 | return array( |
| 8 | 'ignore_errors' => false, |
| 9 | 'drop_links' => false, |
| 10 | 'char_set' => 'auto', |
| 11 | ); |
| 12 | } |
| 13 | public static function convert( string $html, $options = array() ): string { |
| 14 | if ( false === $options || true === $options ) { |
| 15 | // Using old style (< 1.0) of passing in options. |
| 16 | $options = array( 'ignore_errors' => $options ); |
| 17 | } |
| 18 | $options = array_merge( static::default_options(), $options ); |
| 19 | // Check all options are valid. |
| 20 | foreach ( array_keys( $options ) as $key ) { |
| 21 | if ( ! in_array( $key, array_keys( static::default_options() ), true ) ) { |
| 22 | // Log invalid option for debugging purposes without exposing in exception. |
| 23 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Security: Logging sensitive data separately from user-facing exception messages. |
| 24 | error_log( 'Html2Text: Invalid option provided: ' . htmlspecialchars( (string) $key, ENT_QUOTES, 'UTF-8' ) . '. Valid options are: ' . htmlspecialchars( implode( ',', array_keys( static::default_options() ) ), ENT_QUOTES, 'UTF-8' ) ); |
| 25 | // Throw generic error message to avoid exposing user input. |
| 26 | throw new \InvalidArgumentException( 'Invalid option provided for html2text conversion.' ); |
| 27 | } |
| 28 | } |
| 29 | $is_office_document = self::is_office_document( $html ); |
| 30 | if ( $is_office_document ) { |
| 31 | // Remove office namespace. |
| 32 | $html = str_replace( array( '<o:p>', '</o:p>' ), '', $html ); |
| 33 | } |
| 34 | $html = self::fix_newlines( $html ); |
| 35 | // Use mb_convert_encoding for legacy versions of php. |
| 36 | if ( PHP_MAJOR_VERSION * 10 + PHP_MINOR_VERSION < 81 && mb_detect_encoding( $html, 'UTF-8', true ) ) { |
| 37 | $converted = mb_convert_encoding( $html, 'HTML-ENTITIES', 'UTF-8' ); |
| 38 | $html = false !== $converted ? $converted : $html; |
| 39 | } |
| 40 | // Ensure $html is always a string before passing to get_document. |
| 41 | if ( ! is_string( $html ) ) { |
| 42 | $html = (string) $html; |
| 43 | } |
| 44 | $doc = self::get_document( $html, $options ); |
| 45 | $output = self::iterate_over_node( $doc, null, false, $is_office_document, $options ); |
| 46 | // Process output for whitespace/newlines. |
| 47 | $output = self::process_whitespace_newlines( $output ); |
| 48 | return $output; |
| 49 | } |
| 50 | public static function fix_newlines( string $text ): string { |
| 51 | // Replace \r\n to \n. |
| 52 | $text = str_replace( "\r\n", "\n", $text ); |
| 53 | // Remove \rs. |
| 54 | $text = str_replace( "\r", "\n", $text ); |
| 55 | return $text; |
| 56 | } |
| 57 | public static function nbsp_codes(): array { |
| 58 | return array( |
| 59 | "\xc2\xa0", |
| 60 | "\u00a0", |
| 61 | ); |
| 62 | } |
| 63 | public static function zwnj_codes(): array { |
| 64 | return array( |
| 65 | "\xe2\x80\x8c", |
| 66 | "\u200c", |
| 67 | ); |
| 68 | } |
| 69 | public static function process_whitespace_newlines( string $text ): string { |
| 70 | // Remove excess spaces around tabs. |
| 71 | $result = preg_replace( '/ *\t */im', "\t", $text ); |
| 72 | $text = null !== $result ? $result : $text; |
| 73 | // Remove leading whitespace. |
| 74 | $text = ltrim( $text ); |
| 75 | // Remove leading spaces on each line. |
| 76 | $result = preg_replace( "/\n[ \t]*/im", "\n", $text ); |
| 77 | $text = null !== $result ? $result : $text; |
| 78 | // Convert non-breaking spaces to regular spaces to prevent output issues, |
| 79 | // do it here so they do NOT get removed with other leading spaces, as they |
| 80 | // are sometimes used for indentation. |
| 81 | $text = self::render_text( $text ); |
| 82 | // Remove trailing whitespace. |
| 83 | $text = rtrim( $text ); |
| 84 | // Remove trailing spaces on each line. |
| 85 | $result = preg_replace( "/[ \t]*\n/im", "\n", $text ); |
| 86 | $text = null !== $result ? $result : $text; |
| 87 | // Unarmor pre blocks. |
| 88 | $text = self::fix_newlines( $text ); |
| 89 | // Remove unnecessary empty lines. |
| 90 | $result = preg_replace( "/\n\n\n*/im", "\n\n", $text ); |
| 91 | return null !== $result ? $result : $text; |
| 92 | } |
| 93 | public static function is_office_document( string $html ): bool { |
| 94 | return strpos( $html, 'urn:schemas-microsoft-com:office' ) !== false; |
| 95 | } |
| 96 | public static function is_whitespace( string $text ): bool { |
| 97 | return 0 === strlen( trim( self::render_text( $text ), "\n\r\t " ) ); |
| 98 | } |
| 99 | private static function get_document( string $html, array $options ): \DOMDocument { |
| 100 | $doc = new \DOMDocument(); |
| 101 | $html = trim( $html ); |
| 102 | if ( ! $html ) { |
| 103 | // DOMDocument doesn't support empty value and throws an error. |
| 104 | // Return empty document instead. |
| 105 | return $doc; |
| 106 | } |
| 107 | if ( '<' !== $html[0] ) { |
| 108 | // If HTML does not begin with a tag, we put a body tag around it. |
| 109 | // If we do not do this, PHP will insert a paragraph tag around |
| 110 | // the first block of text for some reason which can mess up |
| 111 | // the newlines. See pre.html test for an example. |
| 112 | $html = '<body>' . $html . '</body>'; |
| 113 | } |
| 114 | $header = ''; |
| 115 | // Use char sets for modern versions of php. |
| 116 | if ( PHP_MAJOR_VERSION * 10 + PHP_MINOR_VERSION >= 81 ) { |
| 117 | // Use specified char_set, or auto detect if not set. |
| 118 | $char_set = ! empty( $options['char_set'] ) && is_string( $options['char_set'] ) ? $options['char_set'] : 'auto'; |
| 119 | if ( 'auto' === $char_set ) { |
| 120 | $detected = mb_detect_encoding( $html ); |
| 121 | $char_set = false !== $detected ? $detected : 'UTF-8'; |
| 122 | } elseif ( strpos( $char_set, ',' ) !== false ) { |
| 123 | $encoding_list = explode( ',', $char_set ); |
| 124 | $encoding_list = array_map( 'trim', $encoding_list ); |
| 125 | $encoding_list = array_filter( |
| 126 | $encoding_list, |
| 127 | function ( $encoding ) { |
| 128 | return ! empty( $encoding ); |
| 129 | } |
| 130 | ); |
| 131 | if ( ! empty( $encoding_list ) ) { |
| 132 | // Ensure we have a proper list with consecutive integer keys. |
| 133 | $encoding_list = array_values( $encoding_list ); |
| 134 | mb_detect_order( $encoding_list ); |
| 135 | $detected = mb_detect_encoding( $html ); |
| 136 | $char_set = false !== $detected ? $detected : 'UTF-8'; |
| 137 | } |
| 138 | } |
| 139 | // Turn off error detection for Windows-1252 legacy html. |
| 140 | if ( strpos( $char_set, '1252' ) !== false ) { |
| 141 | $options['ignore_errors'] = true; |
| 142 | } |
| 143 | $header = '<?xml version="1.0" encoding="' . $char_set . '">'; |
| 144 | } |
| 145 | if ( ! empty( $options['ignore_errors'] ) ) { |
| 146 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 147 | $doc->strictErrorChecking = false; |
| 148 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 149 | $doc->recover = true; |
| 150 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 151 | $doc->xmlStandalone = true; |
| 152 | $old_internal_errors = libxml_use_internal_errors( true ); |
| 153 | $load_result = $doc->loadHTML( $header . $html, LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NONET | LIBXML_PARSEHUGE ); |
| 154 | libxml_use_internal_errors( $old_internal_errors ); |
| 155 | } else { |
| 156 | $load_result = $doc->loadHTML( $header . $html ); |
| 157 | } |
| 158 | if ( ! $load_result ) { |
| 159 | // Log truncated HTML content for debugging purposes (limit to 500 chars to prevent log bloat). |
| 160 | $html_preview = strlen( $html ) > 500 ? substr( $html, 0, 500 ) . '...[truncated]' : $html; |
| 161 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Security: Logging sensitive data separately from user-facing exception messages. |
| 162 | error_log( 'Html2Text: Failed to load HTML content: ' . htmlspecialchars( $html_preview, ENT_QUOTES, 'UTF-8' ) ); |
| 163 | // Throw a generic error message to avoid exposing sensitive data. |
| 164 | throw new Html2Text_Exception( 'Could not load HTML - the content may be malformed.' ); |
| 165 | } |
| 166 | return $doc; |
| 167 | } |
| 168 | private static function render_text( string $text ): string { |
| 169 | $text = str_replace( self::nbsp_codes(), ' ', $text ); |
| 170 | $text = str_replace( self::zwnj_codes(), '', $text ); |
| 171 | return $text; |
| 172 | } |
| 173 | private static function next_child_name( ?\DOMNode $node ): ?string { |
| 174 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 175 | if ( null === $node || null === $node->nextSibling ) { |
| 176 | return null; |
| 177 | } |
| 178 | // Get the next child. |
| 179 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 180 | $next_node = $node->nextSibling; |
| 181 | while ( null !== $next_node ) { |
| 182 | if ( $next_node instanceof \DOMText ) { |
| 183 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 184 | if ( ! self::is_whitespace( $next_node->wholeText ) ) { |
| 185 | break; |
| 186 | } |
| 187 | } |
| 188 | if ( $next_node instanceof \DOMElement ) { |
| 189 | break; |
| 190 | } |
| 191 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 192 | $next_node = $next_node->nextSibling; |
| 193 | } |
| 194 | $next_name = null; |
| 195 | if ( $next_node instanceof \DOMElement || $next_node instanceof \DOMText ) { |
| 196 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 197 | $next_name = strtolower( $next_node->nodeName ); |
| 198 | } |
| 199 | return $next_name; |
| 200 | } |
| 201 | private static function iterate_over_node( \DOMNode $node, ?string $prev_name, bool $in_pre, bool $is_office_document, array $options ): string { |
| 202 | if ( $node instanceof \DOMText ) { |
| 203 | // Replace whitespace characters with a space (equivalent to \s). |
| 204 | if ( $in_pre ) { |
| 205 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 206 | $text = "\n" . trim( self::render_text( $node->wholeText ), "\n\r\t " ) . "\n"; |
| 207 | // Remove trailing whitespace only. |
| 208 | $result = preg_replace( "/[ \t]*\n/im", "\n", $text ); |
| 209 | $text = null !== $result ? $result : $text; |
| 210 | // Armor newlines with \r. |
| 211 | return str_replace( "\n", "\r", $text ); |
| 212 | } |
| 213 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 214 | $text = self::render_text( $node->wholeText ); |
| 215 | $result = preg_replace( "/[\\t\\n\\f\\r ]+/im", ' ', $text ); |
| 216 | $text = null !== $result ? $result : $text; |
| 217 | if ( ! self::is_whitespace( $text ) && ( 'p' === $prev_name || 'div' === $prev_name ) ) { |
| 218 | return "\n" . $text; |
| 219 | } |
| 220 | return $text; |
| 221 | } |
| 222 | if ( $node instanceof \DOMDocumentType || $node instanceof \DOMProcessingInstruction ) { |
| 223 | // Ignore. |
| 224 | return ''; |
| 225 | } |
| 226 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 227 | $name = strtolower( $node->nodeName ); |
| 228 | $next_name = self::next_child_name( $node ); |
| 229 | // Start whitespace. |
| 230 | switch ( $name ) { |
| 231 | case 'hr': |
| 232 | $prefix = ''; |
| 233 | if ( null !== $prev_name ) { |
| 234 | $prefix = "\n"; |
| 235 | } |
| 236 | return $prefix . "---------------------------------------------------------------\n"; |
| 237 | case 'style': |
| 238 | case 'head': |
| 239 | case 'title': |
| 240 | case 'meta': |
| 241 | case 'script': |
| 242 | // Ignore these tags. |
| 243 | return ''; |
| 244 | case 'h1': |
| 245 | case 'h2': |
| 246 | case 'h3': |
| 247 | case 'h4': |
| 248 | case 'h5': |
| 249 | case 'h6': |
| 250 | case 'ol': |
| 251 | case 'ul': |
| 252 | case 'pre': |
| 253 | // Add two newlines. |
| 254 | $output = "\n\n"; |
| 255 | break; |
| 256 | case 'td': |
| 257 | case 'th': |
| 258 | // Add tab char to separate table fields. |
| 259 | $output = "\t"; |
| 260 | break; |
| 261 | case 'p': |
| 262 | // Microsoft exchange emails often include HTML which, when passed through |
| 263 | // html2text, results in lots of double line returns everywhere. |
| 264 | // |
| 265 | // To fix this, for any p element with a className of `MsoNormal` (the standard |
| 266 | // classname in any Microsoft export or outlook for a paragraph that behaves |
| 267 | // like a line return) we skip the first line returns and set the name to br. |
| 268 | if ( $is_office_document && $node instanceof \DOMElement && 'MsoNormal' === $node->getAttribute( 'class' ) ) { |
| 269 | $output = ''; |
| 270 | $name = 'br'; |
| 271 | break; |
| 272 | } |
| 273 | // Add two lines. |
| 274 | $output = "\n\n"; |
| 275 | break; |
| 276 | case 'tr': |
| 277 | // Add one line. |
| 278 | $output = "\n"; |
| 279 | break; |
| 280 | case 'div': |
| 281 | $output = ''; |
| 282 | if ( null !== $prev_name ) { |
| 283 | // Add one line. |
| 284 | $output .= "\n"; |
| 285 | } |
| 286 | break; |
| 287 | case 'li': |
| 288 | $output = '- '; |
| 289 | break; |
| 290 | default: |
| 291 | // Print out contents of unknown tags. |
| 292 | $output = ''; |
| 293 | break; |
| 294 | } |
| 295 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 296 | if ( $node->childNodes->length > 0 ) { |
| 297 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 298 | $n = $node->childNodes->item( 0 ); |
| 299 | $previous_sibling_names = array(); |
| 300 | $previous_sibling_name = null; |
| 301 | $parts = array(); |
| 302 | $trailing_whitespace = 0; |
| 303 | while ( null !== $n ) { |
| 304 | $text = self::iterate_over_node( $n, $previous_sibling_name, $in_pre || 'pre' === $name, $is_office_document, $options ); |
| 305 | // Pass current node name to next child, as previousSibling does not appear to get populated. |
| 306 | if ( $n instanceof \DOMDocumentType |
| 307 | || $n instanceof \DOMProcessingInstruction |
| 308 | || ( $n instanceof \DOMText && self::is_whitespace( $text ) ) ) { |
| 309 | // Keep current previousSiblingName, these are invisible. |
| 310 | ++$trailing_whitespace; |
| 311 | } else { |
| 312 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 313 | $previous_sibling_name = strtolower( $n->nodeName ); |
| 314 | $previous_sibling_names[] = $previous_sibling_name; |
| 315 | $trailing_whitespace = 0; |
| 316 | } |
| 317 | $node->removeChild( $n ); |
| 318 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 319 | $n = $node->childNodes->item( 0 ); |
| 320 | $parts[] = $text; |
| 321 | } |
| 322 | // Remove trailing whitespace, important for the br check below. |
| 323 | while ( $trailing_whitespace-- > 0 ) { |
| 324 | array_pop( $parts ); |
| 325 | } |
| 326 | // Suppress last br tag inside a node list if follows text. |
| 327 | $last_name = array_pop( $previous_sibling_names ); |
| 328 | if ( 'br' === $last_name ) { |
| 329 | $last_name = array_pop( $previous_sibling_names ); |
| 330 | if ( '#text' === $last_name ) { |
| 331 | array_pop( $parts ); |
| 332 | } |
| 333 | } |
| 334 | $output .= implode( '', $parts ); |
| 335 | } |
| 336 | // End whitespace. |
| 337 | switch ( $name ) { |
| 338 | case 'h1': |
| 339 | case 'h2': |
| 340 | case 'h3': |
| 341 | case 'h4': |
| 342 | case 'h5': |
| 343 | case 'h6': |
| 344 | case 'pre': |
| 345 | case 'p': |
| 346 | // Add two lines. |
| 347 | $output .= "\n\n"; |
| 348 | break; |
| 349 | case 'br': |
| 350 | // Add one line. |
| 351 | $output .= "\n"; |
| 352 | break; |
| 353 | case 'div': |
| 354 | break; |
| 355 | case 'a': |
| 356 | // Links are returned in [text](link) format. |
| 357 | $href = $node instanceof \DOMElement ? $node->getAttribute( 'href' ) : ''; |
| 358 | $output = trim( $output ); |
| 359 | // Remove double [[ ]] s from linking images. |
| 360 | if ( '[' === substr( $output, 0, 1 ) && ']' === substr( $output, -1 ) ) { |
| 361 | $output = substr( $output, 1, strlen( $output ) - 2 ); |
| 362 | // For linking images, the title of the <a> overrides the title of the <img>. |
| 363 | if ( $node instanceof \DOMElement && $node->getAttribute( 'title' ) ) { |
| 364 | $output = $node->getAttribute( 'title' ); |
| 365 | } |
| 366 | } |
| 367 | // If there is no link text, but a title attr. |
| 368 | if ( ! $output && $node instanceof \DOMElement && $node->getAttribute( 'title' ) ) { |
| 369 | $output = $node->getAttribute( 'title' ); |
| 370 | } |
| 371 | if ( ! $href ) { |
| 372 | // It doesn't link anywhere. |
| 373 | if ( $node instanceof \DOMElement && $node->getAttribute( 'name' ) ) { |
| 374 | if ( $options['drop_links'] ) { |
| 375 | $output = "$output"; |
| 376 | } else { |
| 377 | $output = "[$output]"; |
| 378 | } |
| 379 | } |
| 380 | } elseif ( $href === $output || "mailto:$output" === $href || "http://$output" === $href || "https://$output" === $href ) { |
| 381 | // Link to the same address: just use link. |
| 382 | $output = "$output"; |
| 383 | } elseif ( $output ) { |
| 384 | // Replace it. |
| 385 | if ( $options['drop_links'] ) { |
| 386 | $output = "$output"; |
| 387 | } else { |
| 388 | $output = "[$output]($href)"; |
| 389 | } |
| 390 | } else { |
| 391 | // Empty string. |
| 392 | $output = "$href"; |
| 393 | } |
| 394 | // Does the next node require additional whitespace? |
| 395 | switch ( $next_name ) { |
| 396 | case 'h1': |
| 397 | case 'h2': |
| 398 | case 'h3': |
| 399 | case 'h4': |
| 400 | case 'h5': |
| 401 | case 'h6': |
| 402 | $output .= "\n"; |
| 403 | break; |
| 404 | } |
| 405 | break; |
| 406 | case 'img': |
| 407 | if ( $node instanceof \DOMElement && $node->getAttribute( 'title' ) ) { |
| 408 | $output = '[' . $node->getAttribute( 'title' ) . ']'; |
| 409 | } elseif ( $node instanceof \DOMElement && $node->getAttribute( 'alt' ) ) { |
| 410 | $output = '[' . $node->getAttribute( 'alt' ) . ']'; |
| 411 | } else { |
| 412 | $output = ''; |
| 413 | } |
| 414 | break; |
| 415 | case 'li': |
| 416 | $output .= "\n"; |
| 417 | break; |
| 418 | case 'blockquote': |
| 419 | // Process quoted text for whitespace/newlines. |
| 420 | $output = self::process_whitespace_newlines( $output ); |
| 421 | // Add leading newline. |
| 422 | $output = "\n" . $output; |
| 423 | // Prepend '> ' at the beginning of all lines. |
| 424 | $result = preg_replace( "/\n/im", "\n> ", $output ); |
| 425 | $output = null !== $result ? $result : $output; |
| 426 | // Replace leading '> >' with '>>'. |
| 427 | $result = preg_replace( "/\n> >/im", "\n>>", $output ); |
| 428 | $output = null !== $result ? $result : $output; |
| 429 | // Add another leading newline and trailing newlines. |
| 430 | $output = "\n" . $output . "\n\n"; |
| 431 | break; |
| 432 | default: |
| 433 | // Do nothing. |
| 434 | } |
| 435 | return $output; |
| 436 | } |
| 437 | } |
| 438 |