Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


commit 34f8e48386eb68b37ffb26ea44c14a9ecbe839ff
truex <truex@equwal.com>
2026-09-26 18:49:55 -0700

Make subtitles with SubRead after all pages are read

- SubRead started with the pages read so far, so the subtitles covered
  part of the book. The job now waits: the menu says "Reading pages
  N/M first..." and SubRead starts when the last page is read. Another
  book cancels the job that waits. One job runs at a time.
- makeSubtitles gets a result file per book: resultName gives
  "subread-<hash>.srt" from the FNV-1a hash of the book key, because
  the plugin keeps only [A-Za-z0-9._-] and a Japanese name becomes "_".
  After the answer, when a book opens and when the app is active again,
  pendingSubtitles loads an .srt that SubRead 0.10 and later wrote,
  also when Android stopped the reader during the job. A result for a
  book that is not open goes to the meta data of that book.
- An error that starts with "cannot_start" gives "SubRead cannot read
  the audio file. Choose the audio again."
- SubRead 0.9.1 on an audio file with no speech gave 23 cues cut in the
  middle of words, the language "km" and a match rate of 1, and the
  result replaced the subtitles that the user loaded. subreadConcerns
  now asks with confirm() before a result replaces a file of the user,
  when the language is not the requested one (with "auto": the
  language of the OCR setting), and when the match rate is under 0.8.

 src/book-subtitles.ts       |  68 ++++++++++++++
 src/main.ts                 | 223 +++++++++++++++++++++++++++++++++-----------
 src/messages.ts             |  41 ++++++++
 src/token-cache.ts          |   9 +-
 test/book-subtitles.test.ts |  87 ++++++++++++++++-
 test/messages.test.ts       |  37 ++++++++
 test/token-cache.test.ts    |   9 ++
 7 files changed, 415 insertions(+), 59 deletions(-)
diff --git a/src/book-subtitles.ts b/src/book-subtitles.ts
index 906f12d..5db1729 100644
--- a/src/book-subtitles.ts
+++ b/src/book-subtitles.ts
@@ -32,3 +32,71 @@ export function subtitlesOnOpen(
   if (loaded === null || loaded === book) return 'keep';
   return saved ? 'load' : 'clear';
 }
+
+/**
+ * The name of the result file of SubRead for the book `book` (see bookKey),
+ * for example "subread-1a2b3c4d.srt". The plugin keeps only A-Z, a-z, 0-9,
+ * ".", "_" and "-" of a name, so a Japanese file name gives only "_". The
+ * FNV-1a hash of the key tells two books apart.
+ */
+export function resultName(book: string): string {
+  let hash = 0x811c9dc5;
+  for (const byte of new TextEncoder().encode(book)) {
+    hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
+  }
+  return `subread-${hash.toString(16).padStart(8, '0')}.srt`;
+}
+
+/** A SubRead result is suspect when it found less than this share of the lines in the book. */
+export const LOW_MATCH_RATE = 0.8;
+
+/** What the reader knows when SubRead gives subtitles for the open book. */
+export interface SubreadCheck {
+  /** The narration language that the user asked SubRead for: "auto", "ja", "en". */
+  requested: string;
+  /** The OCR language of the book: "eng", "jpn", "jpn_vert", "jpn+eng". */
+  ocrLang: string;
+  /** The language that SubRead found, when it tells. */
+  language?: string | null;
+  /** 0 to 1: the share of the lines that SubRead found in the book, when it tells. */
+  matchRate?: number;
+  /** The subtitles that are loaded now, if any. */
+  loaded?: { name: string; source: 'file' | 'subread' } | null;
+}
+
+/** The languages that the narration can have: the requested one, else those of the OCR language. */
+function expectedLanguages(requested: string, ocrLang: string): string[] {
+  if (requested !== 'auto') return [requested];
+  const out: string[] = [];
+  if (ocrLang.includes('jpn')) out.push('ja');
+  if (ocrLang.includes('eng')) out.push('en');
+  return out;
+}
+
+/**
+ * The reasons to ask the user before the subtitles of SubRead load. Empty
+ * when they can load at once.
+ *
+ * SubRead 0.9.1 gave 23 cues cut in the middle of words, the language "km"
+ * and a match rate of 1 for an audio file with no speech, and the result
+ * replaced the subtitles that the user loaded. So the reader asks when
+ * subtitles from a file are loaded, when the language is not the one of
+ * the book, and when the match rate is low. With the language "auto", the
+ * OCR language tells the language of the book.
+ */
+export function subreadConcerns(c: SubreadCheck): string[] {
+  const out: string[] = [];
+  if (c.loaded?.source === 'file') {
+    out.push(`They replace ${c.loaded.name}, the subtitles that you loaded.`);
+  }
+  const want = expectedLanguages(c.requested, c.ocrLang);
+  // "en-US" and "ja_JP" count as "en" and "ja".
+  const found = c.language?.trim().toLowerCase().split(/[-_]/)[0];
+  if (found && want.length > 0 && !want.includes(found)) {
+    out.push(`SubRead found the language "${c.language}", not "${want.join('" or "')}".`);
+  }
+  if (c.matchRate !== undefined && c.matchRate >= 0 && c.matchRate < LOW_MATCH_RATE) {
+    out.push(`SubRead found only ${Math.round(c.matchRate * 100)}% of the lines in the book.`);
+  }
+  return out;
+}
diff --git a/src/main.ts b/src/main.ts
index fc21eda..38b71eb 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -6,7 +6,7 @@
 // (the strip) and view.ts (the page).
 
 import { createAligner, type Aligner, type OcrToken, type TokenSpan } from './align';
-import { srtName, subtitlesOnOpen } from './book-subtitles';
+import { resultName, srtName, subreadConcerns, subtitlesOnOpen } from './book-subtitles';
 import { bookText } from './book-text';
 import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from './clock-source';
 import { audioPage, cueParts, cueProgress, pageStartTime, type CueParts } from './cue-pages';
@@ -30,7 +30,12 @@ import {
   playerMessage,
   readingEndMessage,
   readingText,
+  SUBREAD_NOT_INSTALLED,
+  subreadErrorText,
+  subreadQuestion,
+  subreadResultText,
   UPDATING_CACHE_TEXT,
+  waitPagesText,
   withForceOcr,
   type Message,
 } from './messages';
@@ -41,11 +46,12 @@ import { defaultRtl } from './paging';
 import type { PdfDoc } from './pdf';
 import { nextPage } from './read-order';
 import { scrollTarget } from './scroll';
-import { onAction, say, showPlayer, showReading } from './status';
-import { isAndroid, SubRead } from './subread';
+import { fill, onAction, say, showPlayer, showReading } from './status';
+import { isAndroid, SubRead, type SubtitlesResult } from './subread';
 import { decodeSubtitles, lastCueAt, parseSubtitles, type Cue } from './subtitles';
 import {
   bookKey,
+  bookName,
   clearLastBook,
   clearPages,
   dbReady,
@@ -74,8 +80,6 @@ const PAGE_SAVE_MS = 1000;
 /** After this many milliseconds, the strip tells that the database upgrade holds the reading. */
 const SLOW_DB_MS = 2000;
 
-const SUBREAD_RELEASES = 'https://github.com/equwal/subread-android/releases/latest';
-
 function el<T extends HTMLElement>(id: string): T {
   const e = document.getElementById(id);
   if (!e) throw new Error(`Missing element #${id}`);
@@ -158,6 +162,10 @@ const state = {
   dbSlow: false,
   /** "Force OCR" of the open book: the reading skips the text layer. */
   forceOcr: false,
+  /** A SubRead job that waits until all pages of its book are read. */
+  makeWait: null as { audio: { uri: string; name: string }; book: string } | null,
+  /** The book of the SubRead job that runs, or null. */
+  making: null as string | null,
   /** True while the player waits for a dictionary lookup that this app paused. */
   lookupPaused: false,
   /** True while a page turn by the user holds the follow. */
@@ -333,10 +341,16 @@ async function openBook(file: File, meta?: BookMeta): Promise<boolean> {
     clearSubtitles();
   }
   const subtitlesSeq = state.subtitlesSeq;
+  // A SubRead job that waits for the pages of another book does not start.
+  if (state.makeWait && state.makeWait.book !== key) {
+    state.makeWait = null;
+    ui.makeStatus.textContent = '';
+  }
   setForceOcr(meta?.forceOcr ?? false);
   ui.forceOcr.disabled = false;
   ui.clearBook.disabled = false;
-  ui.makeSubs.disabled = false;
+  // One SubRead job at a time.
+  ui.makeSubs.disabled = state.making !== null;
   ui.empty.hidden = true;
   ui.page.hidden = false;
   if (state.clock) showPlayerStatus(state.clock);
@@ -382,22 +396,26 @@ function applyMeta(meta: BookMeta, attempt: number, subtitlesSeq: number): void
     setForceOcr(forceOcr);
     void readBook();
   }
-  if (state.subtitlesSeq !== subtitlesSeq) return;
-  const loaded = state.subtitles ? state.subtitlesBook : undefined;
-  switch (subtitlesOnOpen(loaded, key, meta.subtitles !== undefined)) {
-    case 'keep':
-      if (state.subtitles) {
-        state.subtitlesBook = key;
-        void putMeta(key, { subtitles: state.subtitles });
-      }
-      break;
-    case 'load':
-      if (meta.subtitles) loadSubtitles(meta.subtitles, { save: false });
-      break;
-    case 'clear':
-      clearSubtitles();
-      break;
+  if (state.subtitlesSeq === subtitlesSeq) {
+    const loaded = state.subtitles ? state.subtitlesBook : undefined;
+    switch (subtitlesOnOpen(loaded, key, meta.subtitles !== undefined)) {
+      case 'keep':
+        if (state.subtitles) {
+          state.subtitlesBook = key;
+          void putMeta(key, { subtitles: state.subtitles });
+        }
+        break;
+      case 'load':
+        if (meta.subtitles) loadSubtitles(meta.subtitles, { save: false });
+        break;
+      case 'clear':
+        clearSubtitles();
+        break;
+    }
   }
+  // After the saved subtitles: a SubRead result for a book with a file of
+  // the user asks first.
+  void checkPendingSubtitles();
 }
 
 // --- The page of the book, in its meta data ---
@@ -601,6 +619,7 @@ async function readBook(retry = false): Promise<void> {
       setReading(readingText(read(), total, `page ${page + 1}: ${how}`));
       if (page === state.currentPage) view.mark();
       scheduleAlign();
+      startMakeWhenRead();
     }
     const matched = align();
     say(
@@ -758,52 +777,144 @@ function showSrtRow(): void {
   };
 }
 
+// SubRead makes the subtitles from the audio and the text of the book. It
+// starts when all pages are read: with part of the text, the subtitles
+// cover only part of the book.
 ui.makeSubs.addEventListener('click', async () => {
-  const file = state.file;
-  if (!file) return;
-  let audio;
+  const book = state.book;
+  if (book === null) return;
+  let audio: { uri: string; name: string };
   try {
     audio = await SubRead.pickAudio();
   } catch {
     return;
   }
-  const read = state.pages.filter((p) => p).length;
-  ui.makeStatus.textContent = allPagesRead()
-    ? `SubRead makes the subtitles for ${audio.name}...`
-    : `Only ${read} of ${state.pdf?.numPages ?? 0} pages are read. The subtitles cover those pages. SubRead runs...`;
-  const result = await SubRead.makeSubtitles({
-    audio: audio.uri,
-    bookText: bookText(state.tokens),
-    language: ui.subLang.value,
-  });
-  // The subtitles are for the book of the job, not for a book that opened since.
-  if (state.file !== file) return;
+  if (state.book !== book) return;
+  state.makeWait = { audio, book };
+  startMakeWhenRead();
+});
+
+/**
+ * Starts the SubRead job that waits, when all pages of its book are read.
+ * Else tells how far the reading is. readBook calls this after each page.
+ */
+function startMakeWhenRead(): void {
+  const wait = state.makeWait;
+  const pdf = state.pdf;
+  if (!wait || !pdf || wait.book !== state.book) return;
+  if (!allPagesRead()) {
+    ui.makeStatus.textContent = waitPagesText(state.pages.filter((p) => p).length, pdf.numPages);
+    return;
+  }
+  state.makeWait = null;
+  void makeSubtitles(wait.audio, wait.book);
+}
+
+/** Asks SubRead for the subtitles of the book `book`, and loads them after the checks. */
+async function makeSubtitles(audio: { uri: string; name: string }, book: string): Promise<void> {
+  const name = resultName(book);
+  state.making = book;
+  ui.makeSubs.disabled = true;
+  ui.makeStatus.textContent = `SubRead makes the subtitles for ${audio.name}...`;
+  let result: SubtitlesResult;
+  try {
+    result = await SubRead.makeSubtitles({
+      audio: audio.uri,
+      bookText: bookText(state.tokens),
+      language: ui.subLang.value,
+      resultName: name,
+    });
+  } catch (err) {
+    result = { error: String(err) };
+  } finally {
+    state.making = null;
+    ui.makeSubs.disabled = state.book === null;
+  }
+  // The result file waits for its book: pendingSubtitles loads it when the book opens.
+  if (state.book !== book) {
+    ui.makeStatus.textContent = 'SubRead finished, but another book is open now.';
+    return;
+  }
   if (result.error === 'not_installed') {
-    ui.makeStatus.replaceChildren('SubRead is not installed. ');
-    const a = document.createElement('a');
-    a.href = SUBREAD_RELEASES;
-    a.target = '_blank';
-    a.rel = 'noopener';
-    a.textContent = 'Get SubRead';
-    ui.makeStatus.append(a);
+    fill(ui.makeStatus, SUBREAD_NOT_INSTALLED);
     return;
   }
-  if (result.error !== undefined || result.srt === undefined) {
-    ui.makeStatus.textContent = `SubRead made no subtitles: ${result.error ?? 'no file'}.`;
+  // SubRead 0.10 and later also write the .srt into the result file. Read
+  // it now, so that the file goes, and use it when the answer has no .srt.
+  const copy = await SubRead.pendingSubtitles({ resultName: name }).catch(() => ({}) as PendingSrt);
+  const srt = result.srt ?? copy.srt;
+  if (srt === undefined) {
+    ui.makeStatus.textContent = subreadErrorText(result.error ?? 'no file');
     return;
   }
-  const rate = result.matchRate !== undefined && result.matchRate >= 0 ? result.matchRate : null;
-  ui.makeStatus.textContent =
-    `${result.cues ?? '?'} cues, language ${result.language ?? '?'}` +
-    (rate === null ? '.' : `, ${Math.round(rate * 100)}% of the lines found in the book.`) +
-    (rate !== null && rate < 0.8
-      ? ' Under 80% usually means another edition or the wrong language.'
-      : '');
-  loadSubtitles(
-    { name: srtName(file.name), text: result.srt, source: 'subread' },
-    { from: 'SubRead' },
-  );
-});
+  if (state.book !== book) {
+    void putMeta(book, { subtitles: subreadSubtitles(book, srt) });
+    return;
+  }
+  ui.makeStatus.textContent = subreadResultText(result);
+  offerSubreadSubtitles(srt, result);
+}
+
+type PendingSrt = { srt?: string };
+
+/** Subtitles that SubRead made for the book `book`. */
+function subreadSubtitles(book: string, srt: string): BookSubtitles {
+  return { name: srtName(bookName(book)), text: srt, source: 'subread' };
+}
+
+/**
+ * Loads subtitles that SubRead made for the open book. The user confirms
+ * first when they replace a file that the user loaded, or when the
+ * language or the match rate of SubRead look wrong (subreadConcerns).
+ */
+function offerSubreadSubtitles(
+  srt: string,
+  info: { language?: string | null; matchRate?: number },
+): boolean {
+  const book = state.book;
+  if (book === null) return false;
+  const concerns = subreadConcerns({
+    requested: ui.subLang.value,
+    ocrLang: ui.lang.value,
+    language: info.language,
+    matchRate: info.matchRate,
+    loaded: state.subtitles,
+  });
+  if (concerns.length > 0 && !confirm(subreadQuestion(concerns))) {
+    say('The subtitles of SubRead are not loaded.');
+    return false;
+  }
+  loadSubtitles(subreadSubtitles(book, srt), { from: 'SubRead' });
+  return true;
+}
+
+/**
+ * Loads the .srt that SubRead wrote into the result file of the open book.
+ * SubRead 0.10 and later write it also when Android stopped the reader
+ * during the job; then no answer comes. The reader checks when a book
+ * opens and when the app is active again.
+ */
+async function checkPendingSubtitles(): Promise<void> {
+  const book = state.book;
+  // During a job the answer of makeSubtitles brings the .srt.
+  if (!isAndroid || book === null || state.making !== null) return;
+  let pending: PendingSrt;
+  try {
+    pending = await SubRead.pendingSubtitles({ resultName: resultName(book) });
+  } catch {
+    return;
+  }
+  const srt = pending.srt;
+  if (srt === undefined) return;
+  // The call removed the file, so the .srt goes to the meta data of its book.
+  if (state.book !== book) {
+    void putMeta(book, { subtitles: subreadSubtitles(book, srt) });
+    return;
+  }
+  if (offerSubreadSubtitles(srt, {})) {
+    ui.makeStatus.textContent = 'SubRead finished while the reader was closed.';
+  }
+}
 
 // --- Alignment ---
 
diff --git a/src/messages.ts b/src/messages.ts
index 732d003..60a4a91 100644
--- a/src/messages.ts
+++ b/src/messages.ts
@@ -13,6 +13,7 @@ export interface Message {
 }
 
 export const OVERLAY_RELEASES = 'https://github.com/equwal/subread-overlay/releases/latest';
+export const SUBREAD_RELEASES = 'https://github.com/equwal/subread-android/releases/latest';
 
 /** "1 page", "2 pages". */
 function count(n: number, noun: string): string {
@@ -113,6 +114,46 @@ export function withForceOcr(m: Message | null, forceOcr: boolean): Message | nu
   return { ...m, text };
 }
 
+export const SUBREAD_NOT_INSTALLED: Message = {
+  text: 'SubRead is not installed.',
+  link: { href: SUBREAD_RELEASES, text: 'Get SubRead' },
+};
+
+/** SubRead waits for the reading: it makes the subtitles from the text of all pages. */
+export function waitPagesText(read: number, total: number): string {
+  return `Reading pages ${read}/${total} first...`;
+}
+
+/** What SubRead made: the cues, the language and the share of the lines found in the book. */
+export function subreadResultText(r: {
+  cues?: number;
+  language?: string | null;
+  matchRate?: number;
+}): string {
+  const cues = r.cues !== undefined && r.cues >= 0 ? String(r.cues) : '?';
+  const rate = r.matchRate !== undefined && r.matchRate >= 0 ? r.matchRate : null;
+  return (
+    `${cues} cues, language ${r.language ?? '?'}` +
+    (rate === null ? '.' : `, ${Math.round(rate * 100)}% of the lines found in the book.`) +
+    (rate !== null && rate < 0.8
+      ? ' Under 80% usually means another edition or the wrong language.'
+      : '')
+  );
+}
+
+/** Why SubRead made no subtitles. `cannot_start` means that the read grant of the audio is gone. */
+export function subreadErrorText(error: string): string {
+  if (error.startsWith('cannot_start')) {
+    return 'SubRead cannot read the audio file. Choose the audio again.';
+  }
+  return `SubRead made no subtitles: ${error}.`;
+}
+
+/** The question before the subtitles of SubRead load, with the reasons of subreadConcerns. */
+export function subreadQuestion(concerns: readonly string[]): string {
+  return ['SubRead made subtitles for this book.', ...concerns, 'Load them?'].join('\n\n');
+}
+
 /** The question before the saved pages of one book go. */
 export function clearBookQuestion(name: string): string {
   return `Remove the saved pages of ${name}? The reader reads them again the next time it opens the book.`;
diff --git a/src/token-cache.ts b/src/token-cache.ts
index 1b502a6..f34f38f 100644
--- a/src/token-cache.ts
+++ b/src/token-cache.ts
@@ -124,8 +124,7 @@ async function moveSrtToMeta(
 
 /** Subtitles from SubRead for the book `key`. "book.pdf|123" gives the name "book.srt". */
 function subreadSubtitles(key: string, text: string): BookSubtitles {
-  const bar = key.lastIndexOf('|');
-  return { name: srtName(bar < 0 ? key : key.slice(0, bar)), text, source: 'subread' };
+  return { name: srtName(bookName(key)), text, source: 'subread' };
 }
 
 /** Runs `op` on the database. Gives undefined when IndexedDB fails, and warns once. */
@@ -148,6 +147,12 @@ export function bookKey(file: FileId): string {
   return `${file.name}|${file.size}`;
 }
 
+/** The file name in a book key: "book.pdf|123" gives "book.pdf". */
+export function bookName(key: string): string {
+  const bar = key.lastIndexOf('|');
+  return bar < 0 ? key : key.slice(0, bar);
+}
+
 export function pageKey(file: FileId, page: number, lang: string, source: TextSource): string {
   return `${bookKey(file)}|${page}|${lang}|${source}`;
 }
diff --git a/test/book-subtitles.test.ts b/test/book-subtitles.test.ts
index 452ea59..394b446 100644
--- a/test/book-subtitles.test.ts
+++ b/test/book-subtitles.test.ts
@@ -1,5 +1,6 @@
+import fc from 'fast-check';
 import { describe, expect, it } from 'vitest';
-import { srtName, subtitlesOnOpen } from '../src/book-subtitles';
+import { resultName, srtName, subreadConcerns, subtitlesOnOpen } from '../src/book-subtitles';
 
 describe('srtName', () => {
   it('puts .srt in place of .pdf', () => {
@@ -39,3 +40,87 @@ describe('subtitlesOnOpen', () => {
     expect(subtitlesOnOpen(jpn, jpn, false)).toBe('keep');
   });
 });
+
+describe('resultName', () => {
+  it('is the FNV-1a hash of the book key', () => {
+    // The test vectors of FNV-1a (32 bit).
+    expect(resultName('')).toBe('subread-811c9dc5.srt');
+    expect(resultName('a')).toBe('subread-e40c292c.srt');
+    expect(resultName('foobar')).toBe('subread-bf9cf968.srt');
+  });
+
+  it('tells apart two Japanese books that the plugin would both call "_"', () => {
+    const neko = resultName('吾輩は猫である.pdf|204800');
+    const botchan = resultName('坊っちゃん.pdf|204800');
+    expect(neko).not.toBe(botchan);
+    expect(resultName('吾輩は猫である.pdf|204801')).not.toBe(neko);
+  });
+
+  it('gives a name that the plugin keeps as it is, the same for the same key', () => {
+    fc.assert(
+      fc.property(fc.string({ unit: 'binary' }), (key) => {
+        const name = resultName(key);
+        expect(name).toMatch(/^subread-[0-9a-f]{8}\.srt$/);
+        expect(resultName(key)).toBe(name);
+      }),
+    );
+  });
+});
+
+describe('subreadConcerns', () => {
+  const file = { name: 'sample-eng-2p.srt', source: 'file' as const };
+
+  it('asks for the phone case: no speech, language km, match rate 1, over a loaded file', () => {
+    expect(
+      subreadConcerns({
+        requested: 'auto',
+        ocrLang: 'eng',
+        language: 'km',
+        matchRate: 1,
+        loaded: file,
+      }),
+    ).toEqual([
+      'They replace sample-eng-2p.srt, the subtitles that you loaded.',
+      'SubRead found the language "km", not "en".',
+    ]);
+  });
+
+  it('loads at once a good result over no subtitles or over an earlier SubRead result', () => {
+    const good = { requested: 'auto', ocrLang: 'jpn', language: 'ja', matchRate: 0.97 };
+    expect(subreadConcerns(good)).toEqual([]);
+    const earlier = { name: 'neko.srt', source: 'subread' as const };
+    expect(subreadConcerns({ ...good, loaded: earlier })).toEqual([]);
+  });
+
+  it('compares the found language with the requested one', () => {
+    expect(subreadConcerns({ requested: 'ja', ocrLang: 'eng', language: 'ja' })).toEqual([]);
+    expect(subreadConcerns({ requested: 'ja', ocrLang: 'jpn', language: 'en' })).toEqual([
+      'SubRead found the language "en", not "ja".',
+    ]);
+  });
+
+  it('takes the language of the book from the OCR language when the request is auto', () => {
+    const both = { requested: 'auto', ocrLang: 'jpn+eng' };
+    expect(subreadConcerns({ ...both, language: 'en' })).toEqual([]);
+    expect(subreadConcerns({ ...both, language: 'ja' })).toEqual([]);
+    expect(subreadConcerns({ ...both, language: 'km' })).toEqual([
+      'SubRead found the language "km", not "ja" or "en".',
+    ]);
+    const vertical = { requested: 'auto', ocrLang: 'jpn_vert', language: 'ja' };
+    expect(subreadConcerns(vertical)).toEqual([]);
+    expect(subreadConcerns({ requested: 'auto', ocrLang: 'eng', language: 'en-US' })).toEqual([]);
+  });
+
+  it('does not ask about a language or a match rate that SubRead does not tell', () => {
+    expect(subreadConcerns({ requested: 'ja', ocrLang: 'jpn', language: null })).toEqual([]);
+    expect(subreadConcerns({ requested: 'ja', ocrLang: 'jpn', language: '' })).toEqual([]);
+    expect(subreadConcerns({ requested: 'ja', ocrLang: 'jpn', matchRate: -1 })).toEqual([]);
+  });
+
+  it('asks when less than 80% of the lines are found in the book', () => {
+    expect(subreadConcerns({ requested: 'en', ocrLang: 'eng', matchRate: 0.42 })).toEqual([
+      'SubRead found only 42% of the lines in the book.',
+    ]);
+    expect(subreadConcerns({ requested: 'en', ocrLang: 'eng', matchRate: 0.8 })).toEqual([]);
+  });
+});
diff --git a/test/messages.test.ts b/test/messages.test.ts
index 415f953..b9c0410 100644
--- a/test/messages.test.ts
+++ b/test/messages.test.ts
@@ -18,6 +18,10 @@ import {
   playerProblem,
   readingEndMessage,
   readingText,
+  subreadErrorText,
+  subreadQuestion,
+  subreadResultText,
+  waitPagesText,
   withForceOcr,
 } from '../src/messages';
 
@@ -169,6 +173,39 @@ describe('the end of the reading', () => {
   });
 });
 
+describe('SubRead texts', () => {
+  it('tells how far the reading is while SubRead waits', () => {
+    expect(waitPagesText(3, 40)).toBe('Reading pages 3/40 first...');
+  });
+
+  it('tells what SubRead made', () => {
+    expect(subreadResultText({ cues: 15, language: 'en', matchRate: 0.97 })).toBe(
+      '15 cues, language en, 97% of the lines found in the book.',
+    );
+    expect(subreadResultText({ cues: 15, language: 'en', matchRate: 0.5 })).toBe(
+      '15 cues, language en, 50% of the lines found in the book. Under 80% usually means another edition or the wrong language.',
+    );
+    // The result file holds only the .srt, and the plugin gives -1 for no value.
+    expect(subreadResultText({})).toBe('? cues, language ?.');
+    expect(subreadResultText({ cues: -1, language: null, matchRate: -1 })).toBe(
+      '? cues, language ?.',
+    );
+  });
+
+  it('asks the user to choose the audio again when SubRead cannot start', () => {
+    expect(subreadErrorText('cannot_start: Permission Denial: opening provider')).toBe(
+      'SubRead cannot read the audio file. Choose the audio again.',
+    );
+    expect(subreadErrorText('cancelled')).toBe('SubRead made no subtitles: cancelled.');
+  });
+
+  it('asks with the reasons before the subtitles of SubRead load', () => {
+    expect(subreadQuestion(['A.', 'B.'])).toBe(
+      'SubRead made subtitles for this book.\n\nA.\n\nB.\n\nLoad them?',
+    );
+  });
+});
+
 describe('matchedText', () => {
   it('counts the matched cues of the pages read so far', () => {
     expect(matchedText(2, 15, false)).toBe('2/15 cues matched to the pages read so far.');
diff --git a/test/token-cache.test.ts b/test/token-cache.test.ts
index 716cc97..3f8e61a 100644
--- a/test/token-cache.test.ts
+++ b/test/token-cache.test.ts
@@ -206,6 +206,15 @@ describe('token cache', () => {
     );
   }, 30_000);
 
+  it('gives back the file name of a book key, also a name with "|"', async () => {
+    const cache = await freshCache();
+    fc.assert(
+      fc.property(fc.string({ unit: 'binary' }), fc.nat(), (name, size) => {
+        expect(cache.bookName(cache.bookKey({ name, size }))).toBe(name);
+      }),
+    );
+  });
+
   it('works without IndexedDB, and warns once', async () => {
     const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
     vi.spyOn(indexedDB, 'open').mockImplementation(() => {