, and comes back whole; a translation that lost, doubled or renamed one is refused.
* - keep() and unkeep(): parts to keep, such as "%1$s" or a word that is never translated, go in
* ; a translation that changed one is refused.
* - plainToHtml() and htmlToPlain(): plain text as HTML and back.
*/
final class Markup {
/**
* Replaces every tag that has attributes with a short numbered one, such as for
* . Closing tags and tags without
* attributes stay as they are.
*
* @param string $html Formatted text.
* @return array{0: string, 1: string[]} The text to send, and the tags taken out, by number.
*/
public static function protect( string $html ): array {
$tags = array();
$text = preg_replace_callback(
'~<([a-zA-Z][a-zA-Z0-9-]*)(\s[^<>]*?)?(\s*/)?>~',
static function ( array $found ) use ( &$tags ): string {
if ( ! isset( $found[2] ) || '' === trim( $found[2] ) ) {
return $found[0];
}
$tags[] = $found[0];
return '<' . $found[1] . ' i="' . ( count( $tags ) - 1 ) . '"' . ( ! empty( $found[3] ) ? ' /' : '' ) . '>';
},
$html
);
return array( (string) $text, $tags );
}
/**
* Puts every part a text must keep, such as a placeholder or a word that is never translated,
* inside , which Google and DeepL leave as it is. An empty tag such as
* in its place does not work: Google drops it, measured on 2026-10-10. Parts inside a tag are left
* alone.
*
* @param string $html Text to send, as HTML, its tags already numbered by protect().
* @param string[] $keep Parts to keep, such as "%1$s" or "Lavanda".
* @return array{0: string, 1: string[]} The text with the parts in spans, and the parts.
*/
public static function keep( string $html, array $keep ): array {
$keep = array_values( array_filter( $keep, 'strlen' ) );
if ( ! $keep ) {
return array( $html, array() );
}
usort(
$keep,
static function ( $a, $b ) {
return strlen( $b ) - strlen( $a );
}
);
$parts = array();
$html = preg_replace_callback(
'~(<[^<>]*>)|(' . implode(
'|',
array_map(
static function ( string $part ): string {
return preg_quote( $part, '~' );
},
$keep
)
) . ')~u',
static function ( array $found ) use ( &$parts ): string {
if ( '' !== $found[1] ) {
return $found[1];
}
$parts[] = $found[2];
return '' . $found[2] . '';
},
$html
);
return array( (string) $html, $parts );
}
/**
* Takes the parts keep() put in spans out of their spans again.
*
* @param string $html Translation, with its tags restored.
* @param string[] $parts Parts kept.
* @return string|null The translation, or null when a part is missing or changed.
*/
public static function unkeep( string $html, array $parts ): ?string {
if ( ! $parts ) {
return $html;
}
$found = array();
$html = preg_replace_callback(
'~(.*?)~s',
static function ( array $span ) use ( &$found ): string {
$found[] = $span[1];
return $span[1];
},
$html
);
sort( $found );
sort( $parts );
return $found === $parts ? (string) $html : null;
}
/**
* Puts the tags protect() took out back into a translation.
*
* @param string $text Translation with numbered tags.
* @param string[] $tags Tags taken out, by number.
* @return string|null The translation, or null when a numbered tag went missing, came twice or
* changed its name, so the formatting cannot be trusted.
*/
public static function restore( string $text, array $tags ): ?string {
$used = array();
$broken = false;
$text = preg_replace_callback(
'~<([a-zA-Z][a-zA-Z0-9-]*)\s+i\s*=\s*"?(\d+)"?\s*/?\s*>~',
static function ( array $found ) use ( $tags, &$used, &$broken ): string {
$number = (int) $found[2];
$name = isset( $tags[ $number ] ) && preg_match( '~^<([a-zA-Z][a-zA-Z0-9-]*)~', $tags[ $number ], $original ) ? strtolower( $original[1] ) : '';
if ( '' === $name || isset( $used[ $number ] ) || strtolower( $found[1] ) !== $name ) {
$broken = true;
return $found[0];
}
$used[ $number ] = true;
return $tags[ $number ];
},
$text
);
return $broken || count( $used ) !== count( $tags ) ? null : (string) $text;
}
/**
* Returns plain text as HTML: special characters as entities, line breaks as
. Quotes stay
* as they are, since a service may charge the characters of every entity.
*
* @param string $text Plain text.
*/
public static function plainToHtml( string $text ): string {
return str_replace( array( "\r\n", "\r", "\n" ), '
', htmlspecialchars( $text, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8' ) );
}
/**
* Returns a translation of plain text, which a service sends back as HTML, as plain text again.
*
* @param string $html Translation.
*/
public static function htmlToPlain( string $html ): string {
$text = (string) preg_replace( '~[ \t]*
[ \t]*~i', "\n", $html );
return html_entity_decode( wp_strip_all_tags( $text, false ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
}
}