*/ 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 */ 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) === '', $start + 4); return $end === false ? null : $end + 3; } if (substr($html, $start, 9) === '', $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 */ 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 */ 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; } }