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 94fd37a8ec448e86c14ffc5440620623d427272e
truex <truex@equwal.com>
2026-09-26 13:32:57 -0700

Remove the spans when the pages are read again

A change of the OCR language or of "Force OCR" reads the pages again.
readBook removed the tokens but kept the spans. drawBoxes then read
tokens past the end of the new, shorter token list, and threw a
TypeError on token.page.

readBook now removes the spans, the cue pages and the marked cue with
the tokens. The box loop of drawBoxes moves to lineBoxes in
line-boxes.ts, a pure function with tests. It skips a token index past
the end of the token list.

 README.md               |  2 ++
 src/line-boxes.ts       | 28 ++++++++++++++++++++++++++++
 src/main.ts             | 23 +++++++----------------
 test/line-boxes.test.ts | 38 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 75 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index d536fd8..73d309d 100644
--- a/README.md
+++ b/README.md
@@ -274,6 +274,8 @@ in a browser bundle.
 - `src/subtitles.ts`: pure SRT/VTT parser and active-cue lookup.
 - `src/hit-test.ts`: pure tap on the page. Hit test, scan string, the
   lookup text.
+- `src/line-boxes.ts`: pure boxes that mark a cue, one for each text
+  line of the page.
 - `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/line-boxes.ts b/src/line-boxes.ts
new file mode 100644
index 0000000..19b6f35
--- /dev/null
+++ b/src/line-boxes.ts
@@ -0,0 +1,28 @@
+// The boxes that mark a cue on the page. Pure: no DOM.
+
+import type { OcrToken, TokenSpan } from './align';
+
+export type Box = OcrToken['bbox'];
+
+/**
+ * One box for each text line of `span` on `page`: the union of the boxes
+ * of its tokens on that line. Tokens on other pages do not count. A span
+ * of an older alignment can go past the end of `tokens`: those indices
+ * do not count either.
+ */
+export function lineBoxes(tokens: readonly OcrToken[], span: TokenSpan, page: number): Box[] {
+  const lines = new Map<number, Box>();
+  for (let t = span.start; t < span.end; t++) {
+    const token = tokens[t];
+    if (!token || token.page !== page) continue;
+    const box = lines.get(token.line);
+    if (!box) lines.set(token.line, { ...token.bbox });
+    else {
+      box.x0 = Math.min(box.x0, token.bbox.x0);
+      box.y0 = Math.min(box.y0, token.bbox.y0);
+      box.x1 = Math.max(box.x1, token.bbox.x1);
+      box.y1 = Math.max(box.y1, token.bbox.y1);
+    }
+  }
+  return [...lines.values()];
+}
diff --git a/src/main.ts b/src/main.ts
index 0273d22..3f8ce7b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,11 +1,12 @@
 // UI wiring. The logic lives in the pure modules: align.ts, follower.ts,
-// hit-test.ts, text-layer.ts, player-state.ts, play-clock.ts.
+// hit-test.ts, line-boxes.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';
 import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from './clock-source';
 import { follow, type FollowMode } from './follower';
 import { lookupText, tokenAt } from './hit-test';
+import { lineBoxes } from './line-boxes';
 import { createOcr, type Ocr } from './ocr';
 import { loadPdf, type PdfDoc } from './pdf';
 import { isAndroid, SubRead } from './subread';
@@ -256,6 +257,10 @@ async function readBook(): Promise<void> {
   const seq = ++state.readSeq;
   state.pages = [];
   state.tokens = [];
+  // The spans index the old tokens, so they go too.
+  state.spans = [];
+  state.cuePages = [];
+  state.markedCue = -1;
   ui.pagesStatus.textContent = `Pages: 0/${pdf.numPages} read.`;
   const pending = new Set(Array.from({ length: pdf.numPages }, (_, i) => i));
   // The OCR worker starts on the first page that needs it.
@@ -545,21 +550,7 @@ function drawBoxes(): void {
   const span = state.spans[state.markedCue];
   const size = state.pages[state.currentPage];
   if (!span || !size) return;
-
-  const lines = new Map<number, OcrToken['bbox']>();
-  for (let t = span.start; t < span.end; t++) {
-    const token = state.tokens[t]!;
-    if (token.page !== state.currentPage) continue;
-    const box = lines.get(token.line);
-    if (!box) lines.set(token.line, { ...token.bbox });
-    else {
-      box.x0 = Math.min(box.x0, token.bbox.x0);
-      box.y0 = Math.min(box.y0, token.bbox.y0);
-      box.x1 = Math.max(box.x1, token.bbox.x1);
-      box.y1 = Math.max(box.y1, token.bbox.y1);
-    }
-  }
-  for (const box of lines.values()) {
+  for (const box of lineBoxes(state.tokens, span, state.currentPage)) {
     const div = document.createElement('div');
     div.className = 'box';
     div.style.left = `${(100 * box.x0) / size.width}%`;
diff --git a/test/line-boxes.test.ts b/test/line-boxes.test.ts
new file mode 100644
index 0000000..1171dc5
--- /dev/null
+++ b/test/line-boxes.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import type { OcrToken } from '../src/align';
+import { lineBoxes } from '../src/line-boxes';
+
+function token(page: number, line: number, x0: number, y0: number): OcrToken {
+  return { text: 'a', page, line, bbox: { x0, y0, x1: x0 + 10, y1: y0 + 20 } };
+}
+
+const tokens = [
+  token(0, 0, 0, 0),
+  token(0, 0, 10, 2),
+  token(0, 1, 0, 30),
+  token(1, 100000, 0, 0),
+  token(1, 100000, 10, 0),
+];
+
+describe('lineBoxes', () => {
+  it('gives one box for each line, the union of its token boxes', () => {
+    expect(lineBoxes(tokens, { start: 0, end: 3, matched: true }, 0)).toEqual([
+      { x0: 0, y0: 0, x1: 20, y1: 22 },
+      { x0: 0, y0: 30, x1: 10, y1: 50 },
+    ]);
+  });
+
+  it('leaves out the tokens on other pages', () => {
+    expect(lineBoxes(tokens, { start: 2, end: 5, matched: true }, 1)).toEqual([
+      { x0: 0, y0: 0, x1: 20, y1: 20 },
+    ]);
+  });
+
+  it('skips token indices past the end of the tokens', () => {
+    // A span from the alignment before the pages were read again: the
+    // token list is now shorter than the span.
+    expect(lineBoxes(tokens.slice(0, 2), { start: 0, end: 5, matched: true }, 0)).toEqual([
+      { x0: 0, y0: 0, x1: 20, y1: 22 },
+    ]);
+  });
+});