diff --git a/src/components/reader/DiffText.jsx b/src/components/reader/DiffText.jsx
new file mode 100644
index 0000000..c8c99a0
--- /dev/null
+++ b/src/components/reader/DiffText.jsx
@@ -0,0 +1,13 @@
+// Text with the words that differ from the other translation marked.
+export function DiffText({ text, ranges = [] }) {
+ if (!ranges.length) return text;
+ const parts = [];
+ let cursor = 0;
+ ranges.forEach((r, i) => {
+ if (r.start > cursor) parts.push(text.slice(cursor, r.start));
+ parts.push({text.slice(r.start, r.end)});
+ cursor = r.end;
+ });
+ if (cursor < text.length) parts.push(text.slice(cursor));
+ return <>{parts}>;
+}
diff --git a/src/components/reader/ReaderText.jsx b/src/components/reader/ReaderText.jsx
index 1ba43b8..c2ad126 100644
--- a/src/components/reader/ReaderText.jsx
+++ b/src/components/reader/ReaderText.jsx
@@ -1,6 +1,9 @@
// The page itself: chapter heading, translation notice, and verses set in a
// serif face. Tap a verse to open the verse sheet; select text to highlight.
+import { useMemo } from 'react';
import { formatCrossRef } from '../../utils/wordStudy.js';
+import { diffChapter, differenceLabel } from '../../utils/textDiff.js';
+import { DiffText } from './DiffText.jsx';
// How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing.
const FONT_STYLES = {
@@ -13,10 +16,18 @@ const LINE_HEIGHTS = { snug: 1.6, normal: 1.85, airy: 2.15 };
// `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first.
export default function ReaderText({
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
- font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null,
+ font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, showDiff = true,
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
crossRefs, showCrossRefs, onSelectionEnd, footer,
}) {
+ // How much the second Bible's wording differs from the first, over the chapter and verse by verse
+ const diff = useMemo(
+ () => (parallel && showDiff && !parallel.loading && !parallel.error && parallel.verses?.length ? diffChapter(verses, parallel.verses) : null),
+ [parallel, showDiff, verses],
+ );
+ const diffByVerse = useMemo(() => new Map((diff?.verses ?? []).map((d) => [d.number, d])), [diff]);
+ const jumpToVerse = (n) => document.getElementById(`reader-verse-${n}`)?.scrollIntoView?.({ block: 'center', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
+
const handleTap = (n) => {
const sel = window.getSelection();
if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping
@@ -92,6 +103,23 @@ export default function ReaderText({
{!loading && !error && sideBySide && !parallel.error && (
+ {diff && (
+
+
+ {parallel.translation}{diff.similarity === 1 ? ' uses the same words as ' : <> is {differenceLabel(diff.similarity)} from >}{translationId} in this chapter
+ {diff.total > 0 && · {diff.differing} of {diff.total} verses differ}
+
{READER_TRANSLATIONS.map((t) => {
const row = rows[t.id];
+ // How this wording differs from the Bible being read, word by word
+ const d = row?.text && mine && t.id !== current ? diffVerses(mine, row.text) : null;
return (
Copyright notices for each translation appear at the foot of the chapter when you read it.
+
+ {mine ? <>Marked words aren’t in the {current} wording. > : null}
+ Copyright notices for each translation appear at the foot of the chapter when you read it.
+
);
}
diff --git a/src/hooks/useBibleReader.js b/src/hooks/useBibleReader.js
index 7be2624..b5a7b04 100644
--- a/src/hooks/useBibleReader.js
+++ b/src/hooks/useBibleReader.js
@@ -103,6 +103,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS);
const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS);
const [readerVerseNumbers, setReaderVerseNumbersRaw] = useStoredChoice('reader-verse-numbers', 'on', ['on', 'off']);
+ const [readerShowDiff, setReaderShowDiffRaw] = useStoredChoice('reader-show-diff', 'on', ['on', 'off']);
const [readerParallel, setReaderParallel] = useStoredChoice('reader-parallel', '', ['', ...READER_TRANSLATIONS.map((t) => t.id)]);
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
const [startPagePref, setStartPagePrefState] = useState(() => {
@@ -532,6 +533,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerSpacing, setReaderSpacing,
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
readerParallel, setReaderParallel,
+ readerShowDiff: readerShowDiff === 'on', setReaderShowDiff: (on) => setReaderShowDiffRaw(on ? 'on' : 'off'),
planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref,
readerBookmarks, setReaderBookmarks,
diff --git a/src/index.css b/src/index.css
index 6e5020b..149da11 100644
--- a/src/index.css
+++ b/src/index.css
@@ -82,3 +82,10 @@ select {
transition-duration: 0.01ms !important;
}
}
+
+/* Words that differ between two translations (Reader → read beside another Bible) */
+.diff-hl {
+ background: color-mix(in srgb, var(--r-accent, #a84e27) 20%, transparent);
+ border-bottom: 2px solid color-mix(in srgb, var(--r-accent, #a84e27) 55%, transparent);
+ border-radius: 2px;
+}
diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx
index 2d06105..3fa14a6 100644
--- a/src/pages/ReaderPage.jsx
+++ b/src/pages/ReaderPage.jsx
@@ -47,6 +47,7 @@ export default function ReaderPage() {
readerSpacing, setReaderSpacing,
readerVerseNumbers, setReaderVerseNumbers,
readerParallel, setReaderParallel,
+ readerShowDiff, setReaderShowDiff,
readerBookmarks,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
@@ -302,8 +303,9 @@ export default function ReaderPage() {
// Render verse text with colored highlights and optional search match highlight
const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
- function renderVerseText(verseNum, text, searchLow) {
+ function renderVerseText(verseNum, text, searchLow, diffRanges) {
const spans = [];
+ for (const r of diffRanges ?? []) spans.push({ start: r.start, end: r.end, type: 'diff' });
for (const h of (readerTextHighlights || [])) {
if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) {
spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color });
@@ -318,6 +320,11 @@ export default function ReaderPage() {
}
}
if (!spans.length) return text;
+ // Your highlights and search matches always show; a difference mark that would overlap one is dropped
+ const firm = spans.filter((x) => x.type !== 'diff');
+ for (let i = spans.length - 1; i >= 0; i -= 1) {
+ if (spans[i].type === 'diff' && firm.some((f) => spans[i].start < f.end && f.start < spans[i].end)) spans.splice(i, 1);
+ }
spans.sort((a, b) => a.start - b.start);
const parts = [];
let cursor = 0;
@@ -332,6 +339,8 @@ export default function ReaderPage() {
{text.slice(s.start, s.end)}
,
);
+ } else if (s.type === 'diff') {
+ parts.push({text.slice(s.start, s.end)});
} else {
parts.push({text.slice(s.start, s.end)});
}
@@ -429,7 +438,7 @@ export default function ReaderPage() {
title={`${readerBook?.name ?? ''} ${readerChapter}`}
translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation}
translationId={readerTranslation}
- font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers}
+ font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers} showDiff={readerShowDiff}
parallel={parallelId ? { translation: parallelId, name: parallelSource?.name ?? parallelId, notice: parallelSource?.notice ?? '', ...parallelChapter } : null}
notice={readerNotice}
verses={readerVerses}
@@ -550,6 +559,7 @@ export default function ReaderPage() {
spacing={readerSpacing} onSpacing={setReaderSpacing}
verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers}
translation={readerTranslation} parallel={parallelId} onParallel={setReaderParallel}
+ showDiff={readerShowDiff} onShowDiff={setReaderShowDiff}
/>
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`;
+};
diff --git a/src/utils/textDiff.test.js b/src/utils/textDiff.test.js
new file mode 100644
index 0000000..4fd68ff
--- /dev/null
+++ b/src/utils/textDiff.test.js
@@ -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');
+ });
+});