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


README.md (27111 bytes)

1 # Scan Subread
2 
3 A PDF reader that follows the audiobook. It works with scans and with
4 text PDFs, in a browser and as an Android app, and it is a member of the
5 SubRead suite:
6 
7 - [SubRead Overlay](https://github.com/equwal/subread-overlay) reads the
8   position of the audiobook player and gives it to this app. Install it
9   and allow notification access.
10 - [SubRead Dictionary](https://github.com/equwal/subread-dictionary), or
11   any app in the text selection menu (Takoboto, AnkiDroid, a translator),
12   looks the words up.
13 - [SubRead](https://github.com/equwal/subread-android) makes the subtitle
14   file (`.srt`) from the audiobook and the text of the book.
15 - [SubRead Anki](https://github.com/equwal/subread-anki) makes an Anki
16   card from a word and its sentence.
17 
18 You load a PDF and the `.srt` of its audiobook. The app reads the text
19 of each page, aligns each subtitle cue to it, and follows the player: it
20 marks the line that the narrator reads and turns the page. Tap a word,
21 and the text from that word to the end of the line goes to the
22 dictionary of your choice. Press a word for half a second, and SubRead
23 Anki makes a card. The player pauses while the dictionary or the card
24 is open.
25 
26 <p>
27   <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="200" alt="A text PDF: the line that the narrator reads is marked in yellow">
28   <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="200" alt="A Japanese scan: OCR read the page, and the current line is marked">
29   <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="200" alt="After a page turn by hand, the page stops following, and the Follow button brings it back">
30   <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="200" alt="The start card with the checklist of the SubRead suite">
31 </p>
32 
33 ## Install
34 
35 On Android, download `Scan-SubRead-<version>.apk` from the
36 [latest release](https://github.com/equwal/scan-subread/releases/latest)
37 and open it. The release APKs have the signature of the SubRead suite. A
38 debug build from your own PC has a different signature: uninstall it
39 first. This deletes the pages and the books that the app keeps.
40 
41 In a browser, build and serve the web app (see "Run").
42 
43 ## How to use it
44 
45 1. Press "Open a PDF" on the start card, or open the menu (the `☰`
46    button on a phone; the left panel on a desktop) and load a PDF there.
47    The app reads the pages at once: the current page first, then the
48    pages after it to the end, then the pages before it. The status strip
49    shows how far it is.
50 2. In the menu, load the subtitles, or on Android press "Make subtitles
51    with SubRead": pick the audiobook. SubRead starts when all pages are
52    read ("Reading pages N/M first..."), and makes the `.srt` from the
53    audio and the text of the book. When pages could not be read, the
54    menu says "SubRead waits for 2 pages that could not be read." with
55    Retry, and SubRead starts when Retry reads them. The result loads at
56    once, is kept for this PDF, and can be shared as a file.
57 3. Start the player. The status strip shows its position. The page
58    follows.
59 
60 On a phone the menu is a drawer over the page. A tap outside the drawer
61 only closes it. The drawer also closes after you choose a file or a cue.
62 
63 The subtitles field takes each file, because a browser download of an
64 `.srt` often has the type `application/octet-stream`. A file with no
65 subtitle lines, for example a PDF, keeps the subtitles that are loaded.
66 The app reads a subtitle file in UTF-8, UTF-16 or Shift_JIS. A PDF that
67 does not open keeps the book that is open. A file of 0 bytes, for
68 example a file that another app still writes, gives "The file is empty
69 or not ready."
70 
71 ### The book is kept
72 
73 The app keeps a copy of the last book, its page, its subtitles and its
74 "Force OCR" setting in IndexedDB. At start it opens the last book at
75 its page, with its subtitles, without a picker. Android stops the
76 reader while you are in the dictionary or in the player app, and the
77 reader comes back where you were. When the copy cannot be read, for
78 example after the app storage was cleared, the app forgets it and shows
79 the start card. A book that you open while the last book loads, or while
80 the start waits for the database, wins: the app does not open the last
81 book over it, and it keeps the copy of your book.
82 
83 Subtitles belong to a book. Another book opens with its own subtitles,
84 or with none. Subtitles that you load while no book is open are for the
85 book that you open next. Subtitles that you load while a new book loads
86 are for that book. When the new book does not open, they are for the
87 book that stays open. The page goes to the meta data of the book one
88 second after it changes.
89 
90 The first start after an upgrade from a build with the old dictionary
91 can take long: the upgrade deletes about 86 MB of dictionary data. The
92 strip then says "Updating the page cache...", and the start card shows.
93 A book that you open in that time shows at once, and its meta data
94 applies when it comes. A page turn or a change of "Force OCR" that you
95 make before then stays.
96 
97 ### Android: Back, the screen and the suite
98 
99 - Back closes the page jump, else the drawer, else puts the reader in
100   the background. It never closes the reader, so the reader keeps its
101   state.
102 - The screen stays on while a book is open, the player plays and the
103   follow is not off. In the background the app does not read the
104   player.
105 - The start card and the menu show the SubRead suite: SubRead Overlay
106   (with its notification access), the dictionary apps, SubRead and
107   SubRead Anki, each with a link to its releases when it is missing.
108   "Open SubRead Overlay" opens the overlay, so that you can give it
109   notification access.
110 - SubRead can finish while Android has stopped the reader. SubRead
111   0.10.0 and later write the `.srt` into a result file of the book, and
112   the reader loads it when the book opens or the app comes back.
113 - Before the subtitles of SubRead load, the app asks when they replace
114   a file that you loaded, when SubRead found another language than the
115   one of the book, and when it found less than 80% of the lines.
116 - A SubRead result for a book that is not open waits in the meta data of
117   that book. When you open the book, the app offers it with the same
118   questions.
119 
120 ### The status strip
121 
122 The strip under the top bar shows three things:
123 
124 - The player: its time, and "playing" or "paused". When there is a
125   problem, it says what to do, for example "Allow notification access in
126   SubRead Overlay." with an "Open SubRead Overlay" button. Play and
127   "move the audio to this page" are off while the player cannot work.
128   "Follow paused" shows while a page turn by hand holds the follow.
129 - The reading of the pages, for example "Reading 3/40 · page 5: OCR 45%".
130   It goes away when all pages are read. A page that cannot be read does
131   not stop the others: at the end the strip says "2 pages could not be
132   read." with Retry, which reads only those pages. When OCR cannot
133   start, for example with no network on the first run, or its start
134   takes more than 2 minutes, the strip says so, with Retry. "Force OCR
135   is on" shows while it is on for the book.
136 - The last event, for example a lookup or an error. It fades after 6
137   seconds. While it shows, it takes the place of the player text, but
138   the button or the link of the player stays. Screen readers read this
139   part.
140 
141 The strip is one line, so the page under it does not move, and a long
142 text ends with "…". Tap the strip to read the full texts: they show in a
143 panel over the top of the page. The next tap closes the panel, and else
144 it closes after 6 seconds. A tap on a button of the strip, for example
145 Retry, only does what the button does.
146 
147 ### The three follow modes
148 
149 - **Highlight and turn pages**: the line of the current cue is marked on
150   the page, and the page turns when the cue is on another page. When the
151   mark goes out of view, the page scrolls so that the mark is in the
152   upper third. It does not scroll in the 3 seconds after you scroll.
153 - **Turn pages only**: the page turns, no mark. Use this when the
154   alignment is not good enough for a mark, for example with a noisy scan.
155 - **Off**: the page does not move. The status strip and the cue list
156   still show where the player is.
157 
158 A cue that the alignment did not find in the book has no page. In the
159 two follow modes the page then follows the nearest matched cue before it,
160 up to five cues back. A cue at a page break has text on two pages. The
161 page turns inside that cue: the first page shows for the share of the
162 text of the cue on it, then the next page.
163 
164 When you turn a page yourself (an arrow, a key, a swipe or the page
165 jump), the follow holds: the page does not turn by itself, and a
166 "Follow" button shows over the lower right corner of the page. It is not
167 in the top bar, so the buttons there do not move. The mark still shows
168 when the cue of now is on the page that you look at. The hold ends when
169 you press "Follow", when the audio reaches the page that you look at,
170 when you move the audio from the app (the cue list or "move the audio
171 to this page"), or when the player jumps. A new alignment after a page
172 is read does not end the hold.
173 
174 ### The top bar
175 
176 From the left: the menu, the previous page, the page label, the next
177 page, play or pause, and "move the audio to this page". The last one
178 moves the player to where the text of the page starts, the same as "Move
179 the audio to this page" in the KOReader plugin. When that text starts in
180 a cue from the page before, the audio goes into the cue, at the share of
181 the text on the page before. The button is off while no subtitles are
182 loaded, and while the player has no position or reports a problem.
183 
184 Other ways to turn pages:
185 
186 - The keys ArrowRight, ArrowLeft, PageDown and PageUp.
187 - A swipe to the left or to the right on the page. A page that you
188   zoomed in on does not turn: the swipe moves the page.
189 - The page label: press it, type a page number, and press Enter. Escape
190   or a tap outside cancels.
191 
192 The setting "Pages turn right to left (vertical Japanese)" makes
193 ArrowLeft, a swipe to the right and the left arrow go to the next page.
194 It is on for the OCR language "Japanese, vertical" until you set it.
195 
196 ### Text layer or OCR
197 
198 The app reads the text layer of a page first. When the page has fewer
199 than 10 characters of text, it is a scan: the app renders the page and
200 runs OCR (tesseract.js) in the language of the "OCR language" setting.
201 "Force OCR" skips the text layer, for a PDF whose text layer is wrong.
202 It is a setting of the book: another book opens with its own setting.
203 The status strip says which one was used for each page. A new setting
204 stops the OCR of the reading before at once.
205 
206 The app renders a scanned page to a canvas 1600 pixels wide. tesseract.js
207 gets the pixels of the canvas as a PPM image: a short text header and the
208 RGB bytes. The app does not give it the canvas itself. With a canvas,
209 tesseract.js first makes a PNG file, and on a test phone (Android
210 WebView) that took about 13 s for each page. The PPM image takes
211 milliseconds and has the same pixels, so OCR gives the same tokens.
212 
213 The OCR of a page can take 2 minutes, and more for a page of more than 4
214 million pixels. When it takes longer, or when the OCR worker fails, the
215 app stops the worker and counts the page as not read. The next page
216 starts a new worker, so one bad page does not stop the reading.
217 
218 When the OCR worker cannot start, for example because the language data
219 does not load, the app stops that worker too. tesseract.js does not stop
220 it, so without this each Retry left one more worker in memory. The start
221 also has a time limit of 2 minutes: a download of the language data that
222 stalls, for example behind the login page of a public Wi-Fi, does not
223 end by itself. After the limit the strip says that OCR could not start,
224 with Retry.
225 
226 Both give the same tokens: one box per character with a line id and a
227 word id. The alignment, the mark, the page turn and the lookup work the
228 same way on a scan and on a text PDF.
229 
230 The tokens of each page are kept in IndexedDB, under the file name, the
231 file size, the page, the OCR language and the source. A book that was
232 read once aligns at once the next time. "Clear this book's pages" and
233 "Clear all pages" remove them, after a question.
234 
235 ### Lookups
236 
237 A tap sends the text from the tapped word to the end of its line (at
238 most 40 characters) to the dictionary. A tap may land up to about 24
239 CSS pixels from a character, the size of a finger. In Latin script the
240 text starts at the first letter of the tapped word. In Japanese and
241 Chinese it starts at the tapped character, because a dictionary app
242 scans from there. Punctuation at the start is skipped, and a tap on
243 punctuation only sends nothing. When the line ends within 4 characters
244 and does not end a sentence, the next line of the page follows, so a
245 word that wraps is whole. A tap on a page that is not read yet tells
246 how many pages are read.
247 
248 "Dictionary" lists every app in the text selection menu; "Ask each
249 time" shows the Android chooser. "Pause on lookup" pauses the player
250 before the dictionary opens and starts it again when the dictionary
251 closes. When the player cannot pause, the lookup still runs.
252 
253 On the web there is no dictionary app: the tap copies the text to the
254 clipboard, and the strip shows "Copied" or "Copy failed" with the text.
255 The web does not pause the audio, and it has no "Dictionary" and no
256 "Pause on lookup" setting.
257 
258 ### Anki cards
259 
260 Press a word for half a second, without a move of more than 10 CSS
261 pixels: SubRead Anki shows a card. A move turns the long press into a
262 swipe or a scroll, and the click after a long press does no lookup.
263 
264 - The sentence is the text of the marked cue when the word is in it,
265   else the sentence around the word on its page.
266 - For a Japanese or Chinese word, the card gets the sentence only, and
267   you tap the word in SubRead Anki: OCR does not show where such a word
268   ends. For another word, the card also gets the word, without the
269   punctuation at its edges.
270 - The source is the book name and the page, for example
271   "sample-eng-2p, p. 1".
272 - "Pause on lookup" pauses the player while the card shows.
273 
274 The strip says "Card added to Anki." when the card is in Anki. The web
275 says "Anki cards need SubRead Anki on Android."
276 
277 ### The web
278 
279 The browser has no SubRead Overlay. The "Audio (web only)" field plays
280 the audio in the page, and the page follows that clock. Everything else
281 is the same. The web build is for development and for a desktop reader.
282 
283 ## Run
284 
285 ```bash
286 npm install
287 ```
288 
289 ```bash
290 npm run dev
291 ```
292 
293 Open the URL that Vite prints. Try the fixtures in `fixtures/`:
294 `sample-eng-2p.pdf` (a two-page scan) or `sample-eng-2p-text.pdf` (the
295 same text as a text layer) with `sample-eng-2p.srt` and `silence.wav`.
296 One cue of that file crosses from page 1 to page 2. `sample-jpn.pdf`
297 and `sample-jpn-text.pdf` go with `sample-jpn.srt` and the "Japanese,
298 horizontal" OCR language.
299 
300 The app serves the tesseract.js worker and its WebAssembly core from its
301 own files. The first OCR run of a language downloads the language data
302 from jsDelivr. The browser keeps the language data in IndexedDB after
303 that.
304 
305 ## Check
306 
307 ```bash
308 npm run typecheck
309 ```
310 
311 ```bash
312 npm test
313 ```
314 
315 ```bash
316 npm run test:e2e
317 ```
318 
319 ```bash
320 npm run build
321 ```
322 
323 ```bash
324 npm run format:check
325 ```
326 
327 `npm test` runs the unit and property tests. They need no browser and no
328 network. `npm run test:e2e` runs real OCR on the fixture images in Node
329 and checks that every cue is marked on the correct line and page, and
330 reads the text-layer PDFs with pdf.js in Node and checks that the text
331 layer gives every character in order. It also checks that OCR reads the
332 same symbols from a PPM image of a page as from its PNG file. It needs
333 network on the first run to fetch language data into `.tessdata/`.
334 
335 `npm run fixtures` rebuilds the fixtures from `fixtures/sample-text.ts`.
336 The Japanese text-layer PDF needs `C:\Windows\Fonts\yumin.ttf`; without
337 it, that one file is skipped.
338 
339 ## Android
340 
341 Capacitor wraps the web build in an Android WebView. The app is the same
342 code. The native part is one plugin,
343 `android/app/src/main/java/com/equwal/scansubread/SubReadPlugin.java`,
344 the bridge to the suite:
345 
346 - `playerState`, `play`, `pause`, `seek`: the content provider of SubRead
347   Overlay, `content://space.subread.overlay.player/state` (the debug
348   build of the overlay has the authority
349   `space.subread.overlay.debug.player`; both are tried). The app reads
350   the player every 2 seconds (every 5 seconds after an error) and
351   computes the position between two reads from the reported position,
352   the time of the report and the speed, the same as the overlay itself.
353   A reported position more than 3 seconds from the expected one counts
354   as a seek.
355 - `lookup`: `Intent.ACTION_PROCESS_TEXT` with the text. The call
356   resolves when the dictionary closes.
357 - `dictionaries`, `setDictionary`: the apps that take that intent, and
358   the chosen one.
359 - `makeSubtitles`, `pickAudio`, `pendingSubtitles`: the intent API of
360   SubRead (`space.subread.app.action.ALIGN`). The book text goes to
361   SubRead through a FileProvider. SubRead 0.10.0 and later also write
362   the `.srt` into a result file of the book (`subread-<hash>.srt`), and
363   `pendingSubtitles` reads it. When SubRead is not installed, the menu
364   links to its releases.
365 - `ankiAdd`: `space.subread.anki.action.ADD`, a card in SubRead Anki
366   (the release build, else the debug build).
367 - `suite`: which apps of the suite are installed. `openOverlay`: opens
368   SubRead Overlay for its notification access.
369 - `keepAwake`: keeps the screen on.
370 - `shareText`: the share sheet, for the finished `.srt`. The file Uri is
371   in the clip of the intent, so the preview of the chooser can read it.
372 
373 The `@capacitor/app` plugin gives the Back button and the app state
374 (front or background).
375 
376 `capacitor.config.ts` holds the app id, the app name and the web
377 directory. The `android/` project is the Capacitor template and is
378 committed.
379 
380 Prerequisites:
381 
382 - JDK 21 (`JAVA_HOME` set).
383 - Android SDK with platform 36 and build-tools 35 or newer.
384 - `ANDROID_HOME` set to the SDK path, or `android/local.properties`
385   with `sdk.dir=C:\\Android\\Sdk` (the file is not committed).
386 
387 Build the web app and copy it into the Android project:
388 
389 ```bash
390 npm run android:sync
391 ```
392 
393 Build the debug APK (the first run downloads Gradle and its dependencies):
394 
395 ```bash
396 npm run android:build
397 ```
398 
399 In PowerShell, set the SDK path first when `ANDROID_HOME` is not set:
400 
401 ```bash
402 $env:ANDROID_HOME = 'C:\Android\Sdk'; npm run android:build
403 ```
404 
405 If Gradle fails with `Unable to establish loopback connection`, the JDK
406 cannot open a Unix domain socket in the user temp directory. Point it to
407 a different directory for the build:
408 
409 ```bash
410 $env:JAVA_TOOL_OPTIONS = '-Djdk.net.unixdomain.tmpdir=C:\Windows\Temp'; $env:ANDROID_HOME = 'C:\Android\Sdk'; npm run android:build
411 ```
412 
413 The APK lands at `android/app/build/outputs/apk/debug/app-debug.apk`.
414 Install it on a connected phone with USB debugging on:
415 
416 ```bash
417 adb install -r android/app/build/outputs/apk/debug/app-debug.apk
418 ```
419 
420 A release APK gets the key of the SubRead suite from two environment
421 variables: `SUBREAD_KEYSTORE_FILE` (the key store, key alias `subread`)
422 and `SUBREAD_KEYSTORE_PASSWORD`. The key never goes into the repository.
423 Without the two variables, the release build is not signed. Increase
424 `versionCode` in `android/app/build.gradle` for each release. Build it
425 in `android/`:
426 
427 ```bash
428 .\gradlew.bat assembleRelease
429 ```
430 
431 The APK lands at `android/app/build/outputs/apk/release/app-release.apk`.
432 A release has an annotated tag `vX.Y.Z`, the title "Scan SubRead X.Y.Z"
433 and the asset `Scan-SubRead-X.Y.Z.apk`, the same as the other apps of the
434 suite.
435 
436 Notes:
437 
438 - The WebView serves the app from `https://localhost`. The file fields
439   open the Android file chooser. Put your PDFs and `.srt` files in
440   `Download` to find them fast.
441 - The first OCR run of a language needs network: tesseract.js fetches
442   the language data from jsDelivr over HTTPS. The worker and the
443   WebAssembly core are in the app. The `INTERNET` permission is in the
444   manifest. No cleartext traffic setting is needed.
445 - The pdf.js worker is part of the bundle.
446 - Pinch zoom is on (`zoomEnabled` in `capacitor.config.ts`). The page
447   fits the screen width in each orientation, up to 1000 CSS pixels, and
448   it renders again at the resolution of the screen after a rotation, and
449   at the zoom 0.3 s after a pinch zoom stops, so the text stays sharp.
450   The canvas is at most 4096 pixels wide and 12 million pixels in area.
451   Zoom in and scroll to read small print. On a zoomed page a swipe moves
452   the page and does not turn it.
453 - The pages, the copy of the last book and the meta data of each book
454   (page, subtitles, "Force OCR") live in the WebView's IndexedDB.
455   Uninstalling the app deletes them.
456 
457 ## Dependencies
458 
459 Runtime:
460 
461 - `pdfjs-dist`: renders PDF pages to a canvas and reads the text layer.
462 - `tesseract.js`: OCR in a Web Worker. Returns a box for each symbol.
463 - `tesseract.js-core`: the WebAssembly core of tesseract.js. The app
464   serves it from its own files, so OCR needs the network only for the
465   language data.
466 - `fast-diff`: character-level Myers diff. The alignment is built on it.
467 - `idb`: a thin Promise wrapper around IndexedDB. It replaces the
468   callback and event plumbing of the raw IndexedDB API.
469 - `@capacitor/core`, `@capacitor/android`: the Android shell and the
470   plugin bridge.
471 - `@capacitor/app`: the Back button, the app state (front or
472   background) and "move the app to the background".
473 
474 Development:
475 
476 - `vite`, `typescript`: build and strict type check.
477 - `vitest`, `fast-check`: unit tests and property tests.
478 - `fake-indexeddb`: IndexedDB in Node, for the tests of the page cache.
479 - `prettier`: formatting.
480 - `sharp`, `pdf-lib`, `@pdf-lib/fontkit`, `tsx`: build the synthetic
481   fixtures (render text to a page image, wrap it in an image-only PDF,
482   write the same text as a text-layer PDF, write a silent WAV).
483   `@pdf-lib/fontkit` lets pdf-lib embed a TrueType font for the
484   Japanese text-layer page. `sharp` also gives the e2e test the RGBA
485   pixels of a page image, for the PPM image.
486 
487 The SRT/VTT parser is written by hand (`src/subtitles.ts`). The `subtitle`
488 package imports the Node `stream` module at load time, so it does not run
489 in a browser bundle.
490 
491 ## Layout
492 
493 - `src/player-state.ts`: pure parser of the state line of SubRead
494   Overlay. A port of `player_state.lua` from the KOReader plugin.
495 - `src/play-clock.ts`: pure clock: the position now from a report of
496   the player. A port of `PlayClock.kt` from SubRead Overlay.
497 - `src/follower.ts`: pure reducer: the cue of now and the page of the
498   audio to a mark and a page turn, in the three follow modes, with the
499   hold after a page turn by the user.
500 - `src/cue-pages.ts`: pure parts of each cue on the pages, the page of
501   the audio at a time, and the time where the text of a page starts.
502 - `src/clock-source.ts`: the two time sources, the overlay (Android)
503   and the audio element (web).
504 - `src/text-layer.ts`: pure conversion of pdf.js text items to tokens.
505 - `src/ocr-tokens.ts`: pure conversion of a tesseract result to tokens.
506 - `src/ppm.ts`: pure PPM image of the pixels of a page canvas, for
507   tesseract.js.
508 - `src/align.ts`: pure alignment of subtitle cues to the page text.
509 - `src/subtitles.ts`: pure SRT/VTT parser and active-cue lookup.
510 - `src/hit-test.ts`: pure tap on the page. Hit test, scan string, the
511   lookup text.
512 - `src/line-boxes.ts`: pure boxes that mark a cue, one for each text
513   line of the page, grown to the edges of its words. The left and the
514   right side get a quarter of the line height as padding, because OCR
515   symbol boxes can end before the ink.
516 - `src/paging.ts`: pure page turns from keys, swipes and the arrows, the
517   page jump, the reading direction, and the limits of the long press.
518 - `src/scroll.ts`: pure scroll position that brings the mark into view.
519 - `src/render-size.ts`: pure width of the canvas of a page, for the
520   screen and the pinch zoom.
521 - `src/messages.ts`: pure texts of the status strip.
522 - `src/read-order.ts`: pure order in which the pages are read.
523 - `src/book-text.ts`: pure tokens to the book text for SubRead.
524 - `src/book-open.ts`: pure rules for the open of a book: the open
525   attempts, the book of subtitles that load during an open, the restore
526   of the last book, the error of an open that failed, and the meta data
527   that comes after the open.
528 - `src/book-subtitles.ts`: pure rules for the subtitles of a book: the
529   subtitles when a book opens, the result file name for SubRead, the
530   checks before a SubRead result loads, and the SubRead status of the
531   menu when a book opens.
532 - `src/sentence.ts`: pure sentence around a token.
533 - `src/anki-card.ts`: pure fields of an Anki card for a long press.
534 - `src/suite.ts`: pure checklist of the SubRead suite.
535 - `src/app-state.ts`: pure rules for the Back button and the screen.
536 - `src/subread.ts`: the typed side of the plugin, with the web fallback.
537 - `src/token-cache.ts`: IndexedDB store for the page tokens, the copy of
538   the last book and the meta data of each book (thin).
539 - `src/ocr.ts`, `src/pdf.ts`: thin browser wrappers around the libraries.
540   `src/ocr-job.ts`: the start error, the time limits of the start and of
541   a page, the new worker after a failed page and the stop of OCR, without
542   tesseract.js.
543 - `src/view.ts`: the page view: render to fit the width, draw the mark,
544   scroll to it, render again on a resize.
545 - `src/nav.ts`: page turns by the user: the arrows, the keys, the swipe
546   and the page jump, and the long press.
547 - `src/status.ts`: the status strip.
548 - `src/drawer.ts`: the menu, a drawer on a phone.
549 - `src/main.ts`: UI wiring: the book, the subtitles, the follow, SubRead
550   and the app state.
551 - `test/`: unit and property tests. `test/e2e/`: OCR and text-layer
552   end-to-end tests.
553 - `fixtures/`, `scripts/make-fixtures.ts`: synthetic scanned pages and
554   text-layer PDFs.
555 
556 ## Scope limits
557 
558 - The alignment finds each page in the subtitles on its own: runs of 8
559   characters that occur only once in the subtitles show where the page
560   is, and a character diff compares the page with that part only. So a
561   cue matches only a page that is read, and subtitles of another text
562   match nothing. A page is aligned once, when it is read. That takes a
563   few milliseconds; all the pages of a book of 300 000 characters take
564   about 1 s (Node on a desktop).
565 - A page with fewer than three such runs gets no cues, for example a
566   page with only a few words. A subtitle file of 64 characters or less
567   is too short for the runs: it is compared with each whole page, and
568   text that is not in it can match by chance.
569 - A vertical font in a text layer (`dir: 'ttb'` in pdf.js) lays the
570   characters down the page. This path has unit tests with mocked items,
571   but no fixture PDF: pdf-lib does not write vertical text.
572 - One audio file for one book. A book in many audio files is not
573   handled; SubRead has the same limit.
574 - No iOS build.
575 
576 ## Licence
577 
578 AGPL-3.0. See `LICENSE`.