*/ private array $links = array(); /** * wp-image-{id} attachment ids in encounter order. * * @var int[] */ private array $image_ids = array(); /** * Text runs accumulating toward the current paragraph block. * * @var string[] */ private array $pending = array(); /** * Normalize an HTML fragment. * * @param string $html Rendered HTML. * @return array{blocks: NormalizedBlock[], links: array, image_ids: int[]} */ public function normalize( string $html ): array { $this->blocks = array(); $this->links = array(); $this->image_ids = array(); $this->pending = array(); $html = trim( $html ); if ( '' === $html ) { return array( 'blocks' => array(), 'links' => array(), 'image_ids' => array(), ); } $doc = new DOMDocument(); $previous = libxml_use_internal_errors( true ); $doc->loadHTML( '' . $html . '', LIBXML_NOWARNING | LIBXML_NOERROR ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); $body = $doc->getElementsByTagName( 'body' )->item( 0 ); if ( $body ) { $this->walk( $body ); } $this->flush_pending(); return array( 'blocks' => $this->blocks, 'links' => $this->links, 'image_ids' => array_values( array_unique( $this->image_ids ) ), ); } /** * Depth-first walk emitting blocks. * * @param DOMNode $node Current node. */ private function walk( DOMNode $node ): void { foreach ( iterator_to_array( $node->childNodes ) as $child ) { if ( $child instanceof DOMText ) { $text = $this->clean( $child->wholeText ); if ( '' !== $text ) { $this->pending[] = $text; } continue; } if ( ! $child instanceof DOMElement ) { continue; } $tag = strtolower( $child->tagName ); if ( in_array( $tag, self::SKIP_TAGS, true ) ) { continue; } switch ( $tag ) { case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': $this->flush_pending(); $text = $this->clean( $child->textContent ); if ( '' !== $text ) { $this->blocks[] = new NormalizedBlock( NormalizedBlock::KIND_HEADING, $text, (int) substr( $tag, 1 ) ); } $this->harvest_inline( $child ); break; case 'ul': case 'ol': $this->flush_pending(); $items = array(); foreach ( $child->getElementsByTagName( 'li' ) as $li ) { $text = $this->clean( $li->textContent ); if ( '' !== $text ) { $items[] = '• ' . $text; } } if ( $items ) { $this->blocks[] = new NormalizedBlock( NormalizedBlock::KIND_LIST, implode( "\n", $items ) ); } $this->harvest_inline( $child ); break; case 'table': $this->flush_pending(); $rows = $this->linearize_table( $child ); if ( '' !== $rows ) { $this->blocks[] = new NormalizedBlock( NormalizedBlock::KIND_TABLE, $rows ); } $this->harvest_inline( $child ); break; case 'p': case 'blockquote': case 'pre': case 'figcaption': $text = $this->clean( $child->textContent ); if ( '' !== $text ) { $this->flush_pending(); $this->blocks[] = new NormalizedBlock( NormalizedBlock::KIND_PARAGRAPH, $text ); } $this->harvest_inline( $child ); break; case 'br': $this->flush_pending(); break; case 'img': $this->harvest_image( $child ); break; case 'a': $this->harvest_link( $child ); $text = $this->clean( $child->textContent ); if ( '' !== $text ) { $this->pending[] = $text; } break; default: // Containers (div/section/article/span/…): recurse. $this->walk( $child ); break; } } $this->flush_pending(); } /** * Collect links/images inside an already-consumed block element. * * @param DOMElement $element Element. */ private function harvest_inline( DOMElement $element ): void { foreach ( $element->getElementsByTagName( 'a' ) as $a ) { $this->harvest_link( $a ); } foreach ( $element->getElementsByTagName( 'img' ) as $img ) { $this->harvest_image( $img ); } } /** * Record an anchor. * * @param DOMElement $a Anchor element. */ private function harvest_link( DOMElement $a ): void { $href = trim( $a->getAttribute( 'href' ) ); if ( '' === $href || str_starts_with( $href, '#' ) || str_starts_with( $href, 'javascript:' ) || str_starts_with( $href, 'mailto:' ) || str_starts_with( $href, 'tel:' ) ) { return; } // Resolve relative URLs against the site. if ( str_starts_with( $href, '//' ) ) { $href = ( is_ssl() ? 'https:' : 'http:' ) . $href; } elseif ( str_starts_with( $href, '/' ) ) { $href = untrailingslashit( home_url() ) . $href; } $this->links[] = array( 'url' => $href, 'anchor' => mb_substr( $this->clean( $a->textContent ), 0, 255 ), ); } /** * Record a media-library image id (class="wp-image-{id}"). * * @param DOMElement $img Image element. */ private function harvest_image( DOMElement $img ): void { if ( preg_match( '/wp-image-(\d+)/', $img->getAttribute( 'class' ), $m ) ) { $this->image_ids[] = (int) $m[1]; } } /** * Linearize a table into "Header: value; …" lines. * * @param DOMElement $table Table element. */ private function linearize_table( DOMElement $table ): string { $headers = array(); $lines = array(); foreach ( $table->getElementsByTagName( 'tr' ) as $tr ) { $cells = array(); $is_headers = true; foreach ( $tr->childNodes as $cell ) { if ( $cell instanceof DOMElement && in_array( strtolower( $cell->tagName ), array( 'td', 'th' ), true ) ) { $cells[] = $this->clean( $cell->textContent ); $is_headers = $is_headers && 'th' === strtolower( $cell->tagName ); } } if ( ! $cells ) { continue; } if ( $is_headers && empty( $headers ) ) { $headers = $cells; continue; } if ( $headers ) { $pairs = array(); foreach ( $cells as $i => $value ) { $label = $headers[ $i ] ?? (string) ( $i + 1 ); $pairs[] = $label . ': ' . $value; } $lines[] = implode( '; ', $pairs ); } else { $lines[] = implode( '; ', $cells ); } } return implode( "\n", $lines ); } /** * Entity decode + whitespace collapse. * * @param string $text Raw text. */ private function clean( string $text ): string { $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = (string) preg_replace( '/[\x{00A0}\s]+/u', ' ', $text ); return trim( $text ); } /** * Emit accumulated inline text runs as one paragraph block. */ private function flush_pending(): void { if ( ! $this->pending ) { return; } $text = trim( implode( ' ', $this->pending ) ); $this->pending = array(); if ( '' !== $text && mb_strlen( $text ) > 1 ) { $this->blocks[] = new NormalizedBlock( NormalizedBlock::KIND_PARAGRAPH, $text ); } } }