Reader: show how much another translation differs from the one being read

- 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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 14:36:46 -04:00
1 parent 7101be393e
commit aa27a5bb18
10 files changed
+296 -9

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
// 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`;
};