Files
study-bible-project/src/utils/textDiff.js
T
nmemmertandClaude Sonnet 5.5 aa27a5bb18 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>
2026-10-01 14:36:46 -04:00

91 lines
3.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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`;
};