` and `` — enough to show a translator which sentence of the * original to look at again. */ final class TextDiff { /** * Above this many words on either side the comparison is skipped: it is * quadratic, and a text that long changed is better reread whole. */ protected const MAX_WORDS = 1500; public static function html(?string $old, ?string $new): ?string { $a = self::words($old); $b = self::words($new); if (count($a) > self::MAX_WORDS || count($b) > self::MAX_WORDS) { return null; } // Longest common subsequence, then walked back into runs. $m = count($a); $n = count($b); $lcs = array_fill(0, $m + 1, array_fill(0, $n + 1, 0)); for ($i = $m - 1; $i >= 0; $i--) { for ($j = $n - 1; $j >= 0; $j--) { $lcs[$i][$j] = $a[$i] === $b[$j] ? $lcs[$i + 1][$j + 1] + 1 : max($lcs[$i + 1][$j], $lcs[$i][$j + 1]); } } $out = []; $i = $j = 0; while ($i < $m || $j < $n) { if ($i < $m && $j < $n && $a[$i] === $b[$j]) { $out[] = ['=', $a[$i]]; $i++; $j++; } elseif ($i < $m && ($j === $n || $lcs[$i + 1][$j] >= $lcs[$i][$j + 1])) { // What went comes before what replaced it. $out[] = ['-', $a[$i]]; $i++; } else { $out[] = ['+', $b[$j]]; $j++; } } $html = ''; $run = null; $buffer = []; $flush = function () use (&$html, &$run, &$buffer): void { if ($buffer === []) { return; } $text = e(implode(' ', $buffer)); $html .= match ($run) { '+' => "{$text} ", '-' => "{$text} ", default => "{$text} ", }; $buffer = []; }; foreach ($out as [$op, $word]) { if ($op !== $run) { $flush(); $run = $op; } $buffer[] = $word; } $flush(); return trim($html); } /** * @return array */ protected static function words(?string $text): array { $plain = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags(str_replace(['

', '
', '
', '
'], ' ', (string) $text))))); return $plain === '' ? [] : explode(' ', $plain); } }