258 lines
8.3 KiB
PHP
258 lines
8.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Esun\MultiLocaleTranslation\Model\Html;
|
|
|
|
class TranslatableTextExtractor
|
|
{
|
|
private const IGNORED_TAGS = ['code', 'noscript', 'script', 'style', 'svg', 'template'];
|
|
|
|
/**
|
|
* @return array<int, array{offset:int,length:int,text:string}>
|
|
*/
|
|
public function extract(string $html, int $maxFragmentBytes): array
|
|
{
|
|
$ranges = $this->findTextRanges($html);
|
|
$fragments = [];
|
|
|
|
foreach ($ranges as [$rangeOffset, $rangeLength]) {
|
|
foreach ($this->findEncodedTextRanges($html, $rangeOffset, $rangeLength) as [$offset, $length]) {
|
|
$value = substr($html, $offset, $length);
|
|
if ($value === '' || preg_match('/\S/u', $value) !== 1) {
|
|
continue;
|
|
}
|
|
|
|
preg_match('/\A(\s*)/u', $value, $leadingMatch);
|
|
preg_match('/(\s*)\z/u', $value, $trailingMatch);
|
|
$leadingBytes = strlen($leadingMatch[1] ?? '');
|
|
$trailingBytes = strlen($trailingMatch[1] ?? '');
|
|
$textLength = strlen($value) - $leadingBytes - $trailingBytes;
|
|
if ($textLength <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$textOffset = $offset + $leadingBytes;
|
|
$text = substr($html, $textOffset, $textLength);
|
|
foreach ($this->splitText($text, $maxFragmentBytes) as [$partOffset, $part]) {
|
|
$fragments[] = [
|
|
'offset' => $textOffset + $partOffset,
|
|
'length' => strlen($part),
|
|
'text' => $part,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $fragments;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{0:int,1:int}>
|
|
*/
|
|
private function findTextRanges(string $html): array
|
|
{
|
|
$ranges = [];
|
|
$length = strlen($html);
|
|
$cursor = 0;
|
|
$textStart = 0;
|
|
$ignoredTag = null;
|
|
|
|
while ($cursor < $length) {
|
|
$tagStart = strpos($html, '<', $cursor);
|
|
if ($tagStart === false) {
|
|
break;
|
|
}
|
|
|
|
$tagEnd = $this->findTagEnd($html, $tagStart);
|
|
if ($tagEnd === null) {
|
|
$cursor = $tagStart + 1;
|
|
continue;
|
|
}
|
|
|
|
$tag = substr($html, $tagStart, $tagEnd - $tagStart);
|
|
if ($ignoredTag === null && $tagStart > $textStart) {
|
|
$ranges[] = [$textStart, $tagStart - $textStart];
|
|
}
|
|
|
|
if (preg_match('/^<\s*(\/?)\s*([a-z][a-z0-9:-]*)/i', $tag, $match) === 1) {
|
|
$isClosing = $match[1] === '/';
|
|
$tagName = strtolower($match[2]);
|
|
if ($ignoredTag === null && !$isClosing && in_array($tagName, self::IGNORED_TAGS, true)
|
|
&& !str_ends_with(rtrim($tag), '/>')
|
|
) {
|
|
$ignoredTag = $tagName;
|
|
} elseif ($ignoredTag === $tagName && $isClosing) {
|
|
$ignoredTag = null;
|
|
}
|
|
}
|
|
|
|
$cursor = $tagEnd;
|
|
$textStart = $tagEnd;
|
|
}
|
|
|
|
if ($ignoredTag === null && $textStart < $length) {
|
|
$ranges[] = [$textStart, $length - $textStart];
|
|
}
|
|
|
|
return $ranges;
|
|
}
|
|
|
|
private function findTagEnd(string $html, int $start): ?int
|
|
{
|
|
if (substr($html, $start, 4) === '<!--') {
|
|
$end = strpos($html, '-->', $start + 4);
|
|
return $end === false ? null : $end + 3;
|
|
}
|
|
if (substr($html, $start, 9) === '<![CDATA[') {
|
|
$end = strpos($html, ']]>', $start + 9);
|
|
return $end === false ? null : $end + 3;
|
|
}
|
|
if (preg_match('/\A<\s*(?:\/?\s*[a-z]|!doctype\b|\?)/i', substr($html, $start, 32)) !== 1) {
|
|
return null;
|
|
}
|
|
|
|
$length = strlen($html);
|
|
$quote = null;
|
|
for ($index = $start + 1; $index < $length; $index++) {
|
|
$character = $html[$index];
|
|
if ($quote !== null) {
|
|
if ($character === $quote) {
|
|
$quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
if ($character === '"' || $character === "'") {
|
|
$quote = $character;
|
|
} elseif ($character === '>') {
|
|
return $index + 1;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Split a normal HTML text range again when Page Builder stores HTML tags as <...>.
|
|
*
|
|
* @return array<int, array{0:int,1:int}>
|
|
*/
|
|
private function findEncodedTextRanges(string $html, int $offset, int $length): array
|
|
{
|
|
$ranges = [];
|
|
$limit = $offset + $length;
|
|
$cursor = $offset;
|
|
$textStart = $offset;
|
|
$ignoredTag = null;
|
|
$foundTag = false;
|
|
|
|
while ($cursor < $limit) {
|
|
$tagStart = stripos($html, '<', $cursor);
|
|
if ($tagStart === false || $tagStart >= $limit) {
|
|
break;
|
|
}
|
|
|
|
$tagEnd = $this->findEncodedTagEnd($html, $tagStart, $limit);
|
|
if ($tagEnd === null) {
|
|
$cursor = $tagStart + 4;
|
|
continue;
|
|
}
|
|
|
|
$encodedTag = substr($html, $tagStart, $tagEnd - $tagStart);
|
|
$tag = html_entity_decode($encodedTag, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
if (preg_match('/^<\s*(?:\/?\s*[a-z][a-z0-9:-]*|!--|!doctype\b|\?)/i', $tag) !== 1) {
|
|
$cursor = $tagStart + 4;
|
|
continue;
|
|
}
|
|
|
|
$foundTag = true;
|
|
if ($ignoredTag === null && $tagStart > $textStart) {
|
|
$ranges[] = [$textStart, $tagStart - $textStart];
|
|
}
|
|
|
|
if (preg_match('/^<\s*(\/?)\s*([a-z][a-z0-9:-]*)/i', $tag, $match) === 1) {
|
|
$isClosing = $match[1] === '/';
|
|
$tagName = strtolower($match[2]);
|
|
if ($ignoredTag === null && !$isClosing && in_array($tagName, self::IGNORED_TAGS, true)
|
|
&& !str_ends_with(rtrim($tag), '/>')
|
|
) {
|
|
$ignoredTag = $tagName;
|
|
} elseif ($ignoredTag === $tagName && $isClosing) {
|
|
$ignoredTag = null;
|
|
}
|
|
}
|
|
|
|
$cursor = $tagEnd;
|
|
$textStart = $tagEnd;
|
|
}
|
|
|
|
if (!$foundTag) {
|
|
return [[$offset, $length]];
|
|
}
|
|
if ($ignoredTag === null && $textStart < $limit) {
|
|
$ranges[] = [$textStart, $limit - $textStart];
|
|
}
|
|
|
|
return $ranges;
|
|
}
|
|
|
|
private function findEncodedTagEnd(string $html, int $start, int $limit): ?int
|
|
{
|
|
if (strncasecmp(substr($html, $start, 7), '<!--', 7) === 0) {
|
|
$end = stripos($html, '-->', $start + 7);
|
|
return $end === false || $end >= $limit ? null : $end + 6;
|
|
}
|
|
|
|
$quote = null;
|
|
for ($index = $start + 4; $index < $limit; $index++) {
|
|
$character = $html[$index];
|
|
if ($quote !== null) {
|
|
if ($character === $quote) {
|
|
$quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
if ($character === '"' || $character === "'") {
|
|
$quote = $character;
|
|
} elseif (strncasecmp(substr($html, $index, 4), '>', 4) === 0) {
|
|
return $index + 4;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{0:int,1:string}>
|
|
*/
|
|
private function splitText(string $text, int $maxBytes): array
|
|
{
|
|
$parts = [];
|
|
$offset = 0;
|
|
$length = strlen($text);
|
|
|
|
while ($offset < $length) {
|
|
$remaining = substr($text, $offset);
|
|
if (strlen($remaining) <= $maxBytes) {
|
|
$parts[] = [$offset, $remaining];
|
|
break;
|
|
}
|
|
|
|
$candidate = mb_strcut($remaining, 0, $maxBytes, 'UTF-8');
|
|
$splitAt = strlen($candidate);
|
|
if (preg_match_all('/\s+/u', $candidate, $matches, PREG_OFFSET_CAPTURE) > 0) {
|
|
$last = end($matches[0]);
|
|
$whitespaceEnd = $last[1] + strlen($last[0]);
|
|
if ($whitespaceEnd >= (int)($maxBytes / 2)) {
|
|
$splitAt = $whitespaceEnd;
|
|
}
|
|
}
|
|
|
|
$parts[] = [$offset, substr($text, $offset, $splitAt)];
|
|
$offset += $splitAt;
|
|
}
|
|
|
|
return $parts;
|
|
}
|
|
}
|