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 b4a7e3760a4480405ec51a129a6a96cb5bc77567
truex <truex@equwal.com>
2026-09-26 23:15:15 -0700

Keep a subtitle file for the book that loads, not the book before

Book P is open with its subtitles. The user picks a large PDF A, and
loads the .srt S of A before A shows. loadSubtitles gave S to
state.book, which was still P, and saved S in the meta data of P, over
the subtitles of P: a SubRead result of P was lost for good. When A
showed, openBook saw that S belonged to P and removed it.

book-open.ts now keeps the open attempts (Opens): the attempts that
still load, and the attempt of the book that shows. subtitlesOwner
gives a subtitle file to no book while an open loads that can replace
the book that shows. claimSubtitles gives such subtitles to the book
that shows when no open loads: the new book, or the book that stays
open when the new file does not open. An older open that cannot show
over the new book does not hold the subtitles. SubRead results and the
saved subtitles of a book still belong to that book.

A property test runs random scripts of opens: the book of the last
attempt that loaded shows, whatever the order of the loads.

 README.md              |   6 ++-
 src/book-open.ts       |  57 +++++++++++++++++++++
 src/main.ts            |  60 ++++++++++++++--------
 test/book-open.test.ts | 136 +++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 238 insertions(+), 21 deletions(-)
diff --git a/README.md b/README.md
index 5bd0343..34f6cc8 100644
--- a/README.md
+++ b/README.md
@@ -61,7 +61,9 @@ the start card.
 
 Subtitles belong to a book. Another book opens with its own subtitles,
 or with none. Subtitles that you load while no book is open are for the
-book that you open next. The page goes to the meta data of the book one
+book that you open next. Subtitles that you load while a new book loads
+are for that book. When the new book does not open, they are for the
+book that stays open. The page goes to the meta data of the book one
 second after it changes.
 
 The first start after an upgrade from a build with the old dictionary
@@ -468,6 +470,8 @@ in a browser bundle.
 - `src/messages.ts`: pure texts of the status strip.
 - `src/read-order.ts`: pure order in which the pages are read.
 - `src/book-text.ts`: pure tokens to the book text for SubRead.
+- `src/book-open.ts`: pure rules for the open of a book: the open
+  attempts, and the book of subtitles that load during an open.
 - `src/book-subtitles.ts`: pure rules for the subtitles of a book: the
   subtitles when a book opens, the result file name for SubRead, and the
   checks before a SubRead result loads.
diff --git a/src/book-open.ts b/src/book-open.ts
new file mode 100644
index 0000000..ad0249c
--- /dev/null
+++ b/src/book-open.ts
@@ -0,0 +1,57 @@
+// The open of a book: the open attempts, and the owner of subtitles that
+// load during an open. Pure: no DOM.
+
+/**
+ * The open attempts of books. Each attempt gets the next number: 1, 2, 3 ...
+ * A large PDF can load for many seconds, so the user can pick another book
+ * while one loads. The book of the last attempt that loads shows.
+ */
+export interface Opens {
+  /** The number of the last attempt. */
+  tried: number;
+  /** The attempt of the book that shows, or 0. */
+  shown: number;
+  /** The attempts that still load. */
+  loading: Set<number>;
+}
+
+export function createOpens(): Opens {
+  return { tried: 0, shown: 0, loading: new Set() };
+}
+
+/** Starts an attempt, and gives its number. */
+export function beginOpen(o: Opens): number {
+  const attempt = ++o.tried;
+  o.loading.add(attempt);
+  return attempt;
+}
+
+/**
+ * Ends an attempt. `loaded` is true when its file loaded as a PDF. Gives
+ * true when the book of the attempt shows now: it loaded, and no later
+ * attempt shows a book.
+ */
+export function endOpen(o: Opens, attempt: number, loaded: boolean): boolean {
+  o.loading.delete(attempt);
+  if (!loaded || attempt < o.shown) return false;
+  o.shown = attempt;
+  return true;
+}
+
+/** True while an attempt loads that can replace the book that shows: an attempt after it. */
+export function opening(o: Opens): boolean {
+  for (const attempt of o.loading) if (attempt > o.shown) return true;
+  return false;
+}
+
+/**
+ * The book of subtitles that the user loads now, when `book` shows. While an
+ * open loads that can replace `book`, the subtitles belong to no book yet:
+ * they are for the book that shows when the opens end, the new book or, when
+ * its open fails, `book`. Before, a subtitle file for the new book went into
+ * the meta data of the book before it, over its own subtitles, and the new
+ * book did not get it.
+ */
+export function subtitlesOwner(o: Opens, book: string | null): string | null {
+  return opening(o) ? null : book;
+}
diff --git a/src/main.ts b/src/main.ts
index cdacad6..51f56cd 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -9,6 +9,7 @@ import { App } from '@capacitor/app';
 import { createAligner, shiftSpans, type Aligner, type OcrToken, type TokenSpan } from './align';
 import { ankiCard, cardSource } from './anki-card';
 import { backStep, keepScreenOn } from './app-state';
+import { beginOpen, createOpens, endOpen, subtitlesOwner } from './book-open';
 import { resultName, srtName, subreadConcerns, subtitlesOnOpen } from './book-subtitles';
 import { bookText } from './book-text';
 import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from './clock-source';
@@ -304,8 +305,8 @@ ui.pdfFile.addEventListener('change', () => {
   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 };
+/** The attempts to open a book, and the attempt of the book that is open. */
+const opens = createOpens();
 
 /**
  * Opens `file` as the book. The book that is open stays until the new
@@ -322,11 +323,13 @@ async function openBook(file: File, meta?: BookMeta): Promise<boolean> {
     say(EMPTY_FILE_TEXT);
     return false;
   }
-  const attempt = ++opens.tried;
+  const attempt = beginOpen(opens);
   let bytes: ArrayBuffer;
   try {
     bytes = await file.arrayBuffer();
   } catch (err) {
+    endOpen(opens, attempt, false);
+    claimSubtitles();
     // The copy of the last book is gone, for example after the storage was cleared.
     say(meta ? LAST_BOOK_GONE_TEXT : `Cannot read ${file.name}: ${String(err)}`);
     return false;
@@ -337,15 +340,17 @@ async function openBook(file: File, meta?: BookMeta): Promise<boolean> {
     const { loadPdf } = await import('./pdf');
     pdf = await loadPdf(bytes);
   } catch (err) {
+    endOpen(opens, attempt, false);
+    claimSubtitles();
     say(pdfOpenText(err));
     return false;
   }
   // A file that the user picked later is open already.
-  if (attempt < opens.shown) {
+  if (!endOpen(opens, attempt, true)) {
     void pdf.destroy();
+    claimSubtitles();
     return false;
   }
-  opens.shown = attempt;
   const key = bookKey(file);
   // The page of the book before goes to its meta data now.
   flushPageSave();
@@ -360,11 +365,12 @@ async function openBook(file: File, meta?: BookMeta): Promise<boolean> {
   state.activeCue = -1;
   state.markedCue = -1;
   state.held = false;
-  // The subtitles of another book go at once. Subtitles that were loaded
-  // while no book was open wait for the meta data of this book.
+  // The subtitles of another book go at once. Subtitles of no book, loaded
+  // while no book was open or while this book loaded, are for this book.
   if (state.subtitles && state.subtitlesBook !== null && state.subtitlesBook !== key) {
     clearSubtitles();
   }
+  claimSubtitles();
   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) {
@@ -425,13 +431,10 @@ function applyMeta(meta: BookMeta, attempt: number, subtitlesSeq: number): void
     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 });
-        }
+        claimSubtitles();
         break;
       case 'load':
-        if (meta.subtitles) loadSubtitles(meta.subtitles, { save: false });
+        if (meta.subtitles) loadSubtitles(meta.subtitles, { book: key, save: false });
         break;
       case 'clear':
         clearSubtitles();
@@ -706,24 +709,26 @@ ui.clearAll.addEventListener('click', async () => {
 
 /**
  * Loads subtitles from a file, from SubRead or from the meta data of the
- * book. They belong to the open book, or to the book that opens next.
+ * book.
  *
+ * - `book` is the book that they belong to. Null: no book yet, and the
+ *   book that shows when no open loads gets them (claimSubtitles).
  * - `from` names where they came from in the message.
  * - `cues` are the cues of the text, when the caller parsed them already.
- * - `save` keeps them in the meta data of the open book. The default is
- *   true: each file and each SubRead result is kept.
+ * - `save` keeps them in the meta data of `book`. The default is true:
+ *   each file and each SubRead result is kept.
  */
 function loadSubtitles(
   subtitles: BookSubtitles,
-  opts: { from?: string; cues?: Cue[]; save?: boolean } = {},
+  opts: { book: string | null; from?: string; cues?: Cue[]; save?: boolean },
 ): void {
   const cues = opts.cues ?? parseSubtitles(subtitles.text);
   state.cues = cues;
   state.aligner = createAligner(cues.map((c) => c.text));
   state.subtitles = subtitles;
-  state.subtitlesBook = state.book;
+  state.subtitlesBook = opts.book;
   state.subtitlesSeq++;
-  if (opts.save !== false && state.book !== null) void putMeta(state.book, { subtitles });
+  if (opts.save !== false && opts.book !== null) void putMeta(opts.book, { subtitles });
   state.spans = [];
   state.cueParts = [];
   state.activeCue = -1;
@@ -738,6 +743,19 @@ function loadSubtitles(
   showSrtRow();
 }
 
+/**
+ * Gives subtitles of no book to the book that shows, and keeps them in its
+ * meta data. While an open loads that can replace that book, the subtitles
+ * wait (subtitlesOwner). Each end of an open, and the meta data of the new
+ * book, call this again.
+ */
+function claimSubtitles(): void {
+  const book = subtitlesOwner(opens, state.book);
+  if (!state.subtitles || state.subtitlesBook !== null || book === null) return;
+  state.subtitlesBook = book;
+  void putMeta(book, { subtitles: state.subtitles });
+}
+
 /** Removes the subtitles: the cues, the cue list, the mark, the .srt row. */
 function clearSubtitles(): void {
   state.cues = [];
@@ -782,7 +800,9 @@ ui.subFile.addEventListener('change', async () => {
     say(noCuesText(file.name));
     return;
   }
-  loadSubtitles({ name: file.name, text, source: 'file' }, { cues });
+  // During the open of another book, the file is for the book that shows next.
+  const book = subtitlesOwner(opens, state.book);
+  loadSubtitles({ name: file.name, text, source: 'file' }, { book, cues });
 });
 
 /**
@@ -914,7 +934,7 @@ function offerSubreadSubtitles(
     say('The subtitles of SubRead are not loaded.');
     return false;
   }
-  loadSubtitles(subreadSubtitles(book, srt), { from: 'SubRead' });
+  loadSubtitles(subreadSubtitles(book, srt), { book, from: 'SubRead' });
   return true;
 }
 
diff --git a/test/book-open.test.ts b/test/book-open.test.ts
new file mode 100644
index 0000000..6241e83
--- /dev/null
+++ b/test/book-open.test.ts
@@ -0,0 +1,136 @@
+import fc from 'fast-check';
+import { describe, expect, it } from 'vitest';
+import {
+  beginOpen,
+  createOpens,
+  endOpen,
+  opening,
+  subtitlesOwner,
+  type Opens,
+} from '../src/book-open';
+
+/** One step of a script of opens: start the next attempt, or end one that loads. */
+interface Step {
+  begin: boolean;
+  /** The attempt that ends, when `begin` is false. */
+  attempt: number;
+  loaded: boolean;
+}
+
+/**
+ * Scripts of up to 6 attempts. Each attempt starts in number order and ends
+ * after its start, in any order, with or without a loaded PDF.
+ */
+const scripts: fc.Arbitrary<Step[]> = fc
+  .record({
+    count: fc.integer({ min: 1, max: 6 }),
+    loaded: fc.array(fc.boolean(), { minLength: 6, maxLength: 6 }),
+    picks: fc.array(fc.nat(), { minLength: 12, maxLength: 12 }),
+  })
+  .map(({ count, loaded, picks }) => {
+    const steps: Step[] = [];
+    const loading: number[] = [];
+    let begun = 0;
+    for (const pick of picks) {
+      if (begun < count && (loading.length === 0 || pick % 2 === 0)) {
+        loading.push(++begun);
+        steps.push({ begin: true, attempt: begun, loaded: false });
+      } else if (loading.length > 0) {
+        const [attempt] = loading.splice(pick % loading.length, 1);
+        steps.push({ begin: false, attempt: attempt!, loaded: loaded[attempt! - 1]! });
+      }
+    }
+    // The attempts that still load end in their order.
+    for (const attempt of loading) {
+      steps.push({ begin: false, attempt, loaded: loaded[attempt - 1]! });
+    }
+    return steps;
+  });
+
+describe('open attempts', () => {
+  it('shows the book of the last attempt that loaded, whatever the order of the loads', () => {
+    fc.assert(
+      fc.property(scripts, (steps) => {
+        const o = createOpens();
+        let lastLoaded = 0;
+        for (const step of steps) {
+          const before = o.shown;
+          if (step.begin) {
+            expect(beginOpen(o)).toBe(step.attempt);
+          } else {
+            const shows = endOpen(o, step.attempt, step.loaded);
+            // An attempt shows when it loaded and no later attempt shows.
+            expect(shows).toBe(step.loaded && step.attempt > before);
+            if (step.loaded) lastLoaded = Math.max(lastLoaded, step.attempt);
+          }
+          // The book that shows never goes back to an older attempt.
+          expect(o.shown).toBeGreaterThanOrEqual(before);
+          // An open loads that can replace the book that shows.
+          expect(opening(o)).toBe([...o.loading].some((a) => a > o.shown));
+        }
+        expect(o.shown).toBe(lastLoaded);
+        expect(opening(o)).toBe(false);
+      }),
+    );
+  });
+});
+
+/** Opens where book P shows, and the attempt of P. */
+function withP(): { o: Opens; p: number } {
+  const o = createOpens();
+  const p = beginOpen(o);
+  endOpen(o, p, true);
+  return { o, p };
+}
+
+describe('subtitlesOwner', () => {
+  it('gives subtitles that load during the open of another book to no book', () => {
+    // The finding: book P is open, the user picks a large PDF A, and loads
+    // the .srt of A before A shows. The .srt went into the meta data of P,
+    // over the subtitles of P, and the open of A then cleared it.
+    const { o } = withP();
+    expect(subtitlesOwner(o, 'P')).toBe('P');
+    const a = beginOpen(o);
+    expect(subtitlesOwner(o, 'P')).toBeNull();
+    expect(endOpen(o, a, true)).toBe(true);
+    expect(subtitlesOwner(o, 'A')).toBe('A');
+  });
+
+  it('gives them to the book that stays open when the open fails', () => {
+    const { o } = withP();
+    const a = beginOpen(o);
+    expect(subtitlesOwner(o, 'P')).toBeNull();
+    expect(endOpen(o, a, false)).toBe(false);
+    expect(subtitlesOwner(o, 'P')).toBe('P');
+  });
+
+  it('does not wait for an older open that cannot show', () => {
+    // The user picks a large A, then a small B. B shows while A loads.
+    const o = createOpens();
+    const a = beginOpen(o);
+    const b = beginOpen(o);
+    expect(endOpen(o, b, true)).toBe(true);
+    expect(subtitlesOwner(o, 'B')).toBe('B');
+    expect(endOpen(o, a, true)).toBe(false);
+  });
+
+  it('gives no book while no book is open', () => {
+    const o = createOpens();
+    expect(subtitlesOwner(o, null)).toBeNull();
+    beginOpen(o);
+    expect(subtitlesOwner(o, null)).toBeNull();
+  });
+
+  it('gives no book exactly while an open loads that can replace the book', () => {
+    fc.assert(
+      fc.property(scripts, (steps) => {
+        const o = createOpens();
+        for (const step of steps) {
+          if (step.begin) beginOpen(o);
+          else endOpen(o, step.attempt, step.loaded);
+          expect(subtitlesOwner(o, 'book')).toBe(opening(o) ? null : 'book');
+        }
+      }),
+    );
+  });
+});