Each field's original sits beside the field to write, and a translation that fell behind shows what the original said when it was translated and what it says now, word by word. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
2.7 KiB
PHP
100 lines
2.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support;
|
|
|
|
/**
|
|
* What changed between two versions of a text, word by word, as HTML with
|
|
* `<del>` and `<ins>` — 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) {
|
|
'+' => "<ins>{$text}</ins> ",
|
|
'-' => "<del>{$text}</del> ",
|
|
default => "{$text} ",
|
|
};
|
|
$buffer = [];
|
|
};
|
|
|
|
foreach ($out as [$op, $word]) {
|
|
if ($op !== $run) {
|
|
$flush();
|
|
$run = $op;
|
|
}
|
|
|
|
$buffer[] = $word;
|
|
}
|
|
|
|
$flush();
|
|
|
|
return trim($html);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
protected static function words(?string $text): array
|
|
{
|
|
$plain = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags(str_replace(['</p>', '<br>', '<br/>', '<br />'], ' ', (string) $text)))));
|
|
|
|
return $plain === '' ? [] : explode(' ', $plain);
|
|
}
|
|
}
|