link text, for void tags.
* Original tags (with their attributes/URLs) never reach translators or machine services.
*
* @package Soft_ML
*/
defined( 'ABSPATH' ) || exit;
class Soft_ML_Segmenter {
/** Inline elements: stay inside the surrounding segment. */
const INLINE = array(
'a', 'abbr', 'acronym', 'b', 'bdi', 'bdo', 'big', 'br', 'cite', 'data', 'del', 'dfn', 'em', 'font',
'i', 'img', 'ins', 'kbd', 'label', 'mark', 'q', 's', 'small', 'span', 'strike', 'strong', 'sub',
'sup', 'time', 'u', 'wbr', 'input',
);
/** Elements whose entire content is never translated. */
const SKIP = array( 'head', 'title', 'script', 'style', 'svg', 'textarea', 'pre', 'code', 'noscript', 'iframe', 'object', 'template', 'math', 'select' );
const VOID = array( 'br', 'wbr', 'img', 'input', 'hr', 'meta', 'link', 'source', 'area', 'base', 'col', 'embed', 'param', 'track' );
const ATTRS = array( 'alt', 'title', 'placeholder', 'aria-label' );
const TOKEN_RE = '~(|||||]*>|?[a-zA-Z][a-zA-Z0-9:_-]*(?:"[^"]*"|\'[^\']*\'|[^>"\'])*+>)~is';
/**
* Extract unique segments.
*
* @param string $html Rendered HTML.
* @return array hash => [ 'hash', 'text', 'kind' (text|attr), 'tag', 'count' ]
*/
public static function extract( $html ) {
$found = array();
self::process(
$html,
static function ( $kind, $text, $tag ) use ( &$found ) {
$hash = self::hash( $text );
if ( isset( $found[ $hash ] ) ) {
++$found[ $hash ]['count'];
} else {
$found[ $hash ] = array( 'hash' => $hash, 'text' => $text, 'kind' => $kind, 'tag' => $tag, 'count' => 1 );
}
return null;
}
);
return $found;
}
/**
* Apply translations. Segments without a (valid) translation are left untouched.
*
* @param string $html Rendered HTML.
* @param array $translations hash => translated text (same placeholder format).
* @param array $stats Out: [ 'total' => n, 'translated' => n, 'rejected' => n ].
* @return string
*/
public static function apply( $html, array $translations, &$stats = null ) {
$stats = array( 'total' => 0, 'translated' => 0, 'rejected' => 0 );
return self::process(
$html,
static function ( $kind, $text, $tag ) use ( $translations, &$stats ) {
++$stats['total'];
$hash = self::hash( $text );
if ( ! isset( $translations[ $hash ] ) || '' === trim( $translations[ $hash ] ) ) {
return null;
}
$t = $translations[ $hash ];
if ( 'attr' === $kind ) {
if ( substr_count( $t, '<' ) > substr_count( $text, '<' ) || substr_count( $t, '>' ) > substr_count( $text, '>' ) ) {
++$stats['rejected'];
return null;
}
} elseif ( ! self::placeholders_match( $text, $t ) ) {
++$stats['rejected'];
return null;
}
++$stats['translated'];
return $t;
}
);
}
/**
* Stable key for a source text.
*
* @param string $text Normalised source text.
* @return string
*/
public static function hash( $text ) {
return sha1( $text );
}
/**
* Does $translation carry exactly the placeholders of $source and no other markup?
*
* @param string $source Source text.
* @param string $translation Translation.
* @return bool
*/
public static function placeholders_match( $source, $translation ) {
$re = '~?x\d+/?>~';
preg_match_all( $re, $source, $a );
preg_match_all( $re, $translation, $b );
$a = $a[0];
$b = $b[0];
sort( $a );
sort( $b );
if ( $a !== $b ) {
return false;
}
// Stray angle brackets exist in some legacy source text; allow no more than the source had.
$s = preg_replace( $re, '', $source );
$t = preg_replace( $re, '', $translation );
return substr_count( $t, '<' ) <= substr_count( $s, '<' ) && substr_count( $t, '>' ) <= substr_count( $s, '>' );
}
/**
* Core walker. $cb( kind, text, blockTag ) returns a replacement string or null.
*
* @param string $html Input.
* @param callable $cb Callback.
* @return string
*/
public static function process( $html, callable $cb ) {
$tokens = preg_split( self::TOKEN_RE, $html, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );
if ( false === $tokens ) {
return $html; // PCRE limit hit: fail safe, untranslated.
}
$out = array();
$run = array();
$skip_name = '';
$skip_n = 0;
$block_tag = 'body';
$n = count( $tokens );
for ( $i = 0; $i < $n; $i++ ) {
$tok = $tokens[ $i ];
if ( '<' !== $tok[0] || ( 1 !== preg_match( '~^<(?:!|/?[a-zA-Z])~', $tok ) ) ) {
if ( $skip_n ) {
$out[] = $tok;
} else {
$run[] = array( 't', $tok );
}
continue;
}
// Comments, doctype, CDATA, raw script/style/svg blocks.
if ( '' === substr( $tok, -2 );
if ( $skip_n ) {
if ( $name === $skip_name && ! $void ) {
$skip_n += $closing ? -1 : 1;
}
$out[] = $tok;
continue;
}
if ( ! $closing && ! $void && ( in_array( $name, self::SKIP, true ) || self::is_no_translate( $tok ) ) ) {
self::flush( $run, $out, $cb, $block_tag );
$skip_name = $name;
$skip_n = 1;
$out[] = $tok;
continue;
}
if ( ! $closing ) {
$tok = self::translate_attrs( $tok, $name, $cb, $block_tag );
}
if ( in_array( $name, self::INLINE, true ) ) {
$run[] = array( 'g', $tok, $name, $closing, $void );
} else {
self::flush( $run, $out, $cb, $block_tag );
$out[] = $tok;
if ( ! $closing ) {
$block_tag = $name;
}
}
}
self::flush( $run, $out, $cb, $block_tag );
return implode( '', $out );
}
/**
* Element opted out of translation: translate="no" (HTML standard) or class "notranslate".
*
* @param string $tag Opening tag.
* @return bool
*/
private static function is_no_translate( $tag ) {
return 1 === preg_match( '~\stranslate\s*=\s*["\']no["\']~i', $tag )
|| 1 === preg_match( '~\sclass\s*=\s*["\'][^"\']*\bnotranslate\b~i', $tag );
}
/**
* Translate alt/title/placeholder/aria-label (and button-like input values) inside one tag.
*/
private static function translate_attrs( $tag, $name, callable $cb, $block_tag ) {
if ( false === strpos( $tag, '=' ) ) {
return $tag;
}
$attrs = self::ATTRS;
if ( 'input' === $name && 1 === preg_match( '~\stype\s*=\s*["\']?(submit|button|reset)\b~i', $tag ) ) {
$attrs[] = 'value';
}
$re = '~(\s(?:' . implode( '|', array_map( 'preg_quote', $attrs ) ) . ')\s*=\s*)("([^"]*)"|\'([^\']*)\')~i';
return preg_replace_callback(
$re,
static function ( $m ) use ( $cb, $block_tag ) {
$quote = $m[2][0];
$value = isset( $m[4] ) ? $m[4] : $m[3];
$norm = trim( preg_replace( '/\s+/u', ' ', $value ) );
if ( '' === $norm || ! self::has_letters( $norm ) ) {
return $m[0];
}
$res = $cb( 'attr', $norm, $block_tag );
if ( null === $res ) {
return $m[0];
}
$res = str_replace( $quote, '"' === $quote ? '"' : ''', $res );
return $m[1] . $quote . $res . $quote;
},
$tag
);
}
/**
* Turn the pending inline run into a segment (or pass it through) and append to $out.
*
* @param array $run Pending tokens (by reference, emptied).
* @param array $out Output pieces (by reference).
* @param callable $cb Callback.
* @param string $block_tag Enclosing block tag (context).
*/
private static function flush( array &$run, array &$out, callable $cb, $block_tag ) {
if ( ! $run ) {
return;
}
$tokens = $run;
$run = array();
$has_text = false;
foreach ( $tokens as $t ) {
if ( 't' === $t[0] && '' !== trim( $t[1] ) ) {
$has_text = true;
break;
}
}
if ( ! $has_text ) {
foreach ( $tokens as $t ) {
$out[] = $t[1];
}
return;
}
$lead = 0;
$trail = count( $tokens );
$prefix = '';
$suffix = '';
// Peel edge whitespace, void tags and comments, then matched wrapper tags (.. around everything).
$peel = static function () use ( &$tokens, &$lead, &$trail, &$prefix, &$suffix ) {
while ( $lead < $trail && self::is_edge_filler( $tokens[ $lead ] ) ) {
$prefix .= $tokens[ $lead ][1];
++$lead;
}
while ( $trail > $lead && self::is_edge_filler( $tokens[ $trail - 1 ] ) ) {
$suffix = $tokens[ $trail - 1 ][1] . $suffix;
--$trail;
}
};
$peel();
while ( $trail - $lead >= 2 ) {
$first = $tokens[ $lead ];
$last = $tokens[ $trail - 1 ];
if ( 'g' === $first[0] && 'g' === $last[0] && ! $first[3] && ! $first[4] && $last[3] && $first[2] === $last[2]
&& self::wraps_all( $tokens, $lead, $trail ) ) {
$prefix .= $first[1];
$suffix = $last[1] . $suffix;
++$lead;
--$trail;
$peel();
continue;
}
break;
}
// Build source text with numbered placeholders.
$src = '';
$map = array(); // placeholder string => original tag string
$ids = 0;
$stack = array();
for ( $k = $lead; $k < $trail; $k++ ) {
$t = $tokens[ $k ];
if ( 't' === $t[0] ) {
$src .= $t[1];
} elseif ( 'r' === $t[0] ) {
// Comments inside a segment would be lost on replacement: keep them as void placeholders.
++$ids;
$map[ "" ] = $t[1];
$src .= "";
} elseif ( $t[4] ) {
++$ids;
$map[ "" ] = $t[1];
$src .= "";
} elseif ( ! $t[3] ) {
++$ids;
$stack[] = array( $t[2], $ids );
$map[ "" ] = $t[1];
$src .= "";
} else {
$id = 0;
for ( $s = count( $stack ) - 1; $s >= 0; $s-- ) {
if ( $stack[ $s ][0] === $t[2] ) {
$id = $stack[ $s ][1];
array_splice( $stack, $s, 1 );
break;
}
}
if ( ! $id ) {
++$ids;
$id = $ids;
$map[ "" ] = '';
}
$map[ "" ] = $t[1];
$src .= "";
}
}
$norm = trim( preg_replace( '/\s+/u', ' ', $src ) );
$text = trim( preg_replace( '~?x\d+/?>~', '', $norm ) );
if ( '' === $text || ! self::has_letters( $text ) ) {
foreach ( $tokens as $t ) {
$out[] = $t[1];
}
return;
}
$res = $cb( 'text', $norm, $block_tag );
if ( null === $res ) {
foreach ( $tokens as $t ) {
$out[] = $t[1];
}
return;
}
$out[] = $prefix . strtr( $res, $map ) . $suffix;
}
/**
* @param array $t Token.
* @return bool Whitespace text, comment, or void inline tag.
*/
private static function is_edge_filler( array $t ) {
if ( 't' === $t[0] ) {
return '' === trim( $t[1] );
}
if ( 'r' === $t[0] ) {
return true;
}
return 'g' === $t[0] && $t[4];
}
/**
* Does the opening tag at $lead close exactly at $trail-1?
*/
private static function wraps_all( array $tokens, $lead, $trail ) {
$name = $tokens[ $lead ][2];
$depth = 0;
for ( $k = $lead; $k < $trail; $k++ ) {
$t = $tokens[ $k ];
if ( 'g' !== $t[0] || $t[2] !== $name || $t[4] ) {
continue;
}
$depth += $t[3] ? -1 : 1;
if ( 0 === $depth ) {
return $k === $trail - 1;
}
}
return false;
}
/**
* Letters present once entities are removed?
*
* @param string $s Text.
* @return bool
*/
private static function has_letters( $s ) {
return 1 === preg_match( '/\p{L}/u', preg_replace( '/?\w+;/', '', $s ) );
}
}