- Word-level comparison (ignores case and punctuation): differing words are
marked in both columns of the side-by-side view
- Chapter summary ("NET is 18% different from BSB · 37 of 39 verses differ")
with links to the most different verses, and a percentage on each verse
- Compare tab on a verse shows each translation's difference from the one
being read, with the differing words marked
- "Highlight differences" setting; your own highlights and search matches
always win where they overlap a difference mark
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
91 lines
3.5 KiB
JavaScript
91 lines
3.5 KiB
JavaScript
// How two wordings of the same verse differ: which words are not shared, and how alike they are overall.
|
||
// Comparison ignores case and punctuation ("light,”" matches "Light"), and works on whole words.
|
||
|
||
// Words with their position in the original text: [{ word: 'light', start, end }]
|
||
export function tokenize(text) {
|
||
const out = [];
|
||
const re = /[\p{L}\p{N}][\p{L}\p{N}’'-]*/gu;
|
||
let m;
|
||
while ((m = re.exec(text ?? '')) !== null) {
|
||
out.push({ word: m[0].toLowerCase().replace(/[’']s?$/u, '').replace(/[’'-]/gu, ''), start: m.index, end: m.index + m[0].length });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Longest common subsequence of two word lists -> the indexes kept in each (classic DP; verses are short).
|
||
function commonWords(a, b) {
|
||
const n = a.length;
|
||
const m = b.length;
|
||
const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
|
||
for (let i = n - 1; i >= 0; i -= 1) {
|
||
for (let j = m - 1; j >= 0; j -= 1) {
|
||
dp[i][j] = a[i].word === b[j].word ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||
}
|
||
}
|
||
const inA = new Set();
|
||
const inB = new Set();
|
||
let i = 0;
|
||
let j = 0;
|
||
while (i < n && j < m) {
|
||
if (a[i].word === b[j].word) { inA.add(i); inB.add(j); i += 1; j += 1; }
|
||
else if (dp[i + 1][j] >= dp[i][j + 1]) i += 1;
|
||
else j += 1;
|
||
}
|
||
return { inA, inB };
|
||
}
|
||
|
||
// Joins neighbouring changed words into one range, so a changed phrase is one highlight, not many.
|
||
function ranges(tokens, kept, text) {
|
||
const out = [];
|
||
tokens.forEach((t, i) => {
|
||
if (kept.has(i)) return;
|
||
const last = out[out.length - 1];
|
||
if (last && /^\s*$/.test(text.slice(last.end, t.start))) last.end = t.end;
|
||
else out.push({ start: t.start, end: t.end });
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// -> { a: [{ start, end }], b: [{ start, end }], similarity: 0..1, same: bool }
|
||
// `a` and `b` are the character ranges (in each text) of words the other does not have.
|
||
export function diffVerses(textA, textB) {
|
||
const a = tokenize(textA);
|
||
const b = tokenize(textB);
|
||
if (a.length === 0 && b.length === 0) return { a: [], b: [], similarity: 1, same: true };
|
||
const { inA, inB } = commonWords(a, b);
|
||
const similarity = (2 * inA.size) / (a.length + b.length);
|
||
return { a: ranges(a, inA, textA), b: ranges(b, inB, textB), similarity, same: inA.size === a.length && inB.size === b.length };
|
||
}
|
||
|
||
// A whole chapter: pairs of [mainVerse, otherVerse] by verse number ->
|
||
// { similarity, differing, total, verses: [{ number, similarity, ...ranges }] sorted most different first }
|
||
export function diffChapter(mainVerses, otherVerses) {
|
||
const other = new Map(otherVerses.map((v) => [v.number, v]));
|
||
let shared = 0;
|
||
let words = 0;
|
||
const verses = [];
|
||
for (const v of mainVerses) {
|
||
const o = other.get(v.number);
|
||
if (!o) continue;
|
||
const d = diffVerses(v.text, o.text);
|
||
const na = tokenize(v.text).length;
|
||
const nb = tokenize(o.text).length;
|
||
shared += (d.similarity * (na + nb)) / 2;
|
||
words += (na + nb) / 2;
|
||
verses.push({ number: v.number, ...d });
|
||
}
|
||
return {
|
||
similarity: words === 0 ? 1 : shared / words,
|
||
total: verses.length,
|
||
differing: verses.filter((v) => !v.same).length,
|
||
verses,
|
||
mostDifferent: [...verses].filter((v) => !v.same).sort((x, y) => x.similarity - y.similarity).slice(0, 3),
|
||
};
|
||
}
|
||
|
||
// "12% different" for a similarity of 0.88; "identical words" at 1.
|
||
export const differenceLabel = (similarity) => {
|
||
const pct = Math.round((1 - similarity) * 100);
|
||
return pct === 0 && similarity < 1 ? '<1% different' : pct === 0 ? 'Same words' : `${pct}% different`;
|
||
};
|