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 c2ee4924b6fb176f9de07c035e57a57c3b54a1d8
truex <truex@equwal.com>
2026-09-26 13:38:01 -0700

Read the pages after the current page first

The reader read the current page, the page after it, the page before
it, and then all pages from page 1. A reader who opened the book at
page 150 waited for pages 1 to 149 before the pages that the narration
comes to next.

nextPage in read-order.ts now gives the current page, then the pages
after it up to the end, then the pages before it, nearest first. When
the user turns pages during the read, the order starts again from the
new current page.

 README.md               |  5 ++--
 src/main.ts             | 19 +++++--------
 src/read-order.ts       | 17 ++++++++++++
 test/read-order.test.ts | 71 +++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 98 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index 13ae4de..70499eb 100644
--- a/README.md
+++ b/README.md
@@ -25,8 +25,8 @@ open.
 1. Open the menu (the "Menu" button on a phone; the left panel on a
    desktop).
 2. Load a PDF. The app reads the pages at once: the current page first,
-   then its neighbors, then the rest. "Pages" in the menu shows how far
-   it is.
+   then the pages after it to the end, then the pages before it. "Pages"
+   in the menu shows how far it is.
 3. Load the subtitles, or on Android press "Make subtitles with SubRead":
    pick the audiobook, and SubRead makes the `.srt` from the audio and
    the text of the pages read so far. The result loads at once, is kept
@@ -278,6 +278,7 @@ in a browser bundle.
   lookup text.
 - `src/line-boxes.ts`: pure boxes that mark a cue, one for each text
   line of the page.
+- `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/subread.ts`: the typed side of the plugin, with the web fallback.
 - `src/token-cache.ts`: IndexedDB store for the page tokens and the
diff --git a/src/main.ts b/src/main.ts
index 64f58eb..3940b08 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,5 +1,6 @@
 // UI wiring. The logic lives in the pure modules: align.ts, follower.ts,
-// hit-test.ts, line-boxes.ts, text-layer.ts, player-state.ts, play-clock.ts.
+// hit-test.ts, line-boxes.ts, read-order.ts, text-layer.ts, player-state.ts,
+// play-clock.ts.
 
 import { createAligner, type Aligner, type OcrToken, type TokenSpan } from './align';
 import { bookText } from './book-text';
@@ -9,6 +10,7 @@ import { lookupText, tokenAt } from './hit-test';
 import { lineBoxes } from './line-boxes';
 import { createOcr, type Ocr } from './ocr';
 import { loadPdf, type PdfDoc } from './pdf';
+import { nextPage } from './read-order';
 import { isAndroid, SubRead } from './subread';
 import { cueIndexAt, parseSubtitles, type Cue } from './subtitles';
 import {
@@ -209,13 +211,6 @@ ui.pdfFile.addEventListener('change', async () => {
   }
 });
 
-/** The next page to read: the current page, its neighbors, then the rest in order. */
-function nextPage(pending: Set<number>): number {
-  const current = state.currentPage;
-  for (const p of [current, current + 1, current - 1]) if (pending.has(p)) return p;
-  return Math.min(...pending);
-}
-
 /** The tokens of one page: from the cache, else the text layer, else OCR. */
 async function readPage(
   pdf: PdfDoc,
@@ -247,9 +242,9 @@ async function readPage(
 }
 
 /**
- * Reads every page of the book: the current page first, then its
- * neighbors, then the rest. The cues are aligned again as pages finish,
- * so the follow starts before the whole book is read.
+ * 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.
  */
 async function readBook(): Promise<void> {
   const { pdf, file } = state;
@@ -274,7 +269,7 @@ async function readBook(): Promise<void> {
   const counts = { text: 0, ocr: 0 };
   try {
     while (pending.size > 0) {
-      const page = nextPage(pending);
+      const page = nextPage(pending, state.currentPage);
       pending.delete(page);
       ui.progress.hidden = false;
       ui.progress.value = 0;
diff --git a/src/read-order.ts b/src/read-order.ts
new file mode 100644
index 0000000..425aece
--- /dev/null
+++ b/src/read-order.ts
@@ -0,0 +1,17 @@
+// The order in which the reader reads the pages of a book. Pure.
+
+/**
+ * The next page to read: the current page, then the pages after it up to
+ * the end, then the pages before it, nearest first. The narration goes on
+ * from the current page, so the pages after it are necessary first.
+ * `pending` holds the pages that are not read yet. -1 when it is empty.
+ */
+export function nextPage(pending: ReadonlySet<number>, current: number): number {
+  let after = Infinity;
+  let before = -1;
+  for (const page of pending) {
+    if (page >= current) after = Math.min(after, page);
+    else before = Math.max(before, page);
+  }
+  return after < Infinity ? after : before;
+}
diff --git a/test/read-order.test.ts b/test/read-order.test.ts
new file mode 100644
index 0000000..018810d
--- /dev/null
+++ b/test/read-order.test.ts
@@ -0,0 +1,71 @@
+import fc from 'fast-check';
+import { describe, expect, it } from 'vitest';
+import { nextPage } from '../src/read-order';
+
+/** The whole numbers from `from` up to, but not including, `to`. */
+function range(from: number, to: number): number[] {
+  return Array.from({ length: Math.max(0, to - from) }, (_, i) => from + i);
+}
+
+/** All pages of a book of `count` pages, in the order of nextPage, while the current page stays. */
+function readAll(count: number, current: number): number[] {
+  const pending = new Set(range(0, count));
+  const order: number[] = [];
+  while (pending.size > 0) {
+    const page = nextPage(pending, current);
+    pending.delete(page);
+    order.push(page);
+  }
+  return order;
+}
+
+describe('nextPage', () => {
+  it('reads the current page, then the pages after it, then the pages before it', () => {
+    expect(readAll(6, 2)).toEqual([2, 3, 4, 5, 1, 0]);
+  });
+
+  it('gives -1 when all pages are read', () => {
+    expect(nextPage(new Set(), 3)).toBe(-1);
+  });
+
+  it('reads every page once: from the current page to the end, then back to the start', () => {
+    const book = fc
+      .integer({ min: 1, max: 60 })
+      .chain((count) => fc.tuple(fc.constant(count), fc.nat({ max: count - 1 })));
+    fc.assert(
+      fc.property(book, ([count, current]) => {
+        expect(readAll(count, current)).toEqual([
+          ...range(current, count),
+          ...range(0, current).reverse(),
+        ]);
+      }),
+    );
+  });
+
+  it('follows the current page when the user turns pages during the read', () => {
+    // At each step the user can be on another page.
+    const read = fc
+      .integer({ min: 1, max: 40 })
+      .chain((count) =>
+        fc.tuple(
+          fc.constant(count),
+          fc.array(fc.nat({ max: count - 1 }), { minLength: count, maxLength: count }),
+        ),
+      );
+    fc.assert(
+      fc.property(read, ([count, currents]) => {
+        const pending = new Set(range(0, count));
+        const order: number[] = [];
+        for (const current of currents) {
+          const page = nextPage(pending, current);
+          // The nearest page at or after the current page, else the nearest before it.
+          const after = [...pending].filter((p) => p >= current);
+          expect(page).toBe(after.length > 0 ? Math.min(...after) : Math.max(...pending));
+          pending.delete(page);
+          order.push(page);
+        }
+        expect(order.sort((x, y) => x - y)).toEqual(range(0, count));
+      }),
+    );
+  });
+});