dom = new DOMDocument(); libxml_use_internal_errors( true ); $encoded = mb_encode_numericentity( $html_content, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8' ); @$this->dom->loadHTML( "
or
where both the // outer blockquote and inner p match the search text. $child_blocks = $xpath->query( './/p | .//li', $p ); foreach ( $child_blocks as $child ) { $child_text = $this->normalize_text_for_search( $child->textContent ); if ( mb_strpos( $child_text, $search_prefix ) !== false ) { return $child; } } return $p; } similar_text( mb_substr( $p_text, 0, 200 ), mb_substr( $search_normalized, 0, 200 ), $percent ); if ( $percent > $highest_percent ) { $highest_percent = $percent; $best_match = $p; } } if ( $best_match && $highest_percent > 75 ) { // Same deepest-node preference for fuzzy matches. $child_blocks = $xpath->query( './/p | .//li', $best_match ); foreach ( $child_blocks as $child ) { $child_text = $this->normalize_text_for_search( $child->textContent ); $child_percent = 0; similar_text( mb_substr( $child_text, 0, 200 ), mb_substr( $search_normalized, 0, 200 ), $child_percent ); if ( $child_percent >= $highest_percent ) { return $child; } } return $best_match; } return null; } /** * Check if a node path is an ancestor of another node path. * E.g. "/div/blockquote" is an ancestor of "/div/blockquote/p". * * @param string $ancestor_path * @param string $descendant_path * @return bool */ private function is_path_ancestor( $ancestor_path, $descendant_path ) { if ( $ancestor_path === '' || $descendant_path === '' ) { return false; } // Ancestor if descendant path starts with ancestor path + "/" return strpos( $descendant_path, $ancestor_path . '/' ) === 0; } /** * Get the virtual_text start offset of a given paragraph node. * * Handles nested block structures (e.g.text
) * by falling back to descendant nodes when an exact path match fails. * * @param DOMElement $paragraph_node * @return int|null */ public function get_paragraph_virtual_start( $paragraph_node ) { $para_path = $paragraph_node->getNodePath(); // 1. Try exact path match first (normal case). foreach ( $this->node_map as $map_item ) { $node_path = $this->get_block_path( $map_item['node'] ); if ( $node_path === $para_path ) { return $map_item['start']; } } // 2. Fallback: the paragraph node may be an ancestor of mapped nodes // (e.g. find_paragraph_node returnedtext
but map has// paths inside it). Return the start of the first descendant. foreach ( $this->node_map as $map_item ) { $node_path = $this->get_block_path( $map_item['node'] ); if ( $this->is_path_ancestor( $para_path, $node_path ) ) { return $map_item['start']; } } return null; } /** * Reconstruct the virtual_text fragment for a given paragraph node. * * Handles nested block structures by including text from descendant * nodes when an exact path match fails. * * @param DOMElement $paragraph_node * @return string */ public function get_paragraph_virtual_text( $paragraph_node ) { $para_path = $paragraph_node->getNodePath(); $text = ''; // 1. Try exact path match first (normal case). $has_exact = false; foreach ( $this->node_map as $map_item ) { $node_path = $this->get_block_path( $map_item['node'] ); if ( $node_path === $para_path ) { $text .= $map_item['text']; $has_exact = true; } } // 2. If exact match found, return it. if ( $has_exact ) { return $text; } // 3. Fallback: include text from all descendant nodes. foreach ( $this->node_map as $map_item ) { $node_path = $this->get_block_path( $map_item['node'] ); if ( $this->is_path_ancestor( $para_path, $node_path ) ) { $text .= $map_item['text']; } } return $text; } /** * Normalize text for paragraph search. * * @param string $text * @return string */ private function normalize_text_for_search( $text ) { $text = preg_replace( '/<\/(p|li|ul|ol|blockquote|h[1-6])>/i', '$0 ', $text ); $text = wp_strip_all_tags( $text ); $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = preg_replace( '/\s+/u', ' ', $text ); $text = preg_replace( '/[^\p{L}\p{N}\s]/u', '', $text ); return trim( mb_strtolower( $text, 'UTF-8' ) ); } }