From aa27a5bb18dc6705bc98b7872d55cb9fe2ca70b9 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:36:46 -0400 Subject: [PATCH] Reader: show how much another translation differs from the one being read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/reader/DiffText.jsx | 13 ++++ src/components/reader/ReaderText.jsx | 36 ++++++++- src/components/reader/ReaderText.test.jsx | 53 +++++++++++++ src/components/reader/SettingsSheet.jsx | 8 +- src/components/reader/VerseSheet.jsx | 17 ++++- src/hooks/useBibleReader.js | 2 + src/index.css | 7 ++ src/pages/ReaderPage.jsx | 14 +++- src/utils/textDiff.js | 90 +++++++++++++++++++++++ src/utils/textDiff.test.js | 65 ++++++++++++++++ 10 files changed, 296 insertions(+), 9 deletions(-) create mode 100644 src/components/reader/DiffText.jsx create mode 100644 src/components/reader/ReaderText.test.jsx create mode 100644 src/utils/textDiff.js create mode 100644 src/utils/textDiff.test.js 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} +

+ {diff.mostDifferent.length > 0 && ( +

+ Most different:{' '} + {diff.mostDifferent.map((v, i) => ( + {i > 0 && ', '} ({Math.round((1 - v.similarity) * 100)}%) + ))} +

+ )} +

Marked words aren’t in the other wording.

+
+ )}
{translationId}{parallel.translation}
@@ -99,12 +127,14 @@ export default function ReaderText({

{row.first - ? {num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower)}{chips(row.first)} + ? {num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower, diffByVerse.get(row.number)?.a)}{chips(row.first)} : {num(row.number)}—}

{parallel.translation} - {row.second ? <>{num(row.second.number)}{row.second.text} : parallel.loading ? … : '—'} + {row.second ? <>{num(row.second.number)}{diffByVerse.get(row.number) && !diffByVerse.get(row.number).same && ( + {differenceLabel(diffByVerse.get(row.number).similarity)} + )} : parallel.loading ? … : '—'}

))} diff --git a/src/components/reader/ReaderText.test.jsx b/src/components/reader/ReaderText.test.jsx new file mode 100644 index 0000000..bd2ae5f --- /dev/null +++ b/src/components/reader/ReaderText.test.jsx @@ -0,0 +1,53 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, test, expect } from 'vitest'; +import ReaderText from './ReaderText.jsx'; + +const verses = [ + { number: 1, text: 'In the beginning God created the heavens and the earth.' }, + { number: 2, text: 'Now the earth was formless and void.' }, +]; +const second = [ + { number: 1, text: 'In the beginning God created the heavens and the earth.' }, + { number: 2, text: 'Now the earth was without shape and empty.' }, +]; +const base = { + title: 'Genesis 1', translation: 'Berean Standard Bible', translationId: 'BSB', notice: '', verses, loading: false, error: '', + layout: 'flow', fontSize: 1, wide: false, bookmarks: {}, bookKey: 'GEN', chapter: 1, selectedVerse: null, + onSelectVerse: vi.fn(), renderVerseText: (_n, text) => text, searchLower: '', crossRefs: null, showCrossRefs: false, onSelectionEnd: vi.fn(), +}; +const parallel = (over = {}) => ({ translation: 'NET', name: 'NET Bible', notice: '', verses: second, loading: false, error: '', ...over }); + +test('side by side, it says how different the other Bible is and marks the differing words', () => { + const { container } = render(); + expect(screen.getByText(/is/, { selector: 'p' })).toHaveTextContent(/NET is \d+% different from BSB in this chapter/); + expect(screen.getByText(/1 of 2 verses differ/)).toBeInTheDocument(); + const marked = [...container.querySelectorAll('.diff-hl')].map((n) => n.textContent); + expect(marked).toContain('without shape'); // the other Bible's side + expect(screen.getByRole('button', { name: /verse 2/i })).toBeInTheDocument(); // the most different verse + expect(screen.getByText(/% different/, { selector: 'span.ml-2' })).toBeInTheDocument(); // per-verse figure +}); + +test('identical wording is reported as the same words', () => { + render(); + expect(screen.getByText(/uses the same words as/)).toBeInTheDocument(); +}); + +test('with highlighting off there is no summary and no marks', () => { + const { container } = render(); + expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument(); + expect(container.querySelectorAll('.diff-hl')).toHaveLength(0); +}); + +test('while the other Bible loads there is no comparison yet, and the first Bible still shows', () => { + render(); + expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument(); + expect(screen.getAllByText(/In the beginning God created/).length).toBeGreaterThan(0); +}); + +test('the left Bible gets its own differing words passed to the verse renderer', () => { + const renderVerseText = vi.fn((_n, text) => text); + render(); + const call = renderVerseText.mock.calls.find(([n]) => n === 2); + expect(call[3].map((r) => verses[1].text.slice(r.start, r.end))).toEqual(['formless', 'void']); + fireEvent.click(screen.getByRole('button', { name: /verse 2/i })); // jumping must not throw even if the element is absent +}); diff --git a/src/components/reader/SettingsSheet.jsx b/src/components/reader/SettingsSheet.jsx index e17d946..15d86f5 100644 --- a/src/components/reader/SettingsSheet.jsx +++ b/src/components/reader/SettingsSheet.jsx @@ -22,7 +22,7 @@ function Segmented({ options, value, onChange, label }) { export default function SettingsSheet({ open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide, font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers, - translation, parallel, onParallel, + translation, parallel, onParallel, showDiff, onShowDiff, }) { const row = 'mb-4'; const label = 'mb-1.5 block text-xs text-[var(--r-sub)]'; @@ -68,6 +68,12 @@ export default function SettingsSheet({

Shows both side by side on wider screens, and stacked on a phone.

+ {parallel && ( +
+ Highlight differences from {translation} + +
+ )} ); } diff --git a/src/components/reader/VerseSheet.jsx b/src/components/reader/VerseSheet.jsx index 6b29362..cb43bdc 100644 --- a/src/components/reader/VerseSheet.jsx +++ b/src/components/reader/VerseSheet.jsx @@ -6,6 +6,8 @@ import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx'; import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx'; import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js'; import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js'; +import { diffVerses, differenceLabel } from '../../utils/textDiff.js'; +import { DiffText } from './DiffText.jsx'; const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']]; @@ -68,20 +70,29 @@ function CompareTab({ book, chapter, verse, current }) { return () => controller.abort(); }, [book, chapter, verse]); + const mine = rows[current]?.text; return (
{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 (
-

{t.id}{t.id === current ? ' · reading' : ''}

+

+ {t.id}{t.id === current ? ' · reading' : ''} + {d && {differenceLabel(d.similarity)}} +

{!row ?

Loading…

: row.error ?

{row.error}

- :

{row.text}

} + :

}
); })} -

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'); + }); +});