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`.