init
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user