*/
public static function top_level( string $html, array $places ): array {
if ( ! $places ) return [];
$depths = self::depths( $html, array_map( fn( $p ) => (int) $p[1], $places ) );
$known = array_filter( $depths, fn( $d ) => $d < self::UNKNOWN );
if ( ! $known ) return [];
$min = min( $known );
if ( $min > 0 && self::outside_text_len( $html ) > 40 ) return [];
$keep = [];
foreach ( $places as $i => $p ) if ( $depths[ $i ] === $min ) $keep[] = $p;
return $keep;
}
/** The shallowest depth the article's text sits at (0 unless everything is wrapped). */
public static function flow_depth( string $html ): int {
if ( self::outside_text_len( $html ) > 40 ) return 0;
preg_match_all( '/]*>/i', $html, $m, PREG_OFFSET_CAPTURE );
$known = array_filter( self::depths( $html, array_map( fn( $x ) => $x[1], $m[0] ) ), fn( $d ) => $d < self::UNKNOWN );
return $known ? min( $known ) : 0;
}
/**
* Byte ranges where nothing may be linked or inserted: scripts, styles,
* code, preformatted text, form controls, buttons and HTML comments
* (block comments carry JSON attributes).
* @return array
*/
public static function no_go_ranges( string $html ): array {
$out = [];
foreach ( [ '/<(script|style|code|pre|textarea|button|select|svg)\b[^>]*>.*?<\/\1>/is', '//s' ] as $rx ) {
if ( preg_match_all( $rx, $html, $m, PREG_OFFSET_CAPTURE ) ) {
foreach ( $m[0] as $r ) $out[] = [ $r[1], $r[1] + strlen( $r[0] ) ];
}
}
return $out;
}
/* ------------------------------------------------------------------
Internals
------------------------------------------------------------------ */
/** Offsets between < and > of a tag (not a comment) are never a place. */
private static function inside_tag( string $html, int $off ): bool {
$before = substr( $html, 0, $off );
$lo = strrpos( $before, '<' );
$lc = strrpos( $before, '>' );
return $lo !== false && ( $lc === false || $lo > $lc );
}
/** @return array|null null: parser missing or it gave up. */
private static function parser_depths( string $html, array $offsets ): ?array {
if ( ! self::$use_parser || ! class_exists( '\WP_HTML_Processor' ) || ! method_exists( '\WP_HTML_Processor', 'next_token' ) || ! method_exists( '\WP_HTML_Processor', 'get_breadcrumbs' ) ) {
return null;
}
// A copy with a probe at every offset, inserted from the back so offsets stay valid.
$order = $offsets;
arsort( $order );
$probed = $html;
foreach ( $order as $k => $off ) {
$probed = substr( $probed, 0, $off ) . '' . substr( $probed, $off );
}
try {
$p = \WP_HTML_Processor::create_fragment( $probed );
if ( ! $p ) return null;
$box = array_flip( self::BOX_TAGS );
$found = [];
while ( $p->next_token() ) {
if ( $p->get_token_type() !== '#comment' ) continue;
$t = $p->get_modifiable_text();
if ( ! str_starts_with( $t, self::PROBE ) ) continue;
$n = 0;
foreach ( $p->get_breadcrumbs() as $tag ) if ( isset( $box[ $tag ] ) ) $n++;
$found[ substr( $t, strlen( self::PROBE ) ) ] = $n;
}
if ( $p->get_last_error() !== null ) return null; // unsupported markup: use the fallback
} catch ( \Throwable $e ) {
return null;
}
$out = [];
foreach ( $offsets as $k => $off ) $out[ $k ] = $found[ (string) $k ] ?? self::UNKNOWN;
return $out;
}
/** Tag counting (no parser): opening minus closing box tags before each offset. */
private static function regex_depths( string $html, array $offsets ): array {
$names = implode( '|', array_map( 'strtolower', self::BOX_TAGS ) );
preg_match_all( '#<(/?)(' . $names . ')\b[^>]*?(/?)>#i', $html, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER );
$out = [];
foreach ( $offsets as $k => $off ) {
$d = 0;
foreach ( $tags as $t ) {
if ( $t[0][1] >= $off ) break;
if ( $t[3][0] === '/' ) continue;
$d = max( 0, $d + ( $t[1][0] === '/' ? -1 : 1 ) );
}
$out[ $k ] = $d;
}
return $out;
}
/**
* Inner byte ranges [after the opening tag, before the closing tag) of
* every enclosing shortcode, nested ones included.
* @return array
*/
public static function shortcode_ranges( string $html ): array {
if ( ! preg_match_all( '/\[(\/)?([a-zA-Z][\w-]*)(?:\s[^\]]*)?(\/)?\]/', $html, $m, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ) ) return [];
$out = [];
$stack = [];
foreach ( $m as $t ) {
$name = strtolower( $t[2][0] );
$close = $t[1][0] === '/';
if ( ! $close ) {
if ( isset( $t[3] ) && $t[3][0] === '/' ) continue; // [x /]
if ( stripos( $html, '[/' . $name . ']', $t[0][1] ) === false ) continue; // self-closing
$stack[] = [ $name, $t[0][1] + strlen( $t[0][0] ) ];
continue;
}
for ( $i = count( $stack ) - 1; $i >= 0; $i-- ) {
if ( $stack[ $i ][0] !== $name ) continue;
$out[] = [ $stack[ $i ][1], $t[0][1] ];
array_splice( $stack, $i );
break;
}
}
return $out;
}
/** Characters of article text outside every box and enclosing shortcode. */
private static function outside_text_len( string $html ): int {
// Remove enclosing shortcodes (outermost first: longest ranges cover the nested ones).
$sc = self::shortcode_ranges( $html );
usort( $sc, fn( $a, $b ) => $b[0] <=> $a[0] );
$text = $html;
foreach ( $sc as [ $s, $e ] ) {
if ( $e <= strlen( $text ) ) $text = substr( $text, 0, $s ) . str_repeat( ' ', $e - $s ) . substr( $text, $e );
}
// Then everything inside box elements (tag counting is enough for a length estimate).
$names = implode( '|', array_map( 'strtolower', self::BOX_TAGS ) );
preg_match_all( '#<(/?)(' . $names . ')\b[^>]*?(/?)>#i', $text, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER );
$outside = '';
$d = 0; $last = 0;
foreach ( $tags as $t ) {
if ( $d === 0 ) $outside .= substr( $text, $last, $t[0][1] - $last );
if ( $t[3][0] !== '/' ) $d = max( 0, $d + ( $t[1][0] === '/' ? -1 : 1 ) );
$last = $t[0][1] + strlen( $t[0][0] );
}
if ( $d === 0 ) $outside .= substr( $text, $last );
$outside = preg_replace( '/|\[[^\]]*\]/s', ' ', $outside );
$outside = trim( (string) preg_replace( '/\s+/u', ' ', wp_strip_all_tags( (string) $outside ) ) );
return function_exists( 'mb_strlen' ) ? mb_strlen( $outside, 'UTF-8' ) : strlen( $outside );
}
}