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 da506ca9a8ab8382c10e18f6398d65a25b2fa6c4
truex <truex@equwal.com>
2026-09-26 18:43:05 -0700

Go on after a page that OCR cannot read, and keep Force OCR per book

- The reading ended with an unhandled rejection when OCR could not
  start, and the progress stayed in the strip: the stop did
  `await (await started).terminate()`. stopOcr stops a started OCR and
  ignores a start that failed.
- When OCR cannot start, the strip says "OCR could not start. The first
  OCR run needs a network connection to download the language data."
  with Retry. OCR starts before the render, so no page renders for
  nothing.
- One page that fails no longer stops the other pages. The reading
  counts it and goes on, and at the end the strip says "N pages could
  not be read." with Retry. Retry reads only the pages that are not
  read.
- A new reading (another OCR language, Force OCR, another book) stops
  the OCR of the reading before at once. Two quick changes of a setting
  ran two OCR jobs at the same time. tesseract.js does not settle a
  page when its worker stops, so a page of a stopped OCR now rejects.
- Force OCR is a setting of the book, in its meta data. As a global
  setting it sent each later text PDF through the slow OCR. The
  checkbox shows the setting of the open book, and the strip says
  "Force OCR is on" while it is on.
- "Clear the page cache" removed the OCR work of all books with no
  question. "Clear this book's pages" and "Clear all pages" ask first.

The strip shows a button after a message (fill, onAction). OcrStartError
moves to ocr-job.ts, so main.ts can use it without tesseract.js in its
chunk.

 index.html            |   3 +-
 src/main.ts           | 155 +++++++++++++++++++++++++++++++++++++++++---------
 src/messages.ts       |  43 +++++++++++++-
 src/ocr-job.ts        |  23 ++++++++
 src/ocr.ts            |  34 ++++++-----
 src/status.ts         |  33 +++++++++--
 src/style.css         |  11 ++++
 test/messages.test.ts |  39 +++++++++++++
 test/ocr-job.test.ts  |  45 +++++++++++++++
 test/ocr.test.ts      |  13 +++++
 10 files changed, 351 insertions(+), 48 deletions(-)
diff --git a/index.html b/index.html
index 7aadff2..598d06a 100644
--- a/index.html
+++ b/index.html
@@ -140,7 +140,8 @@
         ><input id="pause-lookup" type="checkbox" checked /> Pause on lookup</label
       >
       <div class="row">
-        <button id="clear-cache">Clear the page cache</button>
+        <button id="clear-book">Clear this book's pages</button>
+        <button id="clear-all">Clear all pages</button>
       </div>
 
       <ol id="cues"></ol>
diff --git a/src/main.ts b/src/main.ts
index ea24bba..fc21eda 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -15,6 +15,8 @@ import { follow, type FollowEvent, type FollowMode } from './follower';
 import { lookupText, tapTolerance, tokenAt } from './hit-test';
 import { lineBoxes, padBox } from './line-boxes';
 import {
+  CLEAR_ALL_QUESTION,
+  clearBookQuestion,
   clockText,
   EMPTY_FILE_TEXT,
   errorMessage,
@@ -26,17 +28,20 @@ import {
   pdfLoadedText,
   pdfOpenText,
   playerMessage,
+  readingEndMessage,
   readingText,
   UPDATING_CACHE_TEXT,
+  withForceOcr,
   type Message,
 } from './messages';
 import { setupNav } from './nav';
 import type { Ocr } from './ocr';
+import { OcrStartError, stopOcr } from './ocr-job';
 import { defaultRtl } from './paging';
 import type { PdfDoc } from './pdf';
 import { nextPage } from './read-order';
 import { scrollTarget } from './scroll';
-import { say, showPlayer, showReading } from './status';
+import { onAction, say, showPlayer, showReading } from './status';
 import { isAndroid, SubRead } from './subread';
 import { decodeSubtitles, lastCueAt, parseSubtitles, type Cue } from './subtitles';
 import {
@@ -99,7 +104,8 @@ const ui = {
   dict: el<HTMLSelectElement>('dict'),
   pauseRow: el<HTMLLabelElement>('pause-row'),
   pauseLookup: el<HTMLInputElement>('pause-lookup'),
-  clearCache: el<HTMLButtonElement>('clear-cache'),
+  clearBook: el<HTMLButtonElement>('clear-book'),
+  clearAll: el<HTMLButtonElement>('clear-all'),
   cues: el<HTMLOListElement>('cues'),
   pageLeft: el<HTMLButtonElement>('page-left'),
   pageRight: el<HTMLButtonElement>('page-right'),
@@ -150,6 +156,8 @@ const state = {
   reading: null as Message | string | null,
   /** True while the database is not open after SLOW_DB_MS. */
   dbSlow: false,
+  /** "Force OCR" of the open book: the reading skips the text layer. */
+  forceOcr: false,
   /** 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. */
@@ -195,7 +203,6 @@ function loadSettings(): void {
   }
   ui.lang.value = settings.get('lang', 'eng');
   ui.subLang.value = settings.get('subLang', 'auto');
-  ui.forceOcr.checked = settings.get('forceOcr', '0') === '1';
   ui.pauseLookup.checked = settings.get('pauseLookup', '1') === '1';
   loadRtl();
 }
@@ -229,11 +236,23 @@ ui.lang.addEventListener('change', () => {
   loadRtl();
   void readBook();
 });
+// Force OCR is a setting of the book. As a global setting it sent each
+// later text PDF through the slow OCR.
 ui.forceOcr.addEventListener('change', () => {
-  settings.set('forceOcr', ui.forceOcr.checked ? '1' : '0');
+  const book = state.book;
+  if (book === null) return;
+  setForceOcr(ui.forceOcr.checked);
+  void putMeta(book, { forceOcr: state.forceOcr });
   void readBook();
 });
 
+/** Sets Force OCR of the open book: the state, the checkbox and the strip. */
+function setForceOcr(on: boolean): void {
+  state.forceOcr = on;
+  ui.forceOcr.checked = on;
+  showReadingPart();
+}
+
 /** Shows a command that failed in the strip. */
 function sayError(err: unknown): void {
   say(errorMessage(err, isAndroid));
@@ -314,6 +333,9 @@ async function openBook(file: File, meta?: BookMeta): Promise<boolean> {
     clearSubtitles();
   }
   const subtitlesSeq = state.subtitlesSeq;
+  setForceOcr(meta?.forceOcr ?? false);
+  ui.forceOcr.disabled = false;
+  ui.clearBook.disabled = false;
   ui.makeSubs.disabled = false;
   ui.empty.hidden = true;
   ui.page.hidden = false;
@@ -355,6 +377,11 @@ function applyMeta(meta: BookMeta, attempt: number, subtitlesSeq: number): void
   if (page !== undefined && page !== state.currentPage && state.currentPage === 0) {
     void showPage(page);
   }
+  const forceOcr = meta.forceOcr ?? false;
+  if (forceOcr !== state.forceOcr) {
+    setForceOcr(forceOcr);
+    void readBook();
+  }
   if (state.subtitlesSeq !== subtitlesSeq) return;
   const loaded = state.subtitles ? state.subtitlesBook : undefined;
   switch (subtitlesOnOpen(loaded, key, meta.subtitles !== undefined)) {
@@ -451,15 +478,19 @@ function watchDb(): void {
   });
 }
 
-/** The tokens of one page: from the cache, else the text layer, else OCR. */
+/**
+ * The tokens of one page: from the cache, else the text layer, else OCR.
+ * `force` skips the text layer. Rejects with OcrStartError when the page
+ * needs OCR and OCR cannot start.
+ */
 async function readPage(
   pdf: PdfDoc,
   file: File,
   page: number,
+  lang: string,
+  force: boolean,
   ocr: () => Promise<Ocr>,
 ): Promise<PageEntry> {
-  const lang = ui.lang.value;
-  const force = ui.forceOcr.checked;
   if (!force) {
     const cached = await getPage(pageKey(file, page, lang, 'text'));
     if (cached) return cached;
@@ -474,32 +505,55 @@ async function readPage(
       return entry;
     }
   }
+  // OCR starts before the render: when it cannot start, no page is rendered for nothing.
+  const engine = await ocr();
   const canvas = await pdf.renderPage(page, OCR_WIDTH);
-  const tokens = await (await ocr()).recognize(canvas, page);
+  const tokens = await engine.recognize(canvas, page);
   const entry: PageEntry = { tokens, width: canvas.width, height: canvas.height, source: 'ocr' };
   await putPage(pageKey(file, page, lang, 'ocr'), entry);
   return entry;
 }
 
+/** The OCR of the reading that runs now. */
+let readingOcr: { started?: Promise<Ocr> } = {};
+
 /**
  * Reads every page of the book: the current page first, then the pages
  * after it, then the pages before it. The cues are aligned again as pages
  * finish, so the follow starts before the whole book is read.
+ *
+ * A page that cannot be read does not stop the other pages. At the end the
+ * strip tells how many pages could not be read, with Retry. `retry` reads
+ * only the pages that are not read.
  */
-async function readBook(): Promise<void> {
+async function readBook(retry = false): Promise<void> {
   const { pdf, file } = state;
   if (!pdf || !file) return;
   const seq = ++state.readSeq;
-  state.pages = [];
-  state.tokens = [];
-  // The spans index the old tokens, so they go too.
-  state.spans = [];
-  state.cueParts = [];
-  state.markedCue = -1;
+  // The OCR of the reading before stops at once. It does not finish its
+  // page: two quick changes of a setting ran two OCR jobs at the same time.
+  stopOcr(readingOcr.started);
+  const ocr: { started?: Promise<Ocr> } = {};
+  readingOcr = ocr;
+  if (!retry) {
+    state.pages = [];
+    state.tokens = [];
+    // The spans index the old tokens, so they go too.
+    state.spans = [];
+    state.cueParts = [];
+    state.markedCue = -1;
+  }
   const total = pdf.numPages;
-  const pending = new Set(Array.from({ length: total }, (_, i) => i));
+  const pending = new Set(
+    Array.from({ length: total }, (_, i) => i).filter((i) => !state.pages[i]),
+  );
   const counts = { text: 0, ocr: 0 };
+  for (const entry of state.pages) if (entry) counts[entry.source]++;
   const read = (): number => counts.text + counts.ocr;
+  let failed = 0;
+  let startFailed = false;
+  const lang = ui.lang.value;
+  const force = state.forceOcr;
   /** The page that is read now. */
   let page = -1;
   const onOcrProgress = (p: number): void => {
@@ -508,10 +562,13 @@ async function readBook(): Promise<void> {
     }
   };
   // The OCR worker, and tesseract.js itself, load on the first page that needs them.
-  const ocr: { started?: Promise<Ocr> } = {};
   const getOcr = (): Promise<Ocr> => {
-    const lang = ui.lang.value;
-    ocr.started ??= import('./ocr').then(({ createOcr }) => createOcr(lang, onOcrProgress));
+    ocr.started ??= import('./ocr').then(
+      ({ createOcr }) => createOcr(lang, onOcrProgress),
+      (err: unknown) => {
+        throw new OcrStartError(err);
+      },
+    );
     return ocr.started;
   };
   try {
@@ -519,7 +576,21 @@ async function readBook(): Promise<void> {
       page = nextPage(pending, state.currentPage);
       pending.delete(page);
       setReading(readingText(read(), total, `page ${page + 1}`));
-      const entry = await readPage(pdf, file, page, getOcr);
+      let entry: PageEntry;
+      try {
+        entry = await readPage(pdf, file, page, lang, force, getOcr);
+      } catch (err) {
+        if (seq !== state.readSeq) return; // Another book or another setting took over.
+        failed++;
+        // Each page that needs OCR gives the same start error. Tell it once.
+        if (err instanceof OcrStartError) {
+          if (!startFailed) console.warn(err);
+          startFailed = true;
+        } else {
+          console.warn(`Page ${page + 1} could not be read.`, err);
+        }
+        continue;
+      }
       if (seq !== state.readSeq) return; // Another book or another setting took over.
       counts[entry.source]++;
       state.pages[page] = entry;
@@ -534,19 +605,27 @@ async function readBook(): Promise<void> {
     const matched = align();
     say(
       pagesReadText(counts) +
-        (matched === null ? '' : ` ${matchedText(matched, state.cues.length, true)}`),
+        (matched === null ? '' : ` ${matchedText(matched, state.cues.length, failed === 0)}`),
     );
   } catch (err) {
     if (seq === state.readSeq) say(`Cannot read the pages: ${String(err)}`);
   } finally {
-    if (ocr.started) await (await ocr.started).terminate().catch(() => undefined);
-    if (seq === state.readSeq) setReading(null);
+    stopOcr(ocr.started);
+    if (seq === state.readSeq) setReading(readingEndMessage(failed, startFailed));
   }
 }
 
-/** Shows the reading part of the strip: the database notice, else the reading of the pages. */
+/**
+ * Shows the reading part of the strip: the database notice, else the
+ * reading of the pages, with the note while Force OCR is on.
+ */
 function showReadingPart(): void {
-  showReading(state.dbSlow ? UPDATING_CACHE_TEXT : state.reading);
+  if (state.dbSlow) {
+    showReading(UPDATING_CACHE_TEXT);
+    return;
+  }
+  const m = typeof state.reading === 'string' ? { text: state.reading } : state.reading;
+  showReading(withForceOcr(m, state.forceOcr && state.book !== null));
 }
 
 function setReading(m: Message | string | null): void {
@@ -560,9 +639,18 @@ function allPagesRead(): boolean {
   return !!pdf && state.pages.filter((p) => p).length === pdf.numPages;
 }
 
-ui.clearCache.addEventListener('click', async () => {
+// The pages of other books cost hours of OCR, so each clear asks first.
+ui.clearBook.addEventListener('click', async () => {
+  const { book, file } = state;
+  if (book === null || !file || !confirm(clearBookQuestion(file.name))) return;
+  await clearPages(book);
+  say(`The saved pages of ${file.name} are removed.`);
+});
+
+ui.clearAll.addEventListener('click', async () => {
+  if (!confirm(CLEAR_ALL_QUESTION)) return;
   await clearPages();
-  say('Page cache cleared.');
+  say('The saved pages of all books are removed.');
 });
 
 // --- Subtitles ---
@@ -1011,9 +1099,22 @@ ui.dict.addEventListener('change', () => {
   SubRead.setDictionary({ component: ui.dict.value }).catch(sayError);
 });
 
+// --- The buttons in the strip ---
+
+onAction((id) => {
+  switch (id) {
+    case 'retry-reading':
+      void readBook(true);
+      break;
+  }
+});
+
 // --- Start ---
 
 loadSettings();
+// Force OCR and the pages of one book need an open book.
+ui.forceOcr.disabled = true;
+ui.clearBook.disabled = true;
 ui.make.hidden = !isAndroid;
 ui.audioRow.hidden = isAndroid;
 // The web copies a lookup at once: there is no dictionary app to choose or to wait for.
diff --git a/src/messages.ts b/src/messages.ts
index 3745483..732d003 100644
--- a/src/messages.ts
+++ b/src/messages.ts
@@ -2,10 +2,14 @@
 
 import { PlayerError, type ClockState } from './clock-source';
 
-/** A text, and a link after it. */
+/** The actions of the buttons in the strip. main.ts runs them. */
+export type ActionId = 'retry-reading';
+
+/** A text, and a link or a button after it. */
 export interface Message {
   text: string;
   link?: { href: string; text: string };
+  action?: { id: ActionId; text: string };
 }
 
 export const OVERLAY_RELEASES = 'https://github.com/equwal/subread-overlay/releases/latest';
@@ -80,6 +84,43 @@ export function pagesReadText(counts: { text: number; ocr: number }): string {
   return `${count(counts.text + counts.ocr, 'page')} read: ${counts.text} text layer, ${counts.ocr} OCR.`;
 }
 
+export const OCR_START_TEXT =
+  'OCR could not start. The first OCR run needs a network connection to download the language data.';
+
+const RETRY = { id: 'retry-reading', text: 'Retry' } as const;
+
+/**
+ * The reading part of the strip after the reading of the pages ends: what
+ * went wrong, with Retry. `failed` is the count of pages that could not be
+ * read. `startFailed` is true when OCR could not start. Null when each page
+ * is read.
+ */
+export function readingEndMessage(failed: number, startFailed: boolean): Message | null {
+  if (startFailed) return { text: OCR_START_TEXT, action: RETRY };
+  if (failed > 0) return { text: `${count(failed, 'page')} could not be read.`, action: RETRY };
+  return null;
+}
+
+export const FORCE_OCR_NOTE = 'Force OCR is on';
+
+/** The reading part of the strip, with the note while Force OCR is on for the open book. */
+export function withForceOcr(m: Message | null, forceOcr: boolean): Message | null {
+  if (!forceOcr) return m;
+  if (m === null) return { text: `${FORCE_OCR_NOTE}.` };
+  const text = m.text.endsWith('.')
+    ? `${m.text} ${FORCE_OCR_NOTE}.`
+    : `${m.text} · ${FORCE_OCR_NOTE}`;
+  return { ...m, text };
+}
+
+/** 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.`;
+}
+
+export const CLEAR_ALL_QUESTION =
+  'Remove the saved pages of all books? The reader reads each book again the next time it opens it.';
+
 /** The message for a tap on a page that is not read yet. */
 export function notReadText(read: number, total: number): string {
   return `This page is not read yet (${read}/${total} read).`;
diff --git a/src/ocr-job.ts b/src/ocr-job.ts
new file mode 100644
index 0000000..d20f745
--- /dev/null
+++ b/src/ocr-job.ts
@@ -0,0 +1,23 @@
+// The OCR of one reading of the book: the start error and the stop. No
+// tesseract.js here, so main.ts can use this module and still load
+// tesseract.js only when a page needs OCR.
+
+/**
+ * OCR could not start: the OCR code, the worker script, the core or the
+ * language data did not load. `cause` holds the error. The first OCR run of
+ * a language needs the network to download the language data.
+ */
+export class OcrStartError extends Error {
+  constructor(cause: unknown) {
+    super(`OCR could not start: ${String(cause)}`, { cause });
+    this.name = 'OcrStartError';
+  }
+}
+
+/**
+ * Stops the OCR of a reading, and does not wait. A reading whose OCR did
+ * not start has nothing to stop, and its start error is no new error here.
+ */
+export function stopOcr(started: Promise<{ terminate(): Promise<void> }> | undefined): void {
+  started?.then((ocr) => ocr.terminate()).catch(() => undefined);
+}
diff --git a/src/ocr.ts b/src/ocr.ts
index dd427ff..4a6ff1b 100644
--- a/src/ocr.ts
+++ b/src/ocr.ts
@@ -13,25 +13,17 @@ import workerPath from 'tesseract.js/dist/worker.min.js?url';
 // current browsers have WebAssembly SIMD.
 import corePath from 'tesseract.js-core/tesseract-core-simd-lstm.wasm.js?url';
 import type { OcrToken } from './align';
+import { OcrStartError } from './ocr-job';
 import { pageToTokens } from './ocr-tokens';
 
+export { OcrStartError };
+
 export interface Ocr {
+  /** Rejects when the OCR stops before the page is read. */
   recognize(image: HTMLCanvasElement, pageIndex: number): Promise<OcrToken[]>;
   terminate(): Promise<void>;
 }
 
-/**
- * OCR could not start: the worker script, the core or the language data did
- * not load. `cause` holds the error of tesseract.js. The first OCR run of a
- * language needs the network to download the language data.
- */
-export class OcrStartError extends Error {
-  constructor(cause: unknown) {
-    super(`OCR could not start: ${String(cause)}`, { cause });
-    this.name = 'OcrStartError';
-  }
-}
-
 /**
  * Start an OCR worker for `lang` ("eng", "jpn", "jpn_vert", "jpn+eng", ...).
  * `onProgress` gets values in [0, 1] while a page is recognized. Rejects with
@@ -52,12 +44,24 @@ export async function createOcr(lang: string, onProgress: (p: number) => void):
       throw new OcrStartError(err);
     }
   }
+  // tesseract.js does not settle a job that runs when its worker stops. So
+  // each page also waits for the stop, and the reading does not wait for
+  // ever for a page of a stopped worker.
+  let stop: (reason: Error) => void = () => undefined;
+  const stopped = new Promise<never>((_resolve, reject) => {
+    stop = reject;
+  });
+  // A stop while no page runs is no error.
+  stopped.catch(() => undefined);
   return {
-    async recognize(image, pageIndex) {
-      const { data } = await worker.recognize(image, {}, { blocks: true, text: false });
-      return pageToTokens(data, pageIndex);
+    recognize(image, pageIndex) {
+      const page = worker
+        .recognize(image, {}, { blocks: true, text: false })
+        .then(({ data }) => pageToTokens(data, pageIndex));
+      return Promise.race([page, stopped]);
     },
     async terminate() {
+      stop(new Error('OCR stopped.'));
       await worker.terminate();
     },
   };
diff --git a/src/status.ts b/src/status.ts
index e4b74c9..ff9eb68 100644
--- a/src/status.ts
+++ b/src/status.ts
@@ -2,7 +2,7 @@
 // the reading of the pages, and the last event. Only the event part is a
 // live region, because the other parts change often.
 
-import type { Message } from './messages';
+import type { ActionId, Message } from './messages';
 
 /** Milliseconds that an event message stays before it fades. */
 export const EVENT_MS = 6000;
@@ -22,8 +22,11 @@ const event = el('status');
 
 let fadeTimer: ReturnType<typeof setTimeout> | undefined;
 
-/** Writes a message into `target`: the text, then the link. */
-function fill(target: HTMLElement, m: Message): void {
+/**
+ * Writes a message into `target`: the text, then the link or the button. A
+ * click on the button runs the handler of onAction.
+ */
+export function fill(target: HTMLElement, m: Message): void {
   target.textContent = m.text;
   if (m.link) {
     const a = document.createElement('a');
@@ -33,15 +36,37 @@ function fill(target: HTMLElement, m: Message): void {
     a.textContent = m.link.text;
     target.append(' ', a);
   }
+  if (m.action) {
+    const button = document.createElement('button');
+    button.type = 'button';
+    button.className = 'action';
+    button.dataset.action = m.action.id;
+    button.textContent = m.action.text;
+    target.append(' ', button);
+  }
+}
+
+let runAction: ((id: ActionId) => void) | null = null;
+
+/** Sets the handler of the buttons that fill makes. */
+export function onAction(handler: (id: ActionId) => void): void {
+  runAction = handler;
 }
 
+document.addEventListener('click', (e) => {
+  const button = e.target instanceof Element ? e.target.closest('button[data-action]') : null;
+  if (button instanceof HTMLButtonElement && button.dataset.action) {
+    runAction?.(button.dataset.action as ActionId);
+  }
+});
+
 let lastPlayer = '';
 
 /** Shows the state of the player. Null hides this part. */
 export function showPlayer(m: Message | null): void {
   // The clock sends a state four times a second. A new link under the
   // finger can lose a tap, so the part changes only when the text changes.
-  const key = m ? `${m.text}|${m.link?.href ?? ''}` : '';
+  const key = m ? `${m.text}|${m.link?.href ?? ''}|${m.action?.id ?? ''}` : '';
   if (key === lastPlayer) return;
   lastPlayer = key;
   player.hidden = m === null;
diff --git a/src/style.css b/src/style.css
index c876674..9f3adfe 100644
--- a/src/style.css
+++ b/src/style.css
@@ -159,6 +159,17 @@ body {
   color: inherit;
 }
 
+/* A button after a message, for example Retry. */
+button.action {
+  font: inherit;
+  padding: 0 0.5rem;
+  color: inherit;
+  background: none;
+  border: 1px solid currentColor;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
 #pages-status {
   opacity: 0.8;
 }
diff --git a/test/messages.test.ts b/test/messages.test.ts
index cd8a46b..415f953 100644
--- a/test/messages.test.ts
+++ b/test/messages.test.ts
@@ -9,13 +9,16 @@ import {
   MISMATCH_TEXT,
   noCuesText,
   notReadText,
+  OCR_START_TEXT,
   OVERLAY_RELEASES,
   pagesReadText,
   pdfLoadedText,
   pdfOpenText,
   playerMessage,
   playerProblem,
+  readingEndMessage,
   readingText,
+  withForceOcr,
 } from '../src/messages';
 
 function state(over: Partial<ClockState>): ClockState {
@@ -130,6 +133,42 @@ describe('file texts', () => {
   });
 });
 
+describe('the end of the reading', () => {
+  const retry = { id: 'retry-reading', text: 'Retry' };
+
+  it('tells that OCR could not start, with Retry', () => {
+    expect(readingEndMessage(40, true)).toEqual({ text: OCR_START_TEXT, action: retry });
+    expect(OCR_START_TEXT).toBe(
+      'OCR could not start. The first OCR run needs a network connection to download the language data.',
+    );
+  });
+
+  it('counts the pages that could not be read, with Retry', () => {
+    expect(readingEndMessage(1, false)).toEqual({
+      text: '1 page could not be read.',
+      action: retry,
+    });
+    expect(readingEndMessage(3, false)).toEqual({
+      text: '3 pages could not be read.',
+      action: retry,
+    });
+    expect(readingEndMessage(0, false)).toBeNull();
+  });
+
+  it('adds the Force OCR note while it is on', () => {
+    expect(withForceOcr(null, false)).toBeNull();
+    expect(withForceOcr(null, true)).toEqual({ text: 'Force OCR is on.' });
+    expect(withForceOcr({ text: 'Reading 1/2 · page 2: OCR 45%' }, true)).toEqual({
+      text: 'Reading 1/2 · page 2: OCR 45% · Force OCR is on',
+    });
+    expect(withForceOcr(readingEndMessage(2, false), true)).toEqual({
+      text: '2 pages could not be read. Force OCR is on.',
+      action: retry,
+    });
+    expect(withForceOcr({ text: 'Reading 1/2' }, false)).toEqual({ text: 'Reading 1/2' });
+  });
+});
+
 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/ocr-job.test.ts b/test/ocr-job.test.ts
new file mode 100644
index 0000000..17e9584
--- /dev/null
+++ b/test/ocr-job.test.ts
@@ -0,0 +1,45 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { OcrStartError, stopOcr } from '../src/ocr-job';
+
+/** The reasons of the unhandled rejections while `run` runs, and 20 ms after it. */
+async function unhandledDuring(run: () => void): Promise<unknown[]> {
+  const reasons: unknown[] = [];
+  const record = (reason: unknown): void => {
+    reasons.push(reason);
+  };
+  process.on('unhandledRejection', record);
+  try {
+    run();
+    await new Promise((resolve) => setTimeout(resolve, 20));
+  } finally {
+    process.off('unhandledRejection', record);
+  }
+  return reasons;
+}
+
+describe('stopOcr', () => {
+  afterEach(() => vi.restoreAllMocks());
+
+  it('stops the OCR that started', async () => {
+    const terminate = vi.fn(() => Promise.resolve());
+    stopOcr(Promise.resolve({ terminate }));
+    await new Promise((resolve) => setTimeout(resolve, 0));
+    expect(terminate).toHaveBeenCalledTimes(1);
+  });
+
+  it('does nothing when no OCR started', () => {
+    expect(() => stopOcr(undefined)).not.toThrow();
+  });
+
+  it('gives no unhandled rejection when the OCR could not start', async () => {
+    // The web finding: the reading ended with an unhandled rejection, and the
+    // progress stayed in the strip.
+    const failed = Promise.reject(new OcrStartError('TypeError: Failed to fetch'));
+    expect(await unhandledDuring(() => void stopOcr(failed))).toEqual([]);
+  });
+
+  it('gives no unhandled rejection when the stop fails', async () => {
+    const terminate = vi.fn(() => Promise.reject(new Error('The worker is gone.')));
+    expect(await unhandledDuring(() => void stopOcr(Promise.resolve({ terminate })))).toEqual([]);
+  });
+});
diff --git a/test/ocr.test.ts b/test/ocr.test.ts
index 1e3db9d..c9bc2fc 100644
--- a/test/ocr.test.ts
+++ b/test/ocr.test.ts
@@ -90,6 +90,19 @@ describe('createOcr', () => {
     expect(worker.terminate).toHaveBeenCalledTimes(1);
   });
 
+  it('rejects the page that runs when the OCR stops', async () => {
+    // tesseract.js does not settle a job when its worker stops. The web
+    // finding: a new reading started while the old job still read its page.
+    const worker = fakeWorker();
+    worker.recognize.mockImplementation(() => new Promise(() => undefined));
+    createWorker.mockResolvedValue(worker);
+    const ocr = await createOcr('eng', () => undefined);
+    const page = ocr.recognize({} as HTMLCanvasElement, 0);
+    await ocr.terminate();
+    await expect(page).rejects.toThrow('OCR stopped.');
+    expect(worker.terminate).toHaveBeenCalledTimes(1);
+  }, 1000);
+
   it('gives the error of a page as it is, not as OcrStartError', async () => {
     createWorker.mockResolvedValue(fakeWorker());
     const ocr = await createOcr('eng', () => undefined);