// 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`; };