The wording is compared word by word, ignoring capitals and punctuation. It shows how the wording differs, not which translation is more accurate. Tap any verse and open Compare to see all six translations of it.
+
+ );
+}
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/ReaderBars.jsx b/src/components/reader/ReaderBars.jsx
index 575365d..9e64073 100644
--- a/src/components/reader/ReaderBars.jsx
+++ b/src/components/reader/ReaderBars.jsx
@@ -1,11 +1,11 @@
// Top and bottom chrome for the reader. Everything stays one tap away; focus
// mode hides both.
-import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon } from './icons.jsx';
+import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon } from './icons.jsx';
import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
-export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings, onDoneDrawing }) {
+export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onCompare, compareActive, onSettings, onDoneDrawing }) {
return (
@@ -25,6 +25,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
+
>
)}
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/components/reader/icons.jsx b/src/components/reader/icons.jsx
index 5d1c265..59523f5 100644
--- a/src/components/reader/icons.jsx
+++ b/src/components/reader/icons.jsx
@@ -11,3 +11,4 @@ export const PenIcon = () => ;
export const FocusIcon = () => ;
export const CheckIcon = () => ;
+export const CompareIcon = () => ;
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..0921d10 100644
--- a/src/pages/ReaderPage.jsx
+++ b/src/pages/ReaderPage.jsx
@@ -15,6 +15,7 @@ import ReaderText from '../components/reader/ReaderText.jsx';
import PlanStrip from '../components/reader/PlanStrip.jsx';
import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
import SettingsSheet from '../components/reader/SettingsSheet.jsx';
+import CompareSheet from '../components/reader/CompareSheet.jsx';
import SearchSheet from '../components/reader/SearchSheet.jsx';
import MarksSheet from '../components/reader/MarksSheet.jsx';
import { useToast } from '../components/Toasts.jsx';
@@ -47,6 +48,7 @@ export default function ReaderPage() {
readerSpacing, setReaderSpacing,
readerVerseNumbers, setReaderVerseNumbers,
readerParallel, setReaderParallel,
+ readerShowDiff, setReaderShowDiff,
readerBookmarks,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
@@ -271,6 +273,7 @@ export default function ReaderPage() {
if (e.key === '/') { e.preventDefault(); openSheet('search'); }
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); }
else if (e.key === ',') { e.preventDefault(); openSheet('settings'); }
+ else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); openSheet('compare'); }
else if (e.key === 'f' || e.key === 'F') setChromeVisible((v) => !v);
else if ((e.key === 'b' || e.key === 'B') && selectedKey) {
readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey);
@@ -302,8 +305,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 +322,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 +341,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)});
}
@@ -396,6 +407,7 @@ export default function ReaderPage() {
onSearch={() => openSheet('search')}
onBookmarks={() => openSheet('bookmarks')}
bookmarkCount={bookmarkEntries.length + highlightEntries.length}
+ onCompare={() => openSheet('compare')} compareActive={!!parallelId}
onSettings={() => openSheet('settings')}
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
/>
@@ -429,7 +441,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}
@@ -549,7 +561,11 @@ export default function ReaderPage() {
font={readerFont} onFont={setReaderFont}
spacing={readerSpacing} onSpacing={setReaderSpacing}
verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers}
+ />
+ {
expect(screen.getByPlaceholderText(/Search John 3/)).toBeTruthy();
});
+test('the compare button next to Aa opens the comparison sheet, and choosing a Bible turns it on', () => {
+ ctx.setReaderParallel = vi.fn();
+ render();
+ const compare = screen.getByLabelText('Compare translations');
+ expect(compare.nextElementSibling).toBe(screen.getByLabelText('Reading settings')); // sits right beside Aa
+ fireEvent.click(compare);
+ expect(screen.getByText(/how different its wording is/)).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'NET' }));
+ expect(ctx.setReaderParallel).toHaveBeenCalledWith('NET');
+});
+
test('tapping a verse selects it', () => {
render();
fireEvent.click(screen.getByText(/This man came to Jesus/));
diff --git a/src/utils/textDiff.js b/src/utils/textDiff.js
new file mode 100644
index 0000000..bd68e55
--- /dev/null
+++ b/src/utils/textDiff.js
@@ -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`;
+};
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');
+ });
+});