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:
1 parent
7101be393e
commit
aa27a5bb18
10 files changed
+296
-9
No files matched your search
@@ -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`;
|
||||
};
|
||||
Reference in new issue
Block a user