Files
esun3d/app/code/Esun/MultiLocaleTranslation/Model/Html/TranslatableTextExtractor.php
T
2026-09-15 10:35:56 +08:00

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 &lt;...&gt;.
*
* @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, '&lt;', $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), '&lt;!--', 7) === 0) {
$end = stripos($html, '--&gt;', $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), '&gt;', 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;
}
}