*/ private array $raw_html_placeholders = []; /** * Prepare HTML for markdown conversion. * * @param string $html HTML fragment. * * @return string */ public function prepare( string $html ): string { $this->raw_html_placeholders = []; if ( '' === trim( $html ) || ! class_exists( '\DOMDocument' ) ) { return $html; } $dom = $this->load_fragment_dom( $html ); if ( ! $dom instanceof \DOMDocument ) { return $html; } $root = $dom->getElementById( 'wp-aisignal-markdown-converter-root' ); if ( ! $root instanceof \DOMElement ) { return $html; } $this->normalize_picture_elements( $dom, $root ); $this->normalize_figure_elements( $dom, $root ); $this->replace_complex_tables( $dom, $root ); return $this->get_inner_html( $root ); } /** * Restore raw HTML placeholders after markdown conversion. * * @param string $markdown Markdown output. * * @return string */ public function restore( string $markdown ): string { foreach ( $this->raw_html_placeholders as $placeholder => $html ) { $markdown = str_replace( $placeholder, "\n\n{$html}\n\n", $markdown ); } $markdown = preg_replace( "/\n{3,}/", "\n\n", $markdown ); return is_string( $markdown ) ? trim( $markdown ) : ''; } /** * Load an HTML fragment into a DOM document. * * @param string $html HTML fragment. * * @return \DOMDocument|null */ private function load_fragment_dom( string $html ) { $dom = new \DOMDocument( '1.0', 'UTF-8' ); $libxml_previous = libxml_use_internal_errors( true ); $wrapped = '
' . $html . '
'; $loaded = $dom->loadHTML( '' . $wrapped ); libxml_clear_errors(); libxml_use_internal_errors( $libxml_previous ); return $loaded ? $dom : null; } /** * Replace picture elements with their fallback image. * * @param \DOMDocument $dom DOM document. * @param \DOMElement $root Root element. * * @return void */ private function normalize_picture_elements( \DOMDocument $dom, \DOMElement $root ): void { $pictures = []; foreach ( $root->getElementsByTagName( 'picture' ) as $picture ) { if ( $picture instanceof \DOMElement ) { $pictures[] = $picture; } } foreach ( $pictures as $picture ) { $img = $this->find_first_descendant_by_tag( $picture, 'img' ); if ( ! $img instanceof \DOMElement ) { continue; } $replacement = $dom->createElement( 'img' ); foreach ( [ 'src', 'alt', 'title' ] as $attribute ) { $value = $img->getAttribute( $attribute ); if ( '' !== $value ) { $replacement->setAttribute( $attribute, $value ); } } $this->replace_node( $picture, $replacement ); } } /** * Normalize figure elements into markdown-friendly image/caption blocks. * * @param \DOMDocument $dom DOM document. * @param \DOMElement $root Root element. * * @return void */ private function normalize_figure_elements( \DOMDocument $dom, \DOMElement $root ): void { $figures = []; foreach ( $root->getElementsByTagName( 'figure' ) as $figure ) { if ( $figure instanceof \DOMElement ) { $figures[] = $figure; } } foreach ( $figures as $figure ) { $replacement = $dom->createElement( 'div' ); $caption = ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name. while ( $figure->firstChild ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name. $child = $figure->firstChild; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name. if ( $child instanceof \DOMElement && 'figcaption' === strtolower( $child->tagName ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name. $caption = trim( $child->textContent ); $figure->removeChild( $child ); continue; } $replacement->appendChild( $child ); } if ( '' !== $caption ) { $paragraph = $dom->createElement( 'p' ); $emphasis = $dom->createElement( 'em' ); $emphasis->appendChild( $dom->createTextNode( $caption ) ); $paragraph->appendChild( $emphasis ); $replacement->appendChild( $paragraph ); } $this->replace_node( $figure, $replacement ); } } /** * Replace complex tables with raw HTML placeholders. * * @param \DOMDocument $dom DOM document. * @param \DOMElement $root Root element. * * @return void */ private function replace_complex_tables( \DOMDocument $dom, \DOMElement $root ): void { $tables = []; foreach ( $root->getElementsByTagName( 'table' ) as $table ) { if ( $table instanceof \DOMElement ) { $tables[] = $table; } } foreach ( $tables as $table ) { if ( ! $this->is_complex_table( $table ) ) { continue; } $placeholder = $this->store_raw_html_placeholder( $table ); $text_node = $dom->createTextNode( $placeholder ); $this->replace_node( $table, $text_node ); } } /** * Determine whether a table cannot be represented safely as a pipe table. * * @param \DOMElement $table Table element. * * @return bool */ private function is_complex_table( \DOMElement $table ): bool { foreach ( [ 'td', 'th' ] as $tag_name ) { foreach ( $table->getElementsByTagName( $tag_name ) as $cell ) { if ( ! $cell instanceof \DOMElement ) { continue; } $colspan = (int) $cell->getAttribute( 'colspan' ); $rowspan = (int) $cell->getAttribute( 'rowspan' ); if ( $colspan > 1 || $rowspan > 1 ) { return true; } if ( $this->table_cell_contains_complex_markup( $cell ) ) { return true; } } } return $table->getElementsByTagName( 'table' )->length > 0; } /** * Determine whether a table cell contains markup beyond simple inline content. * * @param \DOMElement $cell Table cell. * * @return bool */ private function table_cell_contains_complex_markup( \DOMElement $cell ): bool { $allowed_inline_tags = [ 'a', 'abbr', 'b', 'br', 'cite', 'code', 'em', 'i', 'img', 'q', 's', 'small', 'span', 'strong', 'sub', 'sup', ]; foreach ( $cell->getElementsByTagName( '*' ) as $descendant ) { if ( ! $descendant instanceof \DOMElement ) { continue; } // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name. $tag_name = strtolower( $descendant->tagName ); if ( ! in_array( $tag_name, $allowed_inline_tags, true ) ) { return true; } } return false; } /** * Store raw HTML for later restoration. * * @param \DOMElement $node Node to preserve. * * @return string */ private function store_raw_html_placeholder( \DOMElement $node ): string { $placeholder = 'MARKDOWNCONVERTERRAWHTMLTABLE' . ( count( $this->raw_html_placeholders ) + 1 ) . 'TOKEN'; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name. $document = $node->ownerDocument; $html = $document instanceof \DOMDocument ? trim( $document->saveHTML( $node ) ) : ''; $this->raw_html_placeholders[ $placeholder ] = $html; return $placeholder; } /** * Find the first descendant element with a given tag name. * * @param \DOMElement $element Root element. * @param string $tag_name Tag name. * * @return \DOMElement|null */ private function find_first_descendant_by_tag( \DOMElement $element, string $tag_name ) { foreach ( $element->getElementsByTagName( $tag_name ) as $node ) { if ( $node instanceof \DOMElement ) { return $node; } } return null; } /** * Replace a node in the DOM tree. * * @param \DOMNode $old_node Node to replace. * @param \DOMNode|null $new_node Replacement node. * * @return void */ private function replace_node( \DOMNode $old_node, ?\DOMNode $new_node ): void { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name. $parent = $old_node->parentNode; if ( ! $parent instanceof \DOMNode ) { return; } if ( $new_node instanceof \DOMNode ) { $parent->replaceChild( $new_node, $old_node ); return; } $parent->removeChild( $old_node ); } /** * Get the inner HTML for an element. * * @param \DOMElement $element Element. * * @return string */ private function get_inner_html( \DOMElement $element ): string { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name. $document = $element->ownerDocument; if ( ! $document instanceof \DOMDocument ) { return ''; } $html = ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name. foreach ( $element->childNodes as $child ) { $html .= $document->saveHTML( $child ); } return $html; } }