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`;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user