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`;
};
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from 'vitest';
import { diffChapter, diffVerses, differenceLabel, tokenize } from './textDiff.js';
const slice = (text, ranges) => ranges.map((r) => text.slice(r.start, r.end));
describe('tokenize', () => {
test('words only, lowercased, with their place in the text', () => {
const t = tokenize('God said, “Let there be light,”');
expect(t.map((x) => x.word)).toEqual(['god', 'said', 'let', 'there', 'be', 'light']);
expect(t[0]).toMatchObject({ start: 0, end: 3 });
});
test('possessives and apostrophes do not split or change words', () => {
expect(tokenize('God’s law').map((x) => x.word)).toEqual(['god', 'law']);
expect(tokenize("God's law").map((x) => x.word)).toEqual(['god', 'law']);
});
});
describe('diffVerses', () => {
test('identical wording, whatever the punctuation or case, is the same', () => {
const d = diffVerses('In the beginning God created the heavens and the earth.', 'in the beginning, God created the heavens and the earth');
expect(d).toMatchObject({ same: true, similarity: 1, a: [], b: [] });
});
test('marks the words each side has that the other does not', () => {
const a = 'The earth was formless and void';
const b = 'The earth was without shape and empty';
const d = diffVerses(a, b);
expect(slice(a, d.a)).toEqual(['formless', 'void']);
expect(slice(b, d.b)).toEqual(['without shape', 'empty']); // neighbouring words become one range
expect(d.same).toBe(false);
expect(d.similarity).toBeCloseTo((2 * 4) / 13, 5);
});
test('a pure addition shows only on the side that has it', () => {
const d = diffVerses('God saw the light', 'God saw that the light was good');
expect(d.a).toEqual([]);
expect(d.b.length).toBeGreaterThan(0);
});
test('totally different text is not alike at all; two empty ones are the same', () => {
expect(diffVerses('alpha beta', 'gamma delta').similarity).toBe(0);
expect(diffVerses('', '').same).toBe(true);
});
});
describe('diffChapter', () => {
const main = [{ number: 1, text: 'In the beginning God created.' }, { number: 2, text: 'The earth was formless.' }, { number: 3, text: 'Let there be light.' }];
const other = [{ number: 1, text: 'In the beginning God created.' }, { number: 2, text: 'The earth was shapeless and void.' }, { number: 4, text: 'Extra verse only here.' }];
test('scores the chapter over verses both have, and lists the most different first', () => {
const c = diffChapter(main, other);
expect(c.total).toBe(2); // verse 3 and 4 are not in both
expect(c.differing).toBe(1);
expect(c.mostDifferent.map((v) => v.number)).toEqual([2]);
expect(c.similarity).toBeGreaterThan(0.7);
expect(c.similarity).toBeLessThan(1);
});
test('an identical chapter is fully alike', () => {
expect(diffChapter(main, main)).toMatchObject({ similarity: 1, differing: 0, mostDifferent: [] });
});
});
describe('differenceLabel', () => {
test('says it in percent, and never "0% different" for something that does differ', () => {
expect(differenceLabel(0.88)).toBe('12% different');
expect(differenceLabel(1)).toBe('Same words');
expect(differenceLabel(0.9999)).toBe('<1% different');
});
});