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 7279e6a78a56be19697104dac3bc951d712f6cb1
truex <truex@equwal.com>
2026-09-26 18:25:04 -0700

Keep the open book when a file does not load

A damaged file in the PDF field broke the open book: the handler closed
the old document before the new one loaded. After the error, Next
changed the page label but not the page, and pdf.js logged "Cannot read
properties of null (reading 'sendWithPromise')". openBook now loads the
new document first. The old book goes only after the load succeeds.

- A file that pdf.js cannot open gives "This file is not a PDF, or it
  is damaged." in place of the InvalidPDFException text.
- A picked file of 0 bytes, for example a file that another app still
  writes, gives "The file is empty or not ready. Try again in a
  moment." for the PDF and for the subtitles.
- decodeSubtitles reads the subtitle file (UTF-8, UTF-16 or Shift_JIS)
  in place of file.text().
- A subtitle file with no cues keeps the cues that are loaded. On the
  phone a PDF in the subtitles field replaced 15 cues with 0.
- The subtitles field has no type filter. A browser download of an .srt
  often has the type application/octet-stream, and the filter made it
  grey in the Android picker.
- Both fields forget the picked file, so the same file can be picked
  again.
- The .srt row shows only for subtitles that SubRead made.
- When all pages are read and less than 30% of the cues match, the
  strip says "These subtitles do not match this book or the OCR
  language." after the count.
- A render of a closed document no longer gives an unhandled
  rejection.

 index.html                  |   4 +-
 src/book-subtitles.ts       |   6 ++
 src/main.ts                 | 166 +++++++++++++++++++++++++++++++++-----------
 src/messages.ts             |  34 +++++++++
 src/view.ts                 |   3 +-
 test/book-subtitles.test.ts |  15 ++++
 test/messages.test.ts       |  61 ++++++++++++++++
 7 files changed, 245 insertions(+), 44 deletions(-)
diff --git a/index.html b/index.html
index df31fd5..45c4eaf 100644
--- a/index.html
+++ b/index.html
@@ -80,9 +80,7 @@
       </p>
 
       <label>PDF (scan or text) <input id="pdf-file" type="file" accept="application/pdf" /></label>
-      <label
-        >Subtitles (.srt or .vtt) <input id="sub-file" type="file" accept=".srt,.vtt,text/*"
-      /></label>
+      <label>Subtitles (.srt or .vtt) <input id="sub-file" type="file" /></label>
 
       <section id="make" hidden>
         <div class="row">
diff --git a/src/book-subtitles.ts b/src/book-subtitles.ts
new file mode 100644
index 0000000..899bfff
--- /dev/null
+++ b/src/book-subtitles.ts
@@ -0,0 +1,6 @@
+// The subtitles of a book. Pure: no DOM.
+
+/** The name of the .srt of a book: "Neko.pdf" gives "Neko.srt". */
+export function srtName(bookName: string): string {
+  return `${bookName.replace(/\.pdf$/i, '')}.srt`;
+}
diff --git a/src/main.ts b/src/main.ts
index 0775630..5809dfe 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -6,6 +6,7 @@
 // (the strip) and view.ts (the page).
 
 import { createAligner, type Aligner, type OcrToken, type TokenSpan } from './align';
+import { srtName } 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';
@@ -15,10 +16,14 @@ import { lookupText, tapTolerance, tokenAt } from './hit-test';
 import { lineBoxes, padBox } from './line-boxes';
 import {
   clockText,
+  EMPTY_FILE_TEXT,
   errorMessage,
+  matchedText,
+  noCuesText,
   notReadText,
   pagesReadText,
   pdfLoadedText,
+  pdfOpenText,
   playerMessage,
   readingText,
 } from './messages';
@@ -30,7 +35,7 @@ import { nextPage } from './read-order';
 import { scrollTarget } from './scroll';
 import { say, showPlayer, showReading } from './status';
 import { isAndroid, SubRead } from './subread';
-import { lastCueAt, parseSubtitles, type Cue } from './subtitles';
+import { decodeSubtitles, lastCueAt, parseSubtitles, type Cue } from './subtitles';
 import {
   bookKey,
   clearPages,
@@ -39,6 +44,7 @@ import {
   pageKey,
   putPage,
   putSrt,
+  type BookSubtitles,
   type PageEntry,
 } from './token-cache';
 import { createPageView, type Marks } from './view';
@@ -118,7 +124,8 @@ const state = {
   clock: null as ClockState | null,
   readSeq: 0,
   alignTimer: null as ReturnType<typeof setTimeout> | null,
-  srt: null as string | null,
+  /** The subtitles that are loaded, or null. */
+  subtitles: null as BookSubtitles | 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. */
@@ -212,37 +219,74 @@ function sayError(err: unknown): void {
 
 ui.openPdf.addEventListener('click', () => ui.pdfFile.click());
 
-ui.pdfFile.addEventListener('change', async () => {
+ui.pdfFile.addEventListener('change', () => {
   const file = ui.pdfFile.files?.[0];
+  // The same file can be picked again.
+  ui.pdfFile.value = '';
   if (!file) return;
   drawer.closeOnPhone();
+  void openBook(file);
+});
+
+/** The count of attempts to open a book, and the attempt of the book that is open. */
+const opens = { tried: 0, shown: 0 };
+
+/**
+ * Opens `file` as the book. The book that is open stays until the new
+ * file loads: a file that is not a PDF, or is damaged, changes nothing.
+ * Gives true when the new book is open.
+ */
+async function openBook(file: File): Promise<boolean> {
+  if (file.size === 0) {
+    say(EMPTY_FILE_TEXT);
+    return false;
+  }
+  const attempt = ++opens.tried;
+  let pdf: PdfDoc;
   try {
-    state.readSeq++;
-    await state.pdf?.destroy();
     // pdf.js loads with the first PDF, so the start card shows sooner.
     const { loadPdf } = await import('./pdf');
-    state.pdf = await loadPdf(await file.arrayBuffer());
-    state.file = file;
-    state.pages = [];
-    state.tokens = [];
-    state.spans = [];
-    state.cueParts = [];
-    state.activeCue = -1;
-    state.markedCue = -1;
-    state.held = false;
-    ui.makeSubs.disabled = false;
-    ui.empty.hidden = true;
-    ui.page.hidden = false;
-    if (state.clock) showPlayerStatus(state.clock);
-    say(pdfLoadedText(state.pdf.numPages));
-    await showPage(0);
-    const saved = await getSrt(bookKey(file));
-    if (saved) loadSubtitles(saved, 'the last SubRead run');
-    void readBook();
+    pdf = await loadPdf(await file.arrayBuffer());
   } catch (err) {
-    say(`Cannot open PDF: ${String(err)}`);
+    say(pdfOpenText(err));
+    return false;
   }
-});
+  // A file that the user picked later is open already.
+  if (attempt < opens.shown) {
+    void pdf.destroy();
+    return false;
+  }
+  opens.shown = attempt;
+  const old = state.pdf;
+  state.pdf = pdf;
+  state.file = file;
+  state.pages = [];
+  state.tokens = [];
+  state.spans = [];
+  state.cueParts = [];
+  state.activeCue = -1;
+  state.markedCue = -1;
+  state.held = false;
+  ui.makeSubs.disabled = false;
+  ui.empty.hidden = true;
+  ui.page.hidden = false;
+  if (state.clock) showPlayerStatus(state.clock);
+  say(pdfLoadedText(pdf.numPages));
+  // showPage sets the current page at once, so the reading starts there.
+  void showPage(0);
+  // readBook stops the reading of the old book before its document closes.
+  void readBook();
+  void old?.destroy();
+  showSrtRow();
+  const saved = await getSrt(bookKey(file));
+  if (saved && state.file === file) {
+    loadSubtitles(
+      { name: srtName(file.name), text: saved, source: 'subread' },
+      'the last SubRead run',
+    );
+  }
+  return true;
+}
 
 /** The tokens of one page: from the cache, else the text layer, else OCR. */
 async function readPage(
@@ -327,7 +371,7 @@ async function readBook(): Promise<void> {
     const matched = align();
     say(
       pagesReadText(counts) +
-        (matched === null ? '' : ` ${matched}/${state.cues.length} cues matched.`),
+        (matched === null ? '' : ` ${matchedText(matched, state.cues.length, true)}`),
     );
   } catch (err) {
     if (seq === state.readSeq) say(`Cannot read the pages: ${String(err)}`);
@@ -350,11 +394,19 @@ ui.clearCache.addEventListener('click', async () => {
 
 // --- Subtitles ---
 
-/** Load subtitles from a file or from SubRead. `source` names where they came from. */
-function loadSubtitles(text: string, source: string): void {
-  state.cues = parseSubtitles(text);
-  state.aligner = createAligner(state.cues.map((c) => c.text));
-  state.srt = text;
+/**
+ * Loads subtitles from a file or from SubRead. `from` names where they
+ * came from in the message. `cues` are the cues of the text, when the
+ * caller parsed them already.
+ */
+function loadSubtitles(
+  subtitles: BookSubtitles,
+  from = subtitles.name,
+  cues = parseSubtitles(subtitles.text),
+): void {
+  state.cues = cues;
+  state.aligner = createAligner(cues.map((c) => c.text));
+  state.subtitles = subtitles;
   state.spans = [];
   state.cueParts = [];
   state.activeCue = -1;
@@ -363,22 +415,51 @@ function loadSubtitles(text: string, source: string): void {
   renderCueList();
   const matched = align();
   say(
-    `${state.cues.length} cues loaded from ${source}.` +
-      (matched === null ? '' : ` ${matched} matched to the pages read so far.`),
+    `${cues.length} cues loaded from ${from}.` +
+      (matched === null ? '' : ` ${matchedText(matched, cues.length, allPagesRead())}`),
   );
-  offerSrt(text);
+  showSrtRow();
 }
 
+// A browser download of an .srt often has the type application/octet-stream,
+// and a type filter makes such a file grey in the Android picker. So the
+// field takes each file, and the parser decides.
 ui.subFile.addEventListener('change', async () => {
   const file = ui.subFile.files?.[0];
+  // The same file can be picked again.
+  ui.subFile.value = '';
   if (!file) return;
   drawer.closeOnPhone();
-  loadSubtitles(await file.text(), file.name);
+  if (file.size === 0) {
+    say(EMPTY_FILE_TEXT);
+    return;
+  }
+  let text: string;
+  try {
+    text = decodeSubtitles(await file.arrayBuffer());
+  } catch (err) {
+    say(`Cannot read ${file.name}: ${String(err)}`);
+    return;
+  }
+  // A file with no cues, for example a PDF, keeps the cues that are loaded.
+  const cues = parseSubtitles(text);
+  if (cues.length === 0) {
+    say(noCuesText(file.name));
+    return;
+  }
+  loadSubtitles({ name: file.name, text, source: 'file' }, file.name, cues);
 });
 
-function offerSrt(text: string): void {
-  const name = `${(state.file?.name ?? 'book').replace(/\.pdf$/i, '')}.srt`;
-  ui.srtRow.hidden = false;
+/**
+ * The .srt row shows for subtitles that SubRead made: a download on the
+ * web, the share sheet on Android. A file that the user loaded is on the
+ * device already.
+ */
+function showSrtRow(): void {
+  const subtitles = state.subtitles;
+  ui.srtRow.hidden = subtitles?.source !== 'subread';
+  if (!subtitles || ui.srtRow.hidden) return;
+  const { name, text } = subtitles;
   ui.srtDownload.hidden = isAndroid;
   ui.srtShare.hidden = !isAndroid;
   if (!isAndroid) {
@@ -431,7 +512,7 @@ ui.makeSubs.addEventListener('click', async () => {
       ? ' Under 80% usually means another edition or the wrong language.'
       : '');
   await putSrt(bookKey(file), result.srt);
-  loadSubtitles(result.srt, 'SubRead');
+  loadSubtitles({ name: srtName(file.name), text: result.srt, source: 'subread' }, 'SubRead');
 });
 
 // --- Alignment ---
@@ -628,7 +709,12 @@ async function showPage(index: number): Promise<void> {
   if (!pdf || index < 0 || index >= pdf.numPages) return;
   state.currentPage = index;
   nav.update();
-  await view.show(pdf, index);
+  try {
+    await view.show(pdf, index);
+  } catch (err) {
+    // A render of a book that closed meanwhile fails. That is no error.
+    if (state.pdf === pdf) sayError(err);
+  }
 }
 
 // --- Tap on a word ---
diff --git a/src/messages.ts b/src/messages.ts
index 3b68c07..7b6573b 100644
--- a/src/messages.ts
+++ b/src/messages.ts
@@ -84,3 +84,37 @@ export function pagesReadText(counts: { text: number; ocr: number }): string {
 export function notReadText(read: number, total: number): string {
   return `This page is not read yet (${read}/${total} read).`;
 }
+
+/** A picked file of 0 bytes, for example a file that another app still writes. */
+export const EMPTY_FILE_TEXT = 'The file is empty or not ready. Try again in a moment.';
+
+/** The message when pdf.js cannot open a file. */
+export function pdfOpenText(err: unknown): string {
+  if (err instanceof Error && err.name === 'InvalidPDFException') {
+    return 'This file is not a PDF, or it is damaged.';
+  }
+  return `Cannot open the PDF: ${String(err)}`;
+}
+
+/** The message for a subtitle file with no cues, for example a PDF. */
+export function noCuesText(name: string): string {
+  return `No subtitle lines in ${name}. Choose an .srt or .vtt file.`;
+}
+
+/**
+ * When all pages are read and less than this share of the cues is
+ * matched, the subtitles are for another text or another language.
+ */
+export const MISMATCH_SHARE = 0.3;
+
+export const MISMATCH_TEXT = 'These subtitles do not match this book or the OCR language.';
+
+/**
+ * The count of matched cues. When all pages are read and less than
+ * MISMATCH_SHARE of the cues is matched, the hint follows.
+ */
+export function matchedText(matched: number, cues: number, allRead: boolean): string {
+  if (!allRead) return `${matched}/${cues} cues matched to the pages read so far.`;
+  const text = `${matched}/${cues} cues matched.`;
+  return cues > 0 && matched / cues < MISMATCH_SHARE ? `${text} ${MISMATCH_TEXT}` : text;
+}
diff --git a/src/view.ts b/src/view.ts
index 57e342b..f2ca041 100644
--- a/src/view.ts
+++ b/src/view.ts
@@ -168,7 +168,8 @@ export function createPageView(
       const dpr = window.devicePixelRatio || 1;
       const width = ui.page.clientWidth;
       if (width > 0 && (Math.abs(width - shown.cssWidth) >= 1 || dpr !== shown.dpr)) {
-        void render();
+        // A render of a closed document fails. The stretched canvas stays.
+        render().catch(() => undefined);
       }
     }, RESIZE_MS);
   }).observe(ui.viewer);
diff --git a/test/book-subtitles.test.ts b/test/book-subtitles.test.ts
new file mode 100644
index 0000000..962a237
--- /dev/null
+++ b/test/book-subtitles.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest';
+import { srtName } from '../src/book-subtitles';
+
+describe('srtName', () => {
+  it('puts .srt in place of .pdf', () => {
+    expect(srtName('sample-eng-2p.pdf')).toBe('sample-eng-2p.srt');
+    expect(srtName('Neko.PDF')).toBe('Neko.srt');
+    expect(srtName('吾輩は猫である.pdf')).toBe('吾輩は猫である.srt');
+  });
+
+  it('adds .srt to a name without .pdf at the end', () => {
+    expect(srtName('book')).toBe('book.srt');
+    expect(srtName('a.pdf.txt')).toBe('a.pdf.txt.srt');
+  });
+});
diff --git a/test/messages.test.ts b/test/messages.test.ts
index c0138ef..cd8a46b 100644
--- a/test/messages.test.ts
+++ b/test/messages.test.ts
@@ -1,12 +1,18 @@
+import fc from 'fast-check';
 import { describe, expect, it } from 'vitest';
 import { PlayerError, type ClockState } from '../src/clock-source';
 import {
   clockText,
   errorMessage,
+  matchedText,
+  MISMATCH_SHARE,
+  MISMATCH_TEXT,
+  noCuesText,
   notReadText,
   OVERLAY_RELEASES,
   pagesReadText,
   pdfLoadedText,
+  pdfOpenText,
   playerMessage,
   playerProblem,
   readingText,
@@ -98,3 +104,58 @@ describe('page texts', () => {
     expect(notReadText(3, 40)).toBe('This page is not read yet (3/40 read).');
   });
 });
+
+/** The error of pdf.js 6 for a file that is not a PDF: a PNG, an .srt, or a cut PDF. */
+function invalidPdf(): Error {
+  const err = new Error('Invalid PDF structure.');
+  err.name = 'InvalidPDFException';
+  return err;
+}
+
+describe('file texts', () => {
+  it('says that a file is not a PDF, with no exception text', () => {
+    expect(pdfOpenText(invalidPdf())).toBe('This file is not a PDF, or it is damaged.');
+  });
+
+  it('gives another error of pdf.js as it is', () => {
+    expect(pdfOpenText(new Error('Worker was destroyed'))).toBe(
+      'Cannot open the PDF: Error: Worker was destroyed',
+    );
+  });
+
+  it('names the subtitle file with no cues', () => {
+    expect(noCuesText('sample-eng.pdf')).toBe(
+      'No subtitle lines in sample-eng.pdf. Choose an .srt or .vtt file.',
+    );
+  });
+});
+
+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.');
+  });
+
+  it('adds the hint when all pages are read and less than 30% of the cues match', () => {
+    // sample-jpn.srt on the English PDF.
+    expect(matchedText(0, 3, true)).toBe(`0/3 cues matched. ${MISMATCH_TEXT}`);
+    expect(matchedText(4, 15, true)).toBe(`4/15 cues matched. ${MISMATCH_TEXT}`);
+    expect(matchedText(5, 15, true)).toBe('5/15 cues matched.');
+    expect(matchedText(15, 15, true)).toBe('15/15 cues matched.');
+  });
+
+  it('gives the hint exactly for all pages read and less than 30% matched', () => {
+    expect(MISMATCH_SHARE).toBe(0.3);
+    // 3 of 10 is 30%: no hint. In floating point 0.3 * 10 is more than 3.
+    expect(matchedText(3, 10, true)).toBe('3/10 cues matched.');
+    const counts = fc
+      .tuple(fc.nat({ max: 500 }), fc.nat({ max: 500 }))
+      .map(([a, b]) => ({ matched: Math.min(a, b), cues: Math.max(a, b) }));
+    fc.assert(
+      fc.property(counts, fc.boolean(), ({ matched, cues }, allRead) => {
+        const hint = matchedText(matched, cues, allRead).endsWith(MISMATCH_TEXT);
+        // Whole numbers: 10 * matched < 3 * cues is exact.
+        expect(hint).toBe(allRead && 10 * matched < 3 * cues);
+      }),
+    );
+  });
+});