commit e9dcd15783cc210dca84854fac17346b0092f303
truex <truex@equwal.com>
2026-09-25 01:52:37 -0700
Remove the dictionary, local audio and subtitle making
The SubRead suite has a dictionary app and a subtitle maker. This app stops copying them. The tap on a word keeps its hit test in hit-test.ts, and the text for a lookup now reads to the end of the line, up to 40 characters.
.prettierignore | 1 -
README.md | 243 +-------
.../com/equwal/scansubread/LocalAudioPlugin.java | 288 ----------
.../java/com/equwal/scansubread/MainActivity.java | 11 +-
fixtures/test-dict.ts | 65 ---
fixtures/test-dict.zip | Bin 1068 -> 0 bytes
index.html | 61 +--
package-lock.json | 29 -
package.json | 2 -
scripts/make-fixtures.ts | 4 -
src/audiobook.ts | 393 -------------
src/book-text.ts | 20 +-
src/cloud.ts | 174 ------
src/deinflect.ts | 167 ------
src/dictdb.ts | 114 ----
src/engine/align.d.ts | 31 --
src/engine/align.js | 608 ---------------------
src/engine/align.worker.js | 12 -
src/engine/asr.js | 89 ---
src/engine/job.d.ts | 47 --
src/engine/job.js | 159 ------
src/engine/media.js | 99 ----
src/hit-test.ts | 94 ++++
src/http.ts | 106 ----
src/local-audio.ts | 115 ----
src/lookup.ts | 133 -----
src/main.ts | 300 +---------
src/srt-cache.ts | 34 --
src/style.css | 114 +---
src/yomitan.ts | 119 ----
test/book-text.test.ts | 16 +-
test/cloud.test.ts | 155 ------
test/deinflect.test.ts | 79 ---
test/engine.test.ts | 31 --
test/{lookup.test.ts => hit-test.test.ts} | 65 +--
test/http.test.ts | 41 --
test/local-audio.test.ts | 96 ----
test/yomitan.test.ts | 70 ---
vite.config.ts | 30 -
39 files changed, 186 insertions(+), 4029 deletions(-)
diff --git a/.prettierignore b/.prettierignore
index 09c3bc1..4818b89 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,4 +2,3 @@ dist/
package-lock.json
fixtures/*.srt
android/
-src/engine/*.js
diff --git a/README.md b/README.md
index 2cb3f7f..30f4e32 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,14 @@
# Scan Subread
-A reader for scanned books with tap-to-look-up. It runs in a browser and
-as an Android app.
-
-You load a scanned PDF (page images, no text layer) and one or more
-Yomitan dictionaries. The app renders the pages and runs OCR to get a box
-for each character. Tap a word on the page, and the app shows the
-dictionary entries for the longest word that starts at that character,
-after Japanese deinflection (食べました → 食べる).
-
-Optional: load the audiobook. The app makes the subtitles from the
-audiobook and the OCR text, on the subread.space server or in the app
-itself (see "Audiobook"). You can also load a subtitle file (SRT or
-WebVTT) by hand. The app aligns each subtitle cue to the OCR text and
-highlights the region of the page that shows the current cue while the
-audio plays. Tap a cue in the list to seek the audio.
+A reader for scanned books. It runs in a browser and as an Android app.
+
+You load a scanned PDF (page images, no text layer). The app renders the
+pages and runs OCR to get a box for each character. Load a subtitle file
+(SRT or WebVTT) and an audio file: the app aligns each subtitle cue to
+the OCR text and highlights the region of the page that shows the
+current cue while the audio plays. Tap a cue in the list to seek the
+audio. Tap a word on the page: the status line shows the text under the
+tap.
## Use
@@ -23,21 +17,7 @@ audio plays. Tap a cue in the list to seek the audio.
2. Load a PDF with "PDF (scanned)". Pick the OCR language.
3. Press "OCR". The first run downloads the OCR engine and the language
data (see Notes). The status line shows the progress.
-4. Load a dictionary with "Dictionaries (Yomitan zip)". You can pick
- several zips at once. The list under the field shows each imported
- dictionary with a Delete button. Dictionaries stay in the browser's
- IndexedDB across restarts, so you import each one once.
-5. Close the menu and tap a word on the page. On a phone the entries
- appear in a sheet at the bottom. On a desktop they appear next to the
- tap. Tap anywhere else to close it.
-
-Where to get dictionaries: Yomitan dictionaries are zip files with
-`index.json` and `term_bank_N.json` inside. Use the zips that you use with
-the Yomitan browser extension. The app reads the term banks only. Kanji
-banks, tag banks and term meta banks (frequency, pitch) are ignored, so a
-kanji-only or frequency-only dictionary imports zero terms.
-`fixtures/test-dict.zip` is a 13-term sample that covers the words on
-`fixtures/sample-jpn.pdf`.
+4. Load the subtitles and the audio in the "Audiobook" section.
OCR text is noisy. The character boxes that Tesseract returns for Japanese
overlap and are sometimes shifted by half a character. When a tap picks
@@ -54,9 +34,9 @@ npm run dev
```
Open the URL that Vite prints. Try the fixtures in `fixtures/`:
-`sample-jpn.pdf` with the "Japanese, horizontal" OCR language and
-`test-dict.zip`. For the audio feature add `sample-jpn.srt` and
-`silence.wav` (or the `sample-eng` pair with English).
+`sample-jpn.pdf` with the "Japanese, horizontal" OCR language,
+`sample-jpn.srt` and `silence.wav` (or the `sample-eng` pair with
+English).
The first OCR run downloads the tesseract worker, its WebAssembly core and
the language data from jsDelivr. The browser caches the language data in
@@ -89,16 +69,14 @@ network. `npm run test:e2e` runs real OCR on the fixture images in Node
and checks that every cue is highlighted on the correct line. It needs
network on the first run to fetch language data into `.tessdata/`.
-`npm run fixtures` rebuilds the fixtures from `fixtures/sample-text.ts`
-and `fixtures/test-dict.ts`.
+`npm run fixtures` rebuilds the fixtures from `fixtures/sample-text.ts`.
## Android
Capacitor wraps the web build in an Android WebView. The app is the same
-code. The one native part is the local-audio plugin (see "Local audio"
-below). `capacitor.config.ts` holds the app id, the app
-name and the web directory. The `android/` project is the Capacitor
-template and is committed.
+code. `capacitor.config.ts` holds the app id, the app name and the web
+directory. The `android/` project is the Capacitor template and is
+committed.
Prerequisites:
@@ -143,8 +121,8 @@ adb install -r android/app/build/outputs/apk/debug/app-debug.apk
Notes:
- The WebView serves the app from `https://localhost`. The file fields
- open the Android file chooser. Put your PDFs and dictionary zips in
- `Download` to find them fast.
+ open the Android file chooser. Put your PDFs in `Download` to find
+ them fast.
- The first OCR run needs network. tesseract.js fetches its worker, its
WebAssembly core and the language data from jsDelivr over HTTPS. The
`INTERNET` permission is in the manifest. No cleartext traffic setting
@@ -153,110 +131,6 @@ Notes:
- Pinch zoom is on (`zoomEnabled` in `capacitor.config.ts`). The page
fits the screen width in portrait and the screen height in landscape.
Zoom in and scroll to read small print.
-- Dictionaries live in the WebView's IndexedDB. Uninstalling the app
- deletes them.
-
-## Audiobook
-
-The "Audiobook" section of the menu turns an audiobook and the OCR text
-into subtitles. The subread.space service takes only epubs, not scanned
-PDFs, so the app sends the OCR text instead: one line per OCR line,
-pages in order, one blank line between pages (`src/book-text.ts`). A
-space goes between words, but not between two CJK words. Two paths make
-the subtitles. Both need a PDF with OCR done and an audio file.
-
-1. "Make subtitles on subread.space" uploads the audio and the OCR text
- to the server (`POST /api/uploads`), starts the job with the
- narration language, and polls the job every 5 seconds. The status
- line shows the stage and the progress. "Stop" cancels the job. When
- the job succeeds, the app downloads the SRT and loads it. The server
- handles a 10-hour book in minutes. One job costs one credit. The
- first anonymous job is free. When the server answers 402, the status
- line shows the reason and a link to <https://subread.space>, where
- you buy credits or sign in. The job id stays in `localStorage`, so
- the app resumes the poll after a restart.
-2. "Make subtitles in this app" runs the SubPlz browser engine from the
- subread.space frontend (`src/engine/`): ffmpeg.wasm decodes the audio
- in two-minute chunks, whisper-tiny (transformers.js on the ONNX
- runtime) transcribes them, and the aligner matches the transcript to
- the OCR lines. This is slow on a phone: the speech model runs in
- WebAssembly at about real time or slower, and the screen must stay
- on. The transcript is saved after each chunk, so a stopped job
- resumes with the same audio file. The engine assets are not in the
- app: transformers.js, the ONNX runtime, the ffmpeg core (32 MB) and
- the model (about 120 MB) load from the "Engine assets URL", default
- `https://subread.space/vendor/`. The browser caches them.
-
-The narration language is a Whisper code (ja or en). The app sets it
-from the OCR language after each OCR run. The result SRT is kept in
-IndexedDB under the audio file name and size, so the next time you pick
-the same audio file the subtitles load at once.
-
-Sign in: type your email and press "Send link". The link opens
-subread.space in the browser. The app has no deep link, so paste the
-link (or the token in it) into "Paste the sign-in link or token" and
-press "Verify". The account line shows the email and the credits. On the
-web the identity is the `subplz_device` cookie of the dev proxy. On
-Android the requests run through Capacitor's native HTTP plugin
-(`src/http.ts`), because the WebView origin `https://localhost` gets no
-CORS headers from the server. The native cookie store keeps the cookie
-across restarts. The multipart upload crosses the native bridge as
-base64, so the whole audio file is in memory once during the upload.
-
-"Advanced" holds two settings for a self-hosted server: the server URL
-(default `https://subread.space`; empty on the web, where the Vite dev
-proxy forwards `/api`) and the engine assets URL.
-
-Known limit on Android: the in-app path loads the engine assets from
-another origin. That needs CORS headers on `/vendor/` of the server,
-which subread.space does not send yet. Until the server adds
-`add_header Access-Control-Allow-Origin *;` for `/vendor/` in nginx, the
-in-app path fails on the phone with a load error, and the server path is
-the one to use. On the web the Vite proxy makes `/vendor` same-origin,
-so the in-app path runs in the browser pane end to end.
-
-License note: `src/engine/*.js` is copied from the `frontend/engine/`
-directory of [equwal/subplz-web](https://github.com/equwal/subplz-web)
-(AGPL-3.0, same author). The files carry a header comment that says so.
-`align.js` and `align.worker.js` are verbatim. `asr.js`, `media.js` and
-`job.js` are adapted: the paragraphs come from the OCR lines, the assets
-load from a configurable base URL, and the ffmpeg wrapper comes from the
-`@ffmpeg/ffmpeg` package (its worker must be same-origin). The `.d.ts`
-files next to them give strict TypeScript the used signatures.
-
-## Local audio (Android only)
-
-The lookup popup can play a recorded pronunciation of each word. The
-recordings come from a Yomitan local-audio `android.db` file: one SQLite
-file, often 5 to 13 GB, with a table of words and a table of audio clips
-(mp3, ogg or opus). It is the same file that Hoshi Reader imports. You
-make it with the tooling of the
-[local-audio-yomichan](https://github.com/yomidevs/local-audio-yomichan)
-project. The app does not ship any recordings.
-
-The file is too big for the WebView, so a small native plugin
-(`android/app/src/main/java/com/equwal/scansubread/LocalAudioPlugin.java`)
-opens it read-only with the Android SQLite API and returns one clip at a
-time. The plugin looks for the file at two places, in this order:
-
-1. `Android/data/com.equwal.scansubread/files/android.db` on the shared
- storage. Copy the file there by hand with a file manager or `adb push`.
- No import step; no second copy of the file.
-2. The app's private files directory. "Import" in the "Local audio
- (android.db)" section of the menu opens the system file picker and
- streams the picked file into this directory. The copy takes a while for
- a large file; the progress bar shows how far it is. The import refuses
- to start when the free space is less than the file size. "Remove"
- deletes this copy. Uninstalling the app deletes it too.
-
-When a file is in place, the section shows its path and size, and each
-entry in the lookup popup gets one play button per audio source (NHK,
-Forvo speakers, and so on). The lookup runs after the popup is on screen,
-so the popup stays quick. On the web the section says "Android only".
-
-The plugin has no unit tests; it needs a device. `src/local-audio.ts`
-holds the typed bridge and the pure parts (MIME type from the file name,
-one button per source), and those have tests.
## Dependencies
@@ -265,15 +139,9 @@ Runtime:
- `pdfjs-dist`: renders PDF pages to a canvas.
- `tesseract.js`: OCR in a Web Worker. Returns a box for each symbol.
- `fast-diff`: character-level Myers diff. The alignment is built on it.
-- `fflate`: reads the dictionary zips. It replaces a hand-written zip
- parser; the browser has no zip API.
- `idb`: a thin Promise wrapper around IndexedDB. It replaces the
callback and event plumbing of the raw IndexedDB API.
-- `@ffmpeg/ffmpeg`: the ffmpeg.wasm worker wrapper for the in-app
- path. It replaces a copy of the same files from the reference's
- `frontend/vendor/ffmpeg/`. The 32 MB core is not bundled.
-- `@capacitor/core`, `@capacitor/android`: the Android shell, the
- LocalAudio plugin bridge and the native HTTP plugin.
+- `@capacitor/core`, `@capacitor/android`: the Android shell.
Development:
@@ -287,74 +155,19 @@ The SRT/VTT parser is written by hand (`src/subtitles.ts`). The `subtitle`
package imports the Node `stream` module at load time, so it does not run
in a browser bundle.
-The deinflector (`src/deinflect.ts`) is a small hand-written rule table,
-not Yomitan's. It covers the common verb and i-adjective inflections and
-chains up to three rules. A dictionary term's `rules` field (v1, v5,
-adj-i, ...) must allow the deinflection; a term with no rules matches
-any deinflection.
-
## Layout
-- `src/lookup.ts`: pure tap-to-look-up. Hit test, scan string, longest
- match with deinflection.
-- `src/deinflect.ts`: pure Japanese deinflection rules.
-- `src/yomitan.ts`: pure Yomitan zip reader. Term banks and glossary
- text, one bank at a time.
-- `src/dictdb.ts`: IndexedDB store for dictionaries and terms (thin, no
- tests).
-- `src/local-audio.ts`: typed bridge to the LocalAudio plugin and the
- pure helpers for the play buttons.
-- `android/app/src/main/java/com/equwal/scansubread/LocalAudioPlugin.java`:
- the native SQLite reader for `android.db`.
+- `src/hit-test.ts`: pure tap on the page. Hit test, scan string, the
+ lookup text.
- `src/align.ts`: pure alignment of subtitle cues to OCR text.
- `src/subtitles.ts`: pure SRT/VTT parser and active-cue lookup.
- `src/ocr-tokens.ts`: pure conversion of a tesseract result to tokens.
-- `src/book-text.ts`: pure OCR tokens to book text and paragraphs.
-- `src/cloud.ts`: pure client for the subread.space job API over an
- injected transport.
-- `src/http.ts`: the transports: `fetch` on the web, native HTTP on
- Android.
-- `src/srt-cache.ts`: IndexedDB store for finished subtitles (thin).
-- `src/engine/`: the SubPlz browser engine, copied (see "Audiobook").
-- `src/audiobook.ts`: UI wiring of the "Audiobook" section.
+- `src/book-text.ts`: pure OCR tokens to book text.
- `src/ocr.ts`, `src/pdf.ts`: thin browser wrappers around the libraries.
- `src/main.ts`: UI wiring.
- `test/`: unit and property tests. `test/e2e/`: OCR end-to-end test.
-- `fixtures/`, `scripts/make-fixtures.ts`: synthetic scanned pages and
- the sample dictionary.
-
-## Scope limits
-
-- No backend of its own. The subread.space server makes the subtitles
- on the server path; the app talks to it as a client.
-- OCR runs on the first N pages (the "Pages to OCR" field). Tokens are
- kept in memory. They are not saved between sessions; dictionaries are.
-- The scan string is the tapped character and the characters after it on
- the same OCR line, up to 16 characters. A word that wraps to the next
- line is not found.
-- Alignment runs once over the whole book. It takes about 5 s for 200k
- characters at 10% OCR noise, and well under 1 s for a chapter.
-- No audiobook pause control, no iOS build.
-- The fixtures' `silence.wav` is silent. It only drives the clock. Both
- subtitle paths fail or produce nonsense on it: the server reports
- that no subtitle file was produced, and whisper-tiny hallucinates one
- segment.
-- The in-app path on Android waits for CORS headers on the server's
- `/vendor/` (see "Audiobook").
-
-## Next steps
-
-1. Offline OCR on Android: put the tesseract worker, the WebAssembly core
- and the language data in the app bundle (`workerPath`, `corePath`,
- `langPath` options of `createWorker`), so the first OCR run needs no
- network.
-2. Persistence: cache OCR tokens per book in IndexedDB, so a book is
- OCR'd once.
-3. Kanji banks and frequency data from the dictionary zips.
-4. Audiobook pause behavior: pause at the end of each cue, or after a
- sentence, with a setting for the pause length and a key to continue.
-5. iOS: `npx cap add ios`. The web code is the same. It needs a Mac with
- Xcode.
-6. Server: CORS headers on `/vendor/`, so the in-app path runs on
- Android. A streaming native upload, so a 10-hour audiobook does not
- sit in memory as base64 during the upload.
+- `fixtures/`, `scripts/make-fixtures.ts`: synthetic scanned pages.
+
+## Licence
+
+AGPL-3.0.
diff --git a/android/app/src/main/java/com/equwal/scansubread/LocalAudioPlugin.java b/android/app/src/main/java/com/equwal/scansubread/LocalAudioPlugin.java
deleted file mode 100644
index b410004..0000000
--- a/android/app/src/main/java/com/equwal/scansubread/LocalAudioPlugin.java
+++ /dev/null
@@ -1,288 +0,0 @@
-package com.equwal.scansubread;
-
-import android.app.Activity;
-import android.content.Intent;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.net.Uri;
-import android.os.ParcelFileDescriptor;
-import android.util.Base64;
-import androidx.activity.result.ActivityResult;
-import com.getcapacitor.JSArray;
-import com.getcapacitor.JSObject;
-import com.getcapacitor.Plugin;
-import com.getcapacitor.PluginCall;
-import com.getcapacitor.PluginMethod;
-import com.getcapacitor.annotation.ActivityCallback;
-import com.getcapacitor.annotation.CapacitorPlugin;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-
-/**
- * Word pronunciations from a Yomitan local-audio `android.db` (SQLite).
- *
- * The file is many GB, so it never goes through the WebView. The plugin
- * finds it at one of two paths, queries it with the Android SQLite API
- * and returns one audio clip at a time as base64.
- *
- * Search order: the external files dir (the user copies the file there by
- * hand), then the app's private files dir (the imported copy).
- */
-@CapacitorPlugin(name = "LocalAudio")
-public class LocalAudioPlugin extends Plugin {
-
- private static final String DB_NAME = "android.db";
- private static final int CHUNK = 1024 * 1024;
- /** Progress events are sent after this many bytes, and at the end. */
- private static final long PROGRESS_STEP = 16L * CHUNK;
-
- private final Object lock = new Object();
- private SQLiteDatabase db;
- private File openFile;
-
- // --- Paths ---
-
- private File externalDb() {
- File dir = getContext().getExternalFilesDir(null);
- return dir == null ? null : new File(dir, DB_NAME);
- }
-
- private File importedDb() {
- return new File(getContext().getFilesDir(), DB_NAME);
- }
-
- /** The db file in use, or null when none exists. */
- private File findDb() {
- File external = externalDb();
- if (external != null && external.isFile()) return external;
- File imported = importedDb();
- return imported.isFile() ? imported : null;
- }
-
- private JSObject statusObject() {
- File file = findDb();
- JSObject ret = new JSObject();
- ret.put("available", file != null);
- ret.put("path", file == null ? JSObject.NULL : file.getAbsolutePath());
- ret.put("sizeBytes", file == null ? 0 : file.length());
- return ret;
- }
-
- // --- Database ---
-
- /** Open the db when needed. Reopen when the file in use changed. */
- private SQLiteDatabase openDb() {
- synchronized (lock) {
- File file = findDb();
- if (file == null) {
- closeDb();
- return null;
- }
- if (db != null && file.equals(openFile)) return db;
- closeDb();
- db = SQLiteDatabase.openDatabase(file.getAbsolutePath(), null, SQLiteDatabase.OPEN_READONLY);
- openFile = file;
- return db;
- }
- }
-
- private void closeDb() {
- synchronized (lock) {
- if (db != null) db.close();
- db = null;
- openFile = null;
- }
- }
-
- @Override
- protected void handleOnDestroy() {
- closeDb();
- }
-
- // --- Plugin methods ---
-
- @PluginMethod
- public void status(PluginCall call) {
- call.resolve(statusObject());
- }
-
- @PluginMethod
- public void importDb(PluginCall call) {
- Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
- intent.addCategory(Intent.CATEGORY_OPENABLE);
- intent.setType("*/*");
- startActivityForResult(call, intent, "importResult");
- }
-
- @ActivityCallback
- private void importResult(PluginCall call, ActivityResult result) {
- if (call == null) return;
- Intent data = result.getData();
- Uri uri = data == null ? null : data.getData();
- if (result.getResultCode() != Activity.RESULT_OK || uri == null) {
- call.reject("Import cancelled.");
- return;
- }
- // The copy takes minutes for a large file. Keep it off the UI thread.
- new Thread(() -> copyToImported(call, uri), "LocalAudioImport").start();
- }
-
- /** Stream the picked document to filesDir/android.db in 1 MB chunks. */
- private void copyToImported(PluginCall call, Uri uri) {
- File target = importedDb();
- File part = new File(target.getParentFile(), DB_NAME + ".part");
- try (ParcelFileDescriptor pfd = getContext().getContentResolver().openFileDescriptor(uri, "r")) {
- if (pfd == null) {
- call.reject("Cannot open the picked file.");
- return;
- }
- long total = pfd.getStatSize();
- // The old copy is replaced, so its space counts as free.
- long free = target.getParentFile().getUsableSpace() + (target.isFile() ? target.length() : 0);
- if (total > 0 && free < total) {
- call.reject(
- "Not enough free space: the file is " + mb(total) + " MB, free space is " + mb(free) + " MB."
- );
- return;
- }
- closeDb();
- if (part.exists() && !part.delete()) {
- call.reject("Cannot delete the old partial copy.");
- return;
- }
- try (
- InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(pfd.dup());
- OutputStream out = new FileOutputStream(part)
- ) {
- byte[] buf = new byte[CHUNK];
- long copied = 0;
- long lastReport = 0;
- int n;
- while ((n = in.read(buf)) > 0) {
- out.write(buf, 0, n);
- copied += n;
- if (copied - lastReport >= PROGRESS_STEP) {
- lastReport = copied;
- notifyProgress(copied, total);
- }
- }
- notifyProgress(copied, total);
- }
- if (target.exists() && !target.delete()) {
- call.reject("Cannot replace the old copy.");
- return;
- }
- if (!part.renameTo(target)) {
- call.reject("Cannot move the copy into place.");
- return;
- }
- call.resolve(statusObject());
- } catch (IOException | RuntimeException e) {
- part.delete();
- call.reject("Import failed: " + e.getMessage(), e);
- }
- }
-
- private void notifyProgress(long copied, long total) {
- JSObject data = new JSObject();
- data.put("copied", copied);
- data.put("total", total);
- notifyListeners("importProgress", data);
- }
-
- private static long mb(long bytes) {
- return bytes / (1024 * 1024);
- }
-
- @PluginMethod
- public void remove(PluginCall call) {
- closeDb();
- File file = importedDb();
- if (file.exists() && !file.delete()) {
- call.reject("Cannot delete " + file.getAbsolutePath());
- return;
- }
- call.resolve(statusObject());
- }
-
- @PluginMethod
- public void lookup(PluginCall call) {
- String expression = call.getString("expression");
- String reading = call.getString("reading");
- if (expression == null) {
- call.reject("expression is required.");
- return;
- }
- JSArray entries = new JSArray();
- try {
- SQLiteDatabase d = openDb();
- if (d == null) {
- call.reject("No android.db.");
- return;
- }
- String sql;
- String[] args;
- if (reading == null || reading.isEmpty()) {
- sql = "SELECT source, speaker, display, file FROM entries WHERE expression = ? ORDER BY id";
- args = new String[] { expression };
- } else {
- sql =
- "SELECT source, speaker, display, file FROM entries" +
- " WHERE expression = ? AND (reading = ? OR reading IS NULL) ORDER BY id";
- args = new String[] { expression, reading };
- }
- try (Cursor c = d.rawQuery(sql, args)) {
- while (c.moveToNext()) {
- JSObject row = new JSObject();
- row.put("source", c.getString(0));
- row.put("speaker", c.isNull(1) ? JSObject.NULL : c.getString(1));
- row.put("display", c.isNull(2) ? JSObject.NULL : c.getString(2));
- row.put("file", c.getString(3));
- entries.put(row);
- }
- }
- } catch (RuntimeException e) {
- call.reject("Lookup failed: " + e.getMessage(), e);
- return;
- }
- JSObject ret = new JSObject();
- ret.put("entries", entries);
- call.resolve(ret);
- }
-
- @PluginMethod
- public void audio(PluginCall call) {
- String file = call.getString("file");
- String source = call.getString("source");
- if (file == null || source == null) {
- call.reject("file and source are required.");
- return;
- }
- try {
- SQLiteDatabase d = openDb();
- if (d == null) {
- call.reject("No android.db.");
- return;
- }
- try (
- Cursor c = d.rawQuery(
- "SELECT data FROM android WHERE file = ? AND source = ? LIMIT 1",
- new String[] { file, source }
- )
- ) {
- if (!c.moveToFirst()) {
- call.reject("No audio for " + source + "/" + file);
- return;
- }
- JSObject ret = new JSObject();
- ret.put("data", Base64.encodeToString(c.getBlob(0), Base64.NO_WRAP));
- call.resolve(ret);
- }
- } catch (RuntimeException e) {
- call.reject("Audio failed: " + e.getMessage(), e);
- }
- }
-}
diff --git a/android/app/src/main/java/com/equwal/scansubread/MainActivity.java b/android/app/src/main/java/com/equwal/scansubread/MainActivity.java
index 350356d..075f8ca 100644
--- a/android/app/src/main/java/com/equwal/scansubread/MainActivity.java
+++ b/android/app/src/main/java/com/equwal/scansubread/MainActivity.java
@@ -1,14 +1,5 @@
package com.equwal.scansubread;
-import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
-public class MainActivity extends BridgeActivity {
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- // Local plugins register before the bridge is built.
- registerPlugin(LocalAudioPlugin.class);
- super.onCreate(savedInstanceState);
- }
-}
+public class MainActivity extends BridgeActivity {}
diff --git a/fixtures/test-dict.ts b/fixtures/test-dict.ts
deleted file mode 100644
index 4ce8b89..0000000
--- a/fixtures/test-dict.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-// A tiny Yomitan-format dictionary that covers words of the sample-jpn
-// fixture. test/yomitan.test.ts parses it. scripts/make-fixtures.ts
-// writes it to fixtures/test-dict.zip for use on a phone.
-
-import { strToU8, zipSync } from 'fflate';
-
-export const TEST_DICT_INDEX = {
- title: 'Test dictionary',
- revision: '2026-09-24',
- format: 3,
- sequenced: true,
-};
-
-/** Rows in Yomitan term bank order: expression, reading, definitionTags, rules, score, glossary, sequence, termTags. */
-export const TEST_DICT_TERMS: unknown[][] = [
- ['吾輩', 'わがはい', 'pn', '', 10, ['I, me (archaic, pompous)'], 1, ''],
- ['猫', 'ねこ', 'n', '', 50, ['cat'], 2, ''],
- ['名前', 'なまえ', 'n', '', 50, ['name'], 3, ''],
- ['無い', 'ない', 'adj-i', 'adj-i', 40, ['nonexistent, not being (there)'], 4, ''],
- ['生れる', 'うまれる', 'v1', 'v1', 30, ['to be born'], 5, ''],
- ['見当', 'けんとう', 'n', '', 20, ['estimate, guess', 'aim, direction'], 6, ''],
- ['薄暗い', 'うすぐらい', 'adj-i', 'adj-i', 20, ['dim, gloomy'], 7, ''],
- ['泣く', 'なく', 'v5', 'v5', 40, ['to cry, to weep'], 8, ''],
- ['記憶', 'きおく', 'n vs', 'vs', 40, ['memory, recollection'], 9, ''],
- ['人間', 'にんげん', 'n', '', 50, ['human being, person'], 10, ''],
- [
- '見る',
- 'みる',
- 'v1',
- 'v1',
- 50,
- [
- {
- type: 'structured-content',
- content: [
- { tag: 'div', content: [{ tag: 'span', content: 'to see, to look, to watch' }] },
- { tag: 'div', content: 'to examine' },
- { tag: 'img', path: 'img/eye.png' },
- ],
- },
- ],
- 11,
- '',
- ],
- ['聞く', 'きく', 'v5', 'v5', 50, ['to hear, to listen', 'to ask'], 12, ''],
- [
- '書生',
- 'しょせい',
- 'n',
- '',
- 10,
- ['student (esp. one who lives in the home of a teacher)'],
- 13,
- '',
- ],
-];
-
-/** The dictionary as a zip, as Yomitan would export it. */
-export function buildTestDictionaryZip(): Uint8Array {
- return zipSync({
- 'index.json': strToU8(JSON.stringify(TEST_DICT_INDEX)),
- 'term_bank_1.json': strToU8(JSON.stringify(TEST_DICT_TERMS.slice(0, 7))),
- 'term_bank_2.json': strToU8(JSON.stringify(TEST_DICT_TERMS.slice(7))),
- });
-}
diff --git a/fixtures/test-dict.zip b/fixtures/test-dict.zip
deleted file mode 100644
index 8db89ab..0000000
Binary files a/fixtures/test-dict.zip and /dev/null differ
diff --git a/index.html b/index.html
index 32ef7dc..164d4f0 100644
--- a/index.html
+++ b/index.html
@@ -16,7 +16,7 @@
<aside id="panel">
<h1>Scan Subread</h1>
- <p class="hint">Load a scanned PDF, run OCR, then tap a word on the page to look it up.</p>
+ <p class="hint">Load a scanned PDF, run OCR, then tap a word on the page.</p>
<label>PDF (scanned) <input id="pdf-file" type="file" accept="application/pdf" /></label>
<label
@@ -33,69 +33,12 @@
<div id="status" aria-live="polite"></div>
<progress id="progress" max="1" value="0" hidden></progress>
- <label
- >Dictionaries (Yomitan zip)
- <input id="dict-file" type="file" accept=".zip,application/zip" multiple />
- </label>
- <ul id="dicts"></ul>
-
- <section id="local-audio">
- <h2>Local audio (android.db)</h2>
- <div id="la-status" class="hint">Local audio: checking...</div>
- <div class="row">
- <button id="la-import" disabled>Import</button>
- <button id="la-remove" disabled>Remove</button>
- </div>
- <progress id="la-progress" max="1" value="0" hidden></progress>
- <p class="hint">
- Or copy the file by hand to
- <code>Android/data/com.equwal.scansubread/files/android.db</code>.
- </p>
- </section>
-
<section id="audiobook">
<h2>Audiobook</h2>
<label>Audio (optional) <input id="audio-file" type="file" accept="audio/*" /></label>
<label
>Subtitles (optional) <input id="sub-file" type="file" accept=".srt,.vtt,text/*"
/></label>
- <label
- >Narration language
- <select id="ab-lang">
- <option value="ja">Japanese (ja)</option>
- <option value="en">English (en)</option>
- </select>
- </label>
- <div class="row">
- <button id="ab-cloud" disabled>Make subtitles on subread.space</button>
- <button id="ab-local" disabled>Make subtitles in this app</button>
- <button id="ab-stop" hidden>Stop</button>
- </div>
- <div id="ab-status" class="hint">Load a PDF, run OCR and pick an audio file.</div>
- <progress id="ab-progress" max="1" value="0" hidden></progress>
- <p class="hint">
- The server is fast and takes one credit per book. In this app the speech model runs in
- WebAssembly: about real time or slower on a phone, and the screen must stay on.
- </p>
- <div id="ab-account" class="hint">Account: not checked yet.</div>
- <label>Sign in (email) <input id="ab-email" type="email" autocomplete="email" /></label>
- <div class="row">
- <button id="ab-send">Send link</button>
- </div>
- <label>Paste the sign-in link or token <input id="ab-token" type="text" /></label>
- <div class="row">
- <button id="ab-verify">Verify</button>
- </div>
- <details>
- <summary>Advanced</summary>
- <label
- >Server URL <input id="ab-server" type="url" placeholder="https://subread.space"
- /></label>
- <label
- >Engine assets URL
- <input id="ab-assets" type="url" placeholder="https://subread.space/vendor/"
- /></label>
- </details>
<audio id="audio" controls></audio>
</section>
@@ -110,8 +53,6 @@
</div>
</main>
- <div id="popup" hidden></div>
-
<script type="module" src="/src/main.ts"></script>
</body>
</html>
diff --git a/package-lock.json b/package-lock.json
index 49d7615..3def233 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,9 +10,7 @@
"dependencies": {
"@capacitor/android": "^8.5.2",
"@capacitor/core": "^8.5.2",
- "@ffmpeg/ffmpeg": "^0.12.15",
"fast-diff": "^1.3.0",
- "fflate": "^0.8.3",
"idb": "^8.0.3",
"pdfjs-dist": "^6.3.289",
"tesseract.js": "^7.0.0"
@@ -556,27 +554,6 @@
"node": ">=18"
}
},
- "node_modules/@ffmpeg/ffmpeg": {
- "version": "0.12.15",
- "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
- "integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==",
- "license": "MIT",
- "dependencies": {
- "@ffmpeg/types": "^0.12.4"
- },
- "engines": {
- "node": ">=18.x"
- }
- },
- "node_modules/@ffmpeg/types": {
- "version": "0.12.4",
- "resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz",
- "integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==",
- "license": "MIT",
- "engines": {
- "node": ">=16.x"
- }
- },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -2888,12 +2865,6 @@
}
}
},
- "node_modules/fflate": {
- "version": "0.8.3",
- "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
- "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
- "license": "MIT"
- },
"node_modules/fs-extra": {
"version": "11.4.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz",
diff --git a/package.json b/package.json
index 1032c42..f08051c 100644
--- a/package.json
+++ b/package.json
@@ -19,9 +19,7 @@
"dependencies": {
"@capacitor/android": "^8.5.2",
"@capacitor/core": "^8.5.2",
- "@ffmpeg/ffmpeg": "^0.12.15",
"fast-diff": "^1.3.0",
- "fflate": "^0.8.3",
"idb": "^8.0.3",
"pdfjs-dist": "^6.3.289",
"tesseract.js": "^7.0.0"
diff --git a/scripts/make-fixtures.ts b/scripts/make-fixtures.ts
index 491b324..040358a 100644
--- a/scripts/make-fixtures.ts
+++ b/scripts/make-fixtures.ts
@@ -3,7 +3,6 @@
// <name>.pdf one-page image-only PDF, as a scanner would produce
// <name>.srt one cue per text line
// silence.wav silent audio long enough for all cues
-// test-dict.zip tiny Yomitan dictionary for the sample-jpn page
// Run: npm run fixtures
import { mkdir, writeFile } from 'node:fs/promises';
@@ -11,7 +10,6 @@ import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import sharp from 'sharp';
import { CUE_SECONDS, FIXTURES, PAGE, toSrt, type Fixture } from '../fixtures/sample-text';
-import { buildTestDictionaryZip } from '../fixtures/test-dict';
const OUT = path.resolve('fixtures');
@@ -88,8 +86,6 @@ async function main(): Promise<void> {
}
await writeFile(path.join(OUT, 'silence.wav'), silentWav(maxSeconds + 1));
console.log(`wrote silence.wav (${maxSeconds + 1} s)`);
- await writeFile(path.join(OUT, 'test-dict.zip'), buildTestDictionaryZip());
- console.log('wrote test-dict.zip');
}
main().catch((err) => {
diff --git a/src/audiobook.ts b/src/audiobook.ts
deleted file mode 100644
index 2683642..0000000
--- a/src/audiobook.ts
+++ /dev/null
@@ -1,393 +0,0 @@
-// The "Audiobook" section of the drawer: make subtitles from the audiobook
-// and the OCR text, on subread.space or in this app. The result is an SRT
-// that goes down the same path as a hand-picked subtitle file.
-
-import { bookFile, paragraphs, bookText, whisperCode } from './book-text';
-import type { OcrToken } from './align';
-import {
- accountLine,
- ApiError,
- cancelJob,
- fetchSrt,
- getAccount,
- pollUntilDone,
- requestSignIn,
- SITE,
- startJob,
- tokenFrom,
- upload,
- verifySignIn,
- type Http,
- type JobOut,
-} from './cloud';
-import type { Job, JobStatus } from './engine/job.js';
-import { createHttp, DEFAULT_SERVER, defaultAssets } from './http';
-import { audioKey, getSrt, putSrt } from './srt-cache';
-
-const POLL_MS = 5000;
-
-const KEYS = {
- server: 'subread.server',
- assets: 'subread.assets',
- job: 'subread.job',
-};
-
-/** The server job that may still run after the app was closed. */
-interface PendingJob {
- id: string;
- audioKey: string;
- audioName: string;
-}
-
-export interface AudiobookHooks {
- tokens(): readonly OcrToken[];
- /** The tesseract language of the last OCR run. */
- ocrLang(): string;
- /** Load finished subtitles into the reader. */
- loadSrt(srt: string, source: string): void;
-}
-
-export interface Audiobook {
- /** Call after an OCR run: enables the buttons and sets the language. */
- onTokens(): void;
-}
-
-function el<T extends HTMLElement>(id: string): T {
- const e = document.getElementById(id);
- if (!e) throw new Error(`Missing element #${id}`);
- return e as T;
-}
-
-function read(key: string): string | null {
- try {
- return localStorage.getItem(key);
- } catch {
- return null;
- }
-}
-
-function write(key: string, value: string | null): void {
- try {
- if (value === null) localStorage.removeItem(key);
- else localStorage.setItem(key, value);
- } catch {
- // Storage is off. The setting lives for this session only.
- }
-}
-
-/** A URL that ends with one slash. */
-function withSlash(url: string): string {
- return url.endsWith('/') ? url : `${url}/`;
-}
-
-export function setupAudiobook(hooks: AudiobookHooks): Audiobook {
- const ui = {
- audioFile: el<HTMLInputElement>('audio-file'),
- audio: el<HTMLAudioElement>('audio'),
- lang: el<HTMLSelectElement>('ab-lang'),
- cloud: el<HTMLButtonElement>('ab-cloud'),
- local: el<HTMLButtonElement>('ab-local'),
- stop: el<HTMLButtonElement>('ab-stop'),
- status: el<HTMLDivElement>('ab-status'),
- progress: el<HTMLProgressElement>('ab-progress'),
- account: el<HTMLDivElement>('ab-account'),
- email: el<HTMLInputElement>('ab-email'),
- send: el<HTMLButtonElement>('ab-send'),
- token: el<HTMLInputElement>('ab-token'),
- verify: el<HTMLButtonElement>('ab-verify'),
- server: el<HTMLInputElement>('ab-server'),
- assets: el<HTMLInputElement>('ab-assets'),
- };
-
- const state = {
- audio: null as File | null,
- busy: false,
- /** Stops the poll loop of the server job. */
- abort: null as AbortController | null,
- /** The in-app job, while it runs. */
- job: null as Job | null,
- http: null as Http | null,
- };
-
- // --- Settings ---
-
- function server(): string {
- return (read(KEYS.server) ?? DEFAULT_SERVER).replace(/\/+$/, '');
- }
-
- function assets(): string {
- return withSlash(read(KEYS.assets) ?? defaultAssets());
- }
-
- function http(): Http {
- state.http ??= createHttp(server());
- return state.http;
- }
-
- ui.server.value = read(KEYS.server) ?? '';
- ui.server.placeholder = DEFAULT_SERVER || '(dev proxy)';
- ui.assets.value = read(KEYS.assets) ?? '';
- ui.assets.placeholder = defaultAssets();
-
- ui.server.addEventListener('change', () => {
- write(KEYS.server, ui.server.value.trim() || null);
- state.http = null;
- void refreshAccount();
- });
- ui.assets.addEventListener('change', () => {
- write(KEYS.assets, ui.assets.value.trim() || null);
- });
-
- // --- Status ---
-
- function setStatus(text: string, link?: { href: string; label: string }): void {
- ui.status.replaceChildren(text);
- if (link) {
- const a = document.createElement('a');
- a.href = link.href;
- a.target = '_blank';
- a.rel = 'noopener';
- a.textContent = link.label;
- ui.status.append(' ', a);
- }
- }
-
- function setProgress(value: number | null): void {
- ui.progress.hidden = value === null;
- if (value !== null) ui.progress.value = value;
- }
-
- function updateButtons(): void {
- const ready = !state.busy && state.audio !== null && hooks.tokens().length > 0;
- ui.cloud.disabled = !ready;
- ui.local.disabled = !ready;
- ui.stop.hidden = !state.busy;
- }
-
- function setBusy(busy: boolean): void {
- state.busy = busy;
- updateButtons();
- if (!busy) setProgress(null);
- }
-
- // --- Account ---
-
- async function refreshAccount(): Promise<void> {
- try {
- const account = await getAccount(http());
- ui.account.textContent = `Account: ${accountLine(account)}`;
- } catch (err) {
- ui.account.textContent = `Account: cannot reach the server (${String(err)}).`;
- }
- }
-
- ui.send.addEventListener('click', async () => {
- ui.send.disabled = true;
- try {
- const r = await requestSignIn(http(), ui.email.value);
- ui.account.textContent = r.dev_link
- ? `Account: no mail server. Paste this link below: ${r.dev_link}`
- : `Account: link sent to ${r.email}. Open it, then paste the link or token below.`;
- } catch (err) {
- ui.account.textContent = `Account: ${String(err)}`;
- } finally {
- ui.send.disabled = false;
- }
- });
-
- ui.verify.addEventListener('click', async () => {
- ui.verify.disabled = true;
- try {
- const account = await verifySignIn(http(), tokenFrom(ui.token.value));
- ui.token.value = '';
- ui.account.textContent = `Account: ${accountLine(account)}`;
- } catch (err) {
- ui.account.textContent = `Account: ${String(err)}`;
- } finally {
- ui.verify.disabled = false;
- }
- });
-
- // --- Audio file and the SRT cache ---
-
- ui.audioFile.addEventListener('change', async () => {
- const file = ui.audioFile.files?.[0];
- if (!file) return;
- state.audio = file;
- ui.audio.src = URL.createObjectURL(file);
- updateButtons();
- const cached = await getSrt(audioKey(file)).catch(() => undefined);
- if (cached) {
- hooks.loadSrt(cached, 'the cache');
- setStatus(`Subtitles for ${file.name} loaded from the cache.`);
- } else {
- setStatus(`${file.name}: no subtitles yet.`);
- }
- });
-
- /** Keep the SRT, and show it when its audio file is the picked one. */
- async function finish(srt: string, key: string, name: string, source: string): Promise<void> {
- await putSrt(key, srt).catch(() => {});
- if (state.audio && audioKey(state.audio) === key) {
- hooks.loadSrt(srt, source);
- setStatus(`Subtitles ready from ${source}.`);
- } else {
- setStatus(`Subtitles for ${name} are ready. Pick that audio file to load them.`);
- }
- }
-
- // --- Server job ---
-
- function jobLine(job: JobOut): string {
- const pct = Math.round(job.progress * 100);
- return `Server job ${job.status}: ${job.stage} (${pct}%)`;
- }
-
- /** Poll a server job to its end and load the SRT. */
- async function follow(pending: PendingJob): Promise<void> {
- const abort = new AbortController();
- state.abort = abort;
- try {
- const job = await pollUntilDone(http(), pending.id, {
- intervalMs: POLL_MS,
- signal: abort.signal,
- onStatus: (j) => {
- setStatus(jobLine(j));
- setProgress(j.progress);
- },
- });
- write(KEYS.job, null);
- if (job.status === 'succeeded') {
- const srt = await fetchSrt(http(), job.id);
- await finish(srt, pending.audioKey, pending.audioName, 'subread.space');
- } else {
- setStatus(`Server job ${job.status}: ${job.error ?? job.stage}`);
- }
- } finally {
- state.abort = null;
- }
- }
-
- ui.cloud.addEventListener('click', async () => {
- const audio = state.audio;
- if (!audio || state.busy) return;
- setBusy(true);
- try {
- setStatus(`Uploading ${audio.name} and the OCR text...`);
- setProgress(0);
- const job = await upload(http(), audio, bookFile(hooks.tokens()));
- const pending: PendingJob = { id: job.id, audioKey: audioKey(audio), audioName: audio.name };
- write(KEYS.job, JSON.stringify(pending));
- try {
- await startJob(http(), job.id, ui.lang.value);
- } catch (err) {
- write(KEYS.job, null);
- if (err instanceof ApiError && err.status === 402) {
- setStatus(`No credit: ${err.message}`, { href: SITE, label: 'Buy credits or sign in.' });
- return;
- }
- throw err;
- }
- await follow(pending);
- void refreshAccount();
- } catch (err) {
- if ((err as Error).name !== 'AbortError') setStatus(`Server job failed: ${String(err)}`);
- } finally {
- setBusy(false);
- }
- });
-
- // --- In-app job ---
-
- function phaseLine(s: JobStatus): string {
- let line = `${s.phase}: ${s.detail}`;
- if (s.eta) line += `, about ${Math.round(s.eta / 60)} min left`;
- return line;
- }
-
- ui.local.addEventListener('click', async () => {
- const audio = state.audio;
- if (!audio || state.busy) return;
- setBusy(true);
- let wakeLock: WakeLockSentinel | null = null;
- try {
- setStatus('preparing: loading the engine');
- setProgress(0);
- const { Job, Cancelled } = await import('./engine/job.js');
- const job = new Job({
- audio: [audio],
- paragraphs: paragraphs(bookText(hooks.tokens())),
- language: ui.lang.value,
- assets: assets(),
- onStatus: (s) => {
- setStatus(phaseLine(s));
- setProgress(s.indeterminate ? null : s.fraction);
- },
- });
- state.job = job;
- try {
- wakeLock = (await navigator.wakeLock?.request('screen')) ?? null;
- } catch {
- // Not granted. The user keeps the screen on by hand.
- }
- try {
- const result = await job.run();
- const pct = Math.round(result.matchRate * 100);
- await finish(result.srt, audioKey(audio), audio.name, `this app (${pct}% matched)`);
- } catch (err) {
- setStatus(err instanceof Cancelled ? 'Stopped.' : `In-app job failed: ${String(err)}`);
- } finally {
- job.close();
- }
- } catch (err) {
- setStatus(`Cannot load the engine: ${String(err)}`);
- } finally {
- state.job = null;
- await wakeLock?.release().catch(() => {});
- setBusy(false);
- }
- });
-
- ui.stop.addEventListener('click', async () => {
- if (state.job) {
- state.job.cancel();
- return;
- }
- const raw = read(KEYS.job);
- state.abort?.abort();
- if (raw) {
- const pending = JSON.parse(raw) as PendingJob;
- write(KEYS.job, null);
- try {
- await cancelJob(http(), pending.id);
- setStatus('Server job canceled.');
- } catch (err) {
- setStatus(`Cancel failed: ${String(err)}`);
- }
- }
- setBusy(false);
- });
-
- // --- Start ---
-
- const raw = read(KEYS.job);
- if (raw) {
- const pending = JSON.parse(raw) as PendingJob;
- setStatus(`Resuming the server job for ${pending.audioName}...`);
- setBusy(true);
- follow(pending)
- .catch((err: unknown) => {
- if ((err as Error).name !== 'AbortError') setStatus(`Server job failed: ${String(err)}`);
- })
- .finally(() => setBusy(false));
- }
- void refreshAccount();
- updateButtons();
-
- return {
- onTokens() {
- ui.lang.value = whisperCode(hooks.ocrLang());
- updateButtons();
- },
- };
-}
diff --git a/src/book-text.ts b/src/book-text.ts
index b285f0f..170b668 100644
--- a/src/book-text.ts
+++ b/src/book-text.ts
@@ -1,8 +1,7 @@
-// The OCR text as a plain-text book. Pure: no DOM.
+// The page text as a plain-text book. Pure: no DOM.
//
-// Both aligners (the subread.space server and the in-app engine) read the
-// book as text lines. This module turns the OCR tokens into that text and
-// back into the paragraph list the in-app engine takes.
+// SubRead reads the book as text lines when it makes the subtitles. This
+// module turns the page tokens (OCR or text layer) into that text.
import type { OcrToken } from './align';
@@ -75,19 +74,6 @@ export function bookText(tokens: readonly OcrToken[]): string {
return pages.map((p) => p.join('\n')).join('\n\n') + (pages.length > 0 ? '\n' : '');
}
-/** The non-blank lines of a book text, trimmed. This is what the aligner takes. */
-export function paragraphs(text: string): string[] {
- return text
- .split('\n')
- .map((l) => l.trim())
- .filter((l) => l.length > 0);
-}
-
-/** The book as a file for the upload. The server pairs it with the audio by suffix. */
-export function bookFile(tokens: readonly OcrToken[]): File {
- return new File([bookText(tokens)], 'book.txt', { type: 'text/plain' });
-}
-
/** The Whisper language code for a tesseract language ("jpn", "jpn_vert", "jpn+eng", "eng"). */
export function whisperCode(ocrLang: string): 'ja' | 'en' {
return ocrLang.startsWith('jpn') ? 'ja' : 'en';
diff --git a/src/cloud.ts b/src/cloud.ts
deleted file mode 100644
index 00f6411..0000000
--- a/src/cloud.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-// Client for the subread.space job API. Pure over an injected `Http`, so the
-// flow is testable with a fake transport.
-//
-// Flow: POST /api/uploads (audio + book.txt) -> POST /api/jobs/{id}/start
-// -> poll GET /api/jobs/{id} -> GET /api/jobs/{id}/files/srt.
-// Identity is the `subplz_device` cookie that the server sets on the first
-// response. The transport keeps it (browser cookie jar or native cookie store).
-
-export interface HttpRequest {
- method: 'GET' | 'POST' | 'DELETE';
- /** Path under the server base, such as "/api/account". */
- path: string;
- /** JSON body. */
- json?: unknown;
- /** Multipart body: each file goes in the `files` field. */
- files?: File[];
-}
-
-export interface HttpResponse {
- status: number;
- /** The body as text. JSON is parsed by the caller. */
- text: string;
-}
-
-export type Http = (req: HttpRequest) => Promise<HttpResponse>;
-
-/** A non-2xx answer. `status` 402 means: no credit. */
-export class ApiError extends Error {
- constructor(
- public readonly status: number,
- message: string,
- ) {
- super(message);
- this.name = 'ApiError';
- }
-}
-
-export interface JobOut {
- id: string;
- /** draft, queued, running, succeeded, failed or canceled. */
- status: string;
- stage: string;
- progress: number;
- language: string;
- error: string | null;
- artifacts: { kind: string; filename: string; url: string }[];
-}
-
-export interface AccountOut {
- id: string;
- signed_in: boolean;
- email: string | null;
- credits: number;
- free_credits: number;
- cloud_allowed: boolean;
- billing_enabled: boolean;
- email_sign_in_available: boolean;
-}
-
-export const DONE = new Set(['succeeded', 'failed', 'canceled']);
-
-/** Where to buy credits or sign in. */
-export const SITE = 'https://subread.space';
-
-/** Read the JSON of a 2xx answer. Throw ApiError with the server's `detail` otherwise. */
-async function call<T>(http: Http, req: HttpRequest): Promise<T> {
- const res = await http(req);
- if (res.status < 200 || res.status >= 300) {
- let detail = `HTTP ${res.status}`;
- try {
- const body = JSON.parse(res.text) as { detail?: unknown };
- if (typeof body.detail === 'string') detail = body.detail;
- else if (body.detail !== undefined) detail = JSON.stringify(body.detail);
- } catch {
- // Not JSON. Keep the status line.
- }
- throw new ApiError(res.status, detail);
- }
- return JSON.parse(res.text) as T;
-}
-
-export async function upload(http: Http, audio: File, book: File): Promise<JobOut> {
- const out = await call<{ job: JobOut }>(http, {
- method: 'POST',
- path: '/api/uploads',
- files: [audio, book],
- });
- return out.job;
-}
-
-export function startJob(http: Http, id: string, language: string): Promise<JobOut> {
- return call<JobOut>(http, { method: 'POST', path: `/api/jobs/${id}/start`, json: { language } });
-}
-
-export function getJob(http: Http, id: string): Promise<JobOut> {
- return call<JobOut>(http, { method: 'GET', path: `/api/jobs/${id}` });
-}
-
-export function cancelJob(http: Http, id: string): Promise<JobOut> {
- return call<JobOut>(http, { method: 'POST', path: `/api/jobs/${id}/cancel`, json: {} });
-}
-
-export function deleteJob(http: Http, id: string): Promise<unknown> {
- return call<unknown>(http, { method: 'DELETE', path: `/api/jobs/${id}` });
-}
-
-export interface PollOptions {
- intervalMs: number;
- onStatus?: (job: JobOut) => void;
- /** Injectable for tests. Defaults to setTimeout. */
- sleep?: (ms: number) => Promise<void>;
- signal?: AbortSignal;
-}
-
-const defaultSleep = (ms: number): Promise<void> =>
- new Promise((resolve) => setTimeout(resolve, ms));
-
-/**
- * Poll the job until it is succeeded, failed or canceled. Each answer goes
- * to `onStatus`. An aborted signal stops the loop with an AbortError.
- */
-export async function pollUntilDone(http: Http, id: string, opts: PollOptions): Promise<JobOut> {
- const sleep = opts.sleep ?? defaultSleep;
- for (;;) {
- if (opts.signal?.aborted) throw new DOMException('Polling stopped.', 'AbortError');
- const job = await getJob(http, id);
- opts.onStatus?.(job);
- if (DONE.has(job.status)) return job;
- await sleep(opts.intervalMs);
- }
-}
-
-/** The finished SRT. The server may redirect to a presigned URL. The transport follows it. */
-export async function fetchSrt(http: Http, id: string): Promise<string> {
- const res = await http({ method: 'GET', path: `/api/jobs/${id}/files/srt` });
- if (res.status < 200 || res.status >= 300) throw new ApiError(res.status, `HTTP ${res.status}`);
- return res.text;
-}
-
-export function getAccount(http: Http): Promise<AccountOut> {
- return call<AccountOut>(http, { method: 'GET', path: '/api/account' });
-}
-
-export function requestSignIn(
- http: Http,
- email: string,
-): Promise<{ sent: boolean; email: string; dev_link?: string }> {
- return call(http, { method: 'POST', path: '/api/auth/request', json: { email } });
-}
-
-export function verifySignIn(http: Http, token: string): Promise<AccountOut> {
- return call<AccountOut>(http, { method: 'POST', path: '/api/auth/verify', json: { token } });
-}
-
-/**
- * The token out of what the user pasted: a sign-in link
- * (https://subread.space/?login=TOKEN) or the bare token.
- */
-export function tokenFrom(input: string): string {
- const s = input.trim();
- try {
- const url = new URL(s);
- return url.searchParams.get('login') ?? s;
- } catch {
- return s;
- }
-}
-
-/** One line for the account area of the drawer. */
-export function accountLine(a: AccountOut): string {
- const who = a.signed_in && a.email ? `Signed in as ${a.email}.` : 'Not signed in.';
- const credits = a.billing_enabled ? ` Credits: ${a.credits} (${a.free_credits} free).` : '';
- return who + credits;
-}
diff --git a/src/deinflect.ts b/src/deinflect.ts
deleted file mode 100644
index ddecf6b..0000000
--- a/src/deinflect.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-// Japanese deinflection. Pure: no DOM, no dictionary.
-//
-// Input: an inflected surface form such as 食べました.
-// Output: candidate dictionary forms such as 食べる, with the chain of
-// rules that produced each one. The input itself is always a candidate.
-// The dictionary decides which candidates are real words.
-//
-// Each rule replaces a suffix. A rule that ends a chain names the part of
-// speech of its output (`pos`), so the caller can check it against the
-// `rules` field of a dictionary term. A rule with no `pos` maps one
-// inflection to a simpler inflection (ました → ます) and the chain goes on.
-
-export type Pos = 'v1' | 'v5' | 'vs' | 'vk' | 'adj-i';
-
-export interface Rule {
- name: string;
- from: string;
- to: string;
- pos?: Pos;
-}
-
-export interface Candidate {
- term: string;
- /** Names of the rules applied, in order. Empty for the input itself. */
- rules: string[];
- /** Part of speech that the term must have. Undefined: no constraint. */
- pos?: Pos;
-}
-
-/** Longest chain of rules. */
-const MAX_DEPTH = 3;
-
-/** Godan verb rows: the final kana and its a/i/e/o stems and past form. */
-const GODAN = [
- { end: 'う', a: 'わ', i: 'い', e: 'え', o: 'お', ta: 'った' },
- { end: 'く', a: 'か', i: 'き', e: 'け', o: 'こ', ta: 'いた' },
- { end: 'ぐ', a: 'が', i: 'ぎ', e: 'げ', o: 'ご', ta: 'いだ' },
- { end: 'す', a: 'さ', i: 'し', e: 'せ', o: 'そ', ta: 'した' },
- { end: 'つ', a: 'た', i: 'ち', e: 'て', o: 'と', ta: 'った' },
- { end: 'ぬ', a: 'な', i: 'に', e: 'ね', o: 'の', ta: 'んだ' },
- { end: 'ぶ', a: 'ば', i: 'び', e: 'べ', o: 'ぼ', ta: 'んだ' },
- { end: 'む', a: 'ま', i: 'み', e: 'め', o: 'も', ta: 'んだ' },
- { end: 'る', a: 'ら', i: 'り', e: 'れ', o: 'ろ', ta: 'った' },
-];
-
-/** The te form of a past form: た → て, だ → で. */
-function teOf(ta: string): string {
- return ta.slice(0, -1) + (ta.endsWith('だ') ? 'で' : 'て');
-}
-
-function godanRules(): Rule[] {
- const rules: Rule[] = [];
- for (const g of GODAN) {
- const r = (name: string, from: string): Rule => ({ name, from, to: g.end, pos: 'v5' });
- rules.push(
- r('negative', g.a + 'ない'),
- r('passive', g.a + 'れる'),
- r('causative', g.a + 'せる'),
- r('polite', g.i + 'ます'),
- r('tai', g.i + 'たい'),
- r('potential', g.e + 'る'),
- r('ba', g.e + 'ば'),
- r('imperative', g.e),
- r('volitional', g.o + 'う'),
- r('past', g.ta),
- r('te', teOf(g.ta)),
- r('tara', g.ta + 'ら'),
- );
- }
- return rules;
-}
-
-function ichidanRules(): Rule[] {
- const r = (name: string, from: string): Rule => ({ name, from, to: 'る', pos: 'v1' });
- return [
- r('negative', 'ない'),
- r('passive', 'られる'),
- r('potential', 'られる'),
- r('causative', 'させる'),
- r('polite', 'ます'),
- r('tai', 'たい'),
- r('ba', 'れば'),
- r('imperative', 'ろ'),
- r('volitional', 'よう'),
- r('past', 'た'),
- r('te', 'て'),
- r('tara', 'たら'),
- ];
-}
-
-function adjectiveRules(): Rule[] {
- const r = (name: string, from: string): Rule => ({ name, from, to: 'い', pos: 'adj-i' });
- return [
- r('ku', 'く'),
- r('negative', 'くない'),
- r('past', 'かった'),
- r('te', 'くて'),
- r('ba', 'ければ'),
- ];
-}
-
-/** Irregular verbs and one common exception of the godan past rule. */
-function irregularRules(): Rule[] {
- const rules: Rule[] = [];
- const add = (to: string, pos: Pos, forms: Record<string, string>) => {
- for (const [name, from] of Object.entries(forms)) rules.push({ name, from, to, pos });
- };
- add('行く', 'v5', { past: '行った', te: '行って', tara: '行ったら' });
- add('する', 'vs', {
- past: 'した',
- te: 'して',
- negative: 'しない',
- polite: 'します',
- volitional: 'しよう',
- });
- add('来る', 'vk', { past: '来た', te: '来て', negative: '来ない', polite: '来ます' });
- add('くる', 'vk', { past: 'きた', te: 'きて', negative: 'こない', polite: 'きます' });
- return rules;
-}
-
-/** Rules that map one inflection to a simpler one. The chain goes on. */
-const CHAIN_RULES: Rule[] = [
- { name: 'polite past', from: 'ました', to: 'ます' },
- { name: 'polite negative', from: 'ません', to: 'ます' },
- { name: 'polite negative past', from: 'ませんでした', to: 'ます' },
- { name: 'negative past', from: 'なかった', to: 'ない' },
- { name: 'negative te', from: 'なくて', to: 'ない' },
- { name: 'negative ba', from: 'なければ', to: 'ない' },
- { name: 'progressive', from: 'ている', to: 'て' },
- { name: 'progressive', from: 'でいる', to: 'で' },
-];
-
-export const RULES: readonly Rule[] = [
- ...CHAIN_RULES,
- ...irregularRules(),
- ...ichidanRules(),
- ...godanRules(),
- ...adjectiveRules(),
-];
-
-/**
- * All candidate dictionary forms of `word`, the input first. No candidate
- * appears twice. The stem may be empty: the irregular rules (行った, した)
- * match the whole word.
- */
-export function deinflect(word: string): Candidate[] {
- const out: Candidate[] = [{ term: word, rules: [] }];
- const seen = new Set<string>([word]);
- let frontier: Candidate[] = out;
- for (let depth = 0; depth < MAX_DEPTH; depth++) {
- const next: Candidate[] = [];
- for (const c of frontier) {
- for (const rule of RULES) {
- if (!c.term.endsWith(rule.from)) continue;
- const term = c.term.slice(0, -rule.from.length) + rule.to;
- if (seen.has(term)) continue;
- seen.add(term);
- const cand: Candidate = { term, rules: [...c.rules, rule.name] };
- if (rule.pos) cand.pos = rule.pos;
- next.push(cand);
- }
- }
- out.push(...next);
- frontier = next;
- }
- return out;
-}
diff --git a/src/dictdb.ts b/src/dictdb.ts
deleted file mode 100644
index e0e54b6..0000000
--- a/src/dictdb.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-// Dictionary storage in IndexedDB, through `idb`. Thin browser layer.
-//
-// Store `dictionaries`: one row per imported dictionary.
-// Store `terms`: one row per term, with indexes on expression, reading
-// and dictionary id. Terms persist across app restarts.
-
-import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
-import type { TermFinder } from './lookup';
-import { openDictionaryZip, type DictTerm } from './yomitan';
-
-export interface DictionaryRow {
- id: number;
- title: string;
- revision: string;
- termCount: number;
-}
-
-interface TermRow extends DictTerm {
- dict: number;
-}
-
-interface Schema extends DBSchema {
- dictionaries: { key: number; value: DictionaryRow; indexes: { title: string } };
- terms: {
- key: number;
- value: TermRow;
- indexes: { expression: string; reading: string; dict: number };
- };
-}
-
-let dbPromise: Promise<IDBPDatabase<Schema>> | undefined;
-
-function db(): Promise<IDBPDatabase<Schema>> {
- dbPromise ??= openDB<Schema>('scan-subread', 1, {
- upgrade(d) {
- const dicts = d.createObjectStore('dictionaries', { keyPath: 'id', autoIncrement: true });
- dicts.createIndex('title', 'title');
- const terms = d.createObjectStore('terms', { autoIncrement: true });
- terms.createIndex('expression', 'expression');
- terms.createIndex('reading', 'reading');
- terms.createIndex('dict', 'dict');
- },
- });
- return dbPromise;
-}
-
-export async function listDictionaries(): Promise<DictionaryRow[]> {
- return (await db()).getAll('dictionaries');
-}
-
-/**
- * Import one Yomitan dictionary zip. One term bank is parsed and written
- * per transaction, so memory holds one bank at a time. `onProgress` gets
- * a status line after each bank.
- */
-export async function importDictionary(
- zip: Uint8Array,
- onProgress: (status: string) => void,
-): Promise<DictionaryRow> {
- const dict = openDictionaryZip(zip);
- const d = await db();
- const { title, revision } = dict.index;
- if (await d.getFromIndex('dictionaries', 'title', title)) {
- throw new Error(`"${title}" is already imported. Delete it first to import it again.`);
- }
- // The store fills in `id`, so the row goes in without it.
- const id = await d.add('dictionaries', { title, revision, termCount: 0 } as DictionaryRow);
- let termCount = 0;
- for (let i = 0; i < dict.termBanks.length; i++) {
- onProgress(
- `Importing ${title}: bank ${i + 1} of ${dict.termBanks.length}, ${termCount} terms...`,
- );
- const terms = dict.readTermBank(dict.termBanks[i]!);
- const tx = d.transaction('terms', 'readwrite');
- for (const term of terms) void tx.store.add({ ...term, dict: id });
- await tx.done;
- termCount += terms.length;
- }
- const row: DictionaryRow = { id, title, revision, termCount };
- await d.put('dictionaries', row);
- return row;
-}
-
-/** Delete a dictionary and all its terms. */
-export async function deleteDictionary(id: number): Promise<void> {
- const d = await db();
- const tx = d.transaction(['terms', 'dictionaries'], 'readwrite');
- let cursor = await tx.objectStore('terms').index('dict').openKeyCursor(id);
- while (cursor) {
- await tx.objectStore('terms').delete(cursor.primaryKey);
- cursor = await cursor.continue();
- }
- await tx.objectStore('dictionaries').delete(id);
- await tx.done;
-}
-
-/** Terms whose expression or reading equals the key, with the dictionary title. */
-export interface FoundTerm extends DictTerm {
- dictTitle: string;
-}
-
-/** A finder over the stored terms. Titles are read once at creation. */
-export async function storedFinder(): Promise<TermFinder<FoundTerm>> {
- const d = await db();
- const titles = new Map((await d.getAll('dictionaries')).map((r) => [r.id, r.title]));
- return async (key) => {
- const [byExpression, byReading] = await Promise.all([
- d.getAllFromIndex('terms', 'expression', key),
- d.getAllFromIndex('terms', 'reading', key),
- ]);
- const rows = [...byExpression, ...byReading.filter((r) => r.expression !== key)];
- return rows.map((r): FoundTerm => ({ ...r, dictTitle: titles.get(r.dict) ?? '' }));
- };
-}
diff --git a/src/engine/align.d.ts b/src/engine/align.d.ts
deleted file mode 100644
index 2d2ec02..0000000
--- a/src/engine/align.d.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-// Types for the parts of align.js that the app uses.
-
-/** One timed piece of text: a transcript segment or a subtitle cue. */
-export interface Segment {
- text: string;
- start: number;
- end: number;
-}
-
-export interface Lang {
- code: string;
- translate(s: string): string;
- clean(s: string): string;
-}
-
-export interface BookAlignment {
- cues: Segment[];
- paragraphsUsed: number;
- paragraphsDropped: number;
- /** Share of cues whose text comes from the book, in [0, 1]. */
- matchRate: number;
-}
-
-export function language(code: string): Lang;
-export function alignBook(
- transcript: readonly Segment[],
- paragraphs: readonly string[],
- lang: Lang,
-): BookAlignment;
-export function writeSrt(cues: readonly Segment[]): string;
-export const UNMATCHED: string;
diff --git a/src/engine/align.js b/src/engine/align.js
deleted file mode 100644
index 796d9b8..0000000
--- a/src/engine/align.js
+++ /dev/null
@@ -1,608 +0,0 @@
-/* Copied from equwal/subplz-web frontend/engine (AGPL-3.0, same author). */
-/* The alignment engine, in the browser.
- *
- * A transcript with times and a book without them go in; subtitles worded by
- * the book and timed by the transcript come out.
- *
- * This is a port of SubPlz's aligner (ats.align, subplz.align.shift_align),
- * kept statement-for-statement where it is a port, quirks included: the tests
- * in tests/engine run it against the reference implementation's real output,
- * stage by stage, and it is only trustworthy while it reproduces that exactly.
- *
- * What is not a port is anchoredAlign. The reference aligns one chapter at a
- * time in a full dynamic-programming table and needs gigabytes to do it. This
- * aligns the whole book at once by anchoring on stretches unique to both texts
- * and solving exactly only between anchors - seconds and megabytes, which is
- * what makes doing it in a browser tab possible at all.
- *
- * Offsets are code points throughout, never UTF-16 units, so a slice cannot
- * split a surrogate pair however the text was cut.
- */
-
-export const toCodePoints = (s) => Int32Array.from(s, (ch) => ch.codePointAt(0));
-
-export function fromCodePoints(cps, from = 0, to = cps.length) {
- if (to <= from) return '';
- let out = '';
- for (let i = from; i < to; i += 8192) {
- out += String.fromCodePoint(...cps.subarray(i, Math.min(to, i + 8192)));
- }
- return out;
-}
-
-/* ------------------------------------------------------------------ language */
-
-const JA = (() => {
- const map = new Map();
- // Katakana to hiragana: the recogniser picks between them at random.
- for (let i = 0; i < 0x56; i++) map.set(0x30a1 + i, 0x3041 + i);
- // Kanji numerals to digits. The digit string is shorter and wraps, which is
- // how both 十 and 拾 come out as 1.
- const kansuu = toCodePoints('一二三四五六七八九十〇零壱弐参肆伍陸漆捌玖拾');
- const arabic = toCodePoints('123456789100');
- kansuu.forEach((cp, i) => map.set(cp, arabic[i % arabic.length]));
- // ASCII to full width, so "A" in one text meets "A" in the other.
- for (let cp = 0x21; cp < 0x7f; cp++) map.set(cp, cp + 0xfee0);
- // Particles written one way and pronounced another.
- for (const [a, b] of [['は', 'わ'], ['あ', 'わ'], ['お', 'を'], ['へ', 'え']]) {
- map.set(a.codePointAt(0), b.codePointAt(0));
- }
- return map;
-})();
-
-// Everything that is not a letter or digit goes, except 。 when it leads a run.
-const JA_NOISE = /(?![。])[\p{C}\p{M}\p{P}\p{S}\p{Z}\sー々ゝ]+/gu;
-// Collapse repeats: long vowels and stutters are where the two texts disagree.
-const JA_REPEATS = /(.)(?=\1+)/gu;
-
-/** Only Japanese has rules of its own; everything else is case-folded. */
-export function language(code) {
- const japanese = code === 'ja';
- const translate = (s) => {
- if (!japanese) return s.toLowerCase();
- let out = '';
- for (const ch of s) {
- const cp = ch.codePointAt(0);
- out += JA.has(cp) ? String.fromCodePoint(JA.get(cp)) : ch;
- }
- return out.toLowerCase();
- };
- const clean = japanese
- ? (s) => translate(s).replace(JA_NOISE, '').replace(JA_REPEATS, '')
- : translate;
- return { code, translate, clean };
-}
-
-/* --------------------------------------------------------------------- gotoh */
-
-// The reference's scores, times ten so ties are exact.
-const MATCH = 10, MISMATCH = -6, OPEN = -8, EXTEND = -5;
-const NEG = -(2 ** 29);
-
-export const cells = (n, m) => (n + 1) * (m + 1);
-
-/** Exact global alignment with affine gaps. Returns {score, t, q}: a path of breakpoints. */
-export function gotoh(target, query) {
- const n = target.length, m = query.length, width = m + 1;
- const trace = new Uint8Array(cells(n, m));
- let pm = new Int32Array(width).fill(NEG), px = new Int32Array(width).fill(NEG),
- py = new Int32Array(width).fill(NEG);
- let cm = new Int32Array(width), cx = new Int32Array(width), cy = new Int32Array(width);
-
- pm[0] = 0;
- for (let j = 1; j <= m; j++) { py[j] = OPEN + (j - 1) * EXTEND; trace[j] = j === 1 ? 0 : 32; }
-
- for (let i = 1; i <= n; i++) {
- const row = i * width, t = target[i - 1];
- cm[0] = NEG; cy[0] = NEG; cx[0] = OPEN + (i - 1) * EXTEND;
- trace[row] = i === 1 ? 0 : 4;
- for (let j = 1; j <= m; j++) {
- let bits = 0, best = pm[j - 1];
- if (px[j - 1] > best) { best = px[j - 1]; bits = 1; }
- if (py[j - 1] > best) { best = py[j - 1]; bits = 2; }
- cm[j] = best + (t === query[j - 1] ? MATCH : MISMATCH);
-
- let bx = pm[j] + OPEN, xb = 0;
- if (px[j] + EXTEND > bx) { bx = px[j] + EXTEND; xb = 4; }
- if (py[j] + OPEN > bx) { bx = py[j] + OPEN; xb = 8; }
- cx[j] = bx;
-
- let by = cm[j - 1] + OPEN, yb = 0;
- if (cx[j - 1] + OPEN > by) { by = cx[j - 1] + OPEN; yb = 16; }
- if (cy[j - 1] + EXTEND > by) { by = cy[j - 1] + EXTEND; yb = 32; }
- cy[j] = by;
-
- trace[row + j] = bits | xb | yb;
- }
- [pm, cm] = [cm, pm]; [px, cx] = [cx, px]; [py, cy] = [cy, py];
- }
-
- let state = 0, score = pm[m];
- if (px[m] > score) { score = px[m]; state = 1; }
- if (py[m] > score) { score = py[m]; state = 2; }
-
- const ts = [n], qs = [m];
- let i = n, j = m, last = -1;
- while (i > 0 || j > 0) {
- if (last !== -1 && last !== state) { ts.push(i); qs.push(j); }
- last = state;
- const bits = trace[i * width + j];
- if (state === 0) { state = bits & 3; i--; j--; }
- else if (state === 1) { state = (bits >> 2) & 3; i--; }
- else { state = (bits >> 4) & 3; j--; }
- }
- if (ts[ts.length - 1] !== 0 || qs[qs.length - 1] !== 0) { ts.push(0); qs.push(0); }
- return { score, t: ts.reverse(), q: qs.reverse() };
-}
-
-export function pathScore(target, query, path) {
- let total = 0;
- for (let k = 1; k < path.t.length; k++) {
- const dt = path.t[k] - path.t[k - 1], dq = path.q[k] - path.q[k - 1];
- if (dt > 0 && dq > 0) {
- for (let d = 0; d < dt; d++) {
- total += target[path.t[k - 1] + d] === query[path.q[k - 1] + d] ? MATCH : MISMATCH;
- }
- } else if (dt > 0 || dq > 0) total += OPEN + (Math.max(dt, dq) - 1) * EXTEND;
- }
- return total;
-}
-
-/* ------------------------------------------------------------------ anchored */
-
-// FNV-1a over code points, folded to 53 bits so it is an exact JS number.
-function gramHash(seq, at, k) {
- let lo = 0x811c9dc5, hi = 0x1000193;
- for (let d = 0; d < k; d++) {
- const c = seq[at + d];
- lo = Math.imul(lo ^ c, 0x01000193) >>> 0;
- hi = Math.imul(hi ^ (c >>> 3), 0x27d4eb2f) >>> 0;
- }
- return (hi & 0x1fffff) * 4294967296 + lo;
-}
-
-/** k-gram -> its position, or -2 when it occurs more than once. */
-function uniqueGrams(seq, from, to, k) {
- const map = new Map();
- for (let i = from; i <= to - k; i++) {
- const h = gramHash(seq, i, k);
- map.set(h, map.has(h) ? -2 : i);
- }
- return map;
-}
-
-function anchors(target, query, t0, t1, q0, q1, k) {
- if (t1 - t0 < k || q1 - q0 < k) return [];
- const inTarget = uniqueGrams(target, t0, t1, k);
- const inQuery = uniqueGrams(query, q0, q1, k);
- const runs = [];
- let current = null;
- for (let qi = q0; qi <= q1 - k; qi++) {
- const h = gramHash(query, qi, k);
- const ti = inQuery.get(h) === qi ? inTarget.get(h) : undefined;
- let same = ti !== undefined && ti >= 0;
- for (let d = 0; same && d < k; d++) same = target[ti + d] === query[qi + d];
- if (!same) { current = null; continue; }
- if (current && ti === current.t + (qi - current.q)) current.len = qi - current.q + k;
- else runs.push(current = { t: ti, q: qi, len: k });
- }
- return runs;
-}
-
-/** Longest chain of runs in order on both sides (patience sort), overlaps trimmed. */
-function chainOf(runs) {
- if (!runs.length) return runs;
- const byT = runs.slice().sort((a, b) => a.t - b.t);
- const tails = [], prev = new Int32Array(byT.length).fill(-1);
- byT.forEach((r, i) => {
- let lo = 0, hi = tails.length;
- while (lo < hi) { const mid = (lo + hi) >>> 1; if (byT[tails[mid]].q < r.q) lo = mid + 1; else hi = mid; }
- if (lo > 0) prev[i] = tails[lo - 1];
- tails[lo] = i;
- });
- const picked = [];
- for (let at = tails[tails.length - 1]; at >= 0; at = prev[at]) picked.push(byT[at]);
- picked.reverse();
-
- const out = [];
- let endT = 0, endQ = 0;
- for (const r of picked) {
- const cut = Math.max(endT - r.t, endQ - r.q, 0);
- if (cut >= r.len) continue;
- r.t += cut; r.q += cut; r.len -= cut;
- out.push(r);
- endT = r.t + r.len; endQ = r.q + r.len;
- }
- return out;
-}
-
-/** Global alignment of inputs too big for one table. Same shape of result as gotoh. */
-export function anchoredAlign(target, query, { exactCells = 4_000_000, firstK = 14, lastK = 5 } = {}) {
- const ts = [0], qs = [0];
- let lastKind = 0;
- const moveTo = (t, q) => {
- const pt = ts[ts.length - 1], pq = qs[qs.length - 1];
- if (t === pt && q === pq) return;
- const dt = t - pt, dq = q - pq;
- if (dt > 0 && dq > 0 && dt !== dq) { // a diagonal and a gap in one step
- const d = Math.min(dt, dq);
- moveTo(pt + d, pq + d); moveTo(t, q);
- return;
- }
- const kind = dt > 0 && dq > 0 ? 1 : dt > 0 ? 2 : 3;
- if (kind === lastKind && ts.length > 1) { ts[ts.length - 1] = t; qs[qs.length - 1] = q; }
- else { ts.push(t); qs.push(q); }
- lastKind = kind;
- };
-
- // An explicit stack: a book is thousands of ranges deep in places.
- const work = [[0, target.length, 0, query.length, firstK]];
- while (work.length) {
- const job = work.pop();
- if (job.length === 2) { moveTo(job[0], job[1]); continue; }
- const [t0, t1, q0, q1, k] = job;
- const n = t1 - t0, m = q1 - q0;
- if (n === 0 || m === 0) { moveTo(t1, q1); continue; }
- if (cells(n, m) <= exactCells) {
- const sub = gotoh(target.subarray(t0, t1), query.subarray(q0, q1));
- for (let i = 0; i < sub.t.length; i++) moveTo(t0 + sub.t[i], q0 + sub.q[i]);
- continue;
- }
-
- let kk = k, chain = [];
- for (; kk >= lastK; kk -= 3) {
- chain = chainOf(anchors(target, query, t0, t1, q0, q1, kk));
- if (chain.length) break;
- }
- const next = [];
- if (!chain.length) {
- // Two long stretches with nothing in common: halve both and carry on.
- const tm = t0 + (n >> 1), qm = q0 + (m >> 1);
- next.push([t0, tm, q0, qm, lastK], [tm, t1, qm, q1, lastK]);
- } else {
- let ct = t0, cq = q0;
- for (const r of chain) {
- next.push([ct, r.t, cq, r.q, kk], [r.t + r.len, r.q + r.len]);
- ct = r.t + r.len; cq = r.q + r.len;
- }
- next.push([ct, t1, cq, q1, kk]);
- }
- for (let i = next.length - 1; i >= 0; i--) work.push(next[i]);
- }
- moveTo(target.length, query.length);
- const path = { t: ts, q: qs };
- path.score = pathScore(target, query, path);
- return path;
-}
-
-/* ----------------------------------------------------------------- align_sub */
-
-const FULL_STOP = '。'.codePointAt(0);
-
-function distinctExceptFullStop(cps, start, end) {
- const seen = new Set();
- for (let k = start; k < Math.min(end, cps.length); k++) if (cps[k] !== FULL_STOP) seen.add(cps[k]);
- return seen.size;
-}
-
-/** Which piece of which paragraph each transcript segment covers. Spans are [start, end, sub]. */
-export function alignSub(path, text, subs, thing = 2) {
- let line = 0, sub = 0, toff = 0, off = 0;
- const pos = [0, 0], p = [0, 0], gaps = [0, 0];
- const segments = [[]];
- const last = () => segments[segments.length - 1];
-
- for (let i = 0; i < path.t.length; i++) {
- const c0 = path.t[i], c1 = path.q[i];
- const isGap = c0 - p[0] === 0 || c1 - p[1] === 0;
-
- while (sub < subs.length && pos[1] + subs[sub].length <= c1) {
- if (line >= text.length) return segments.slice(0, text.length);
- const subLen = subs[sub].length;
- pos[1] += subLen;
- if (isGap) { gaps[0] += Math.max(pos[1] - p[1], 0); gaps[1] += Math.max(pos[0] - p[0], 0); }
-
- const diff = subLen + gaps[1] - gaps[0];
- if (diff > Math.floor(subLen / 4)) {
- let target = toff + diff + off;
- off = 0;
- while (line < text.length && target >= text[line].length) {
- const start = toff, end = text[line].length;
- if (end - start !== 0) {
- const prev = last();
- if (end - start < thing || distinctExceptFullStop(text[line], start, end) < thing) {
- if (prev.length) prev[prev.length - 1][1] = end;
- else prev.push([start, end, sub]);
- } else prev.push([start, end, sub]);
- }
- segments.push([]);
- pos[0] += end - start;
- target -= text[line].length;
- line++;
- toff = 0;
- }
- pos[0] += target - toff;
- last().push([toff, target, sub]);
- toff = target;
- } else {
- const prev = last();
- if (toff >= Math.floor(text[line].length / 2) && prev.length) {
- prev[prev.length - 1][1] += diff;
- toff += diff;
- } else off += diff;
- }
-
- sub++;
- gaps[0] = 0; gaps[1] = 0;
- p[0] = Math.max(pos[0], p[0]); p[1] = Math.max(pos[1], p[1]);
- }
-
- if (isGap) { gaps[0] += c1 - p[1]; gaps[1] += c0 - p[0]; }
- p[0] = c0; p[1] = c1;
- }
- return segments.slice(0, text.length);
-}
-
-/** Spans were computed on cleaned text; map them back onto the original. */
-export function fix(lang, original, edited, segments) {
- segments.forEach((spans, l) => {
- const o = toCodePoints(lang.translate(original[l])), e = edited[l];
- const m = new Int32Array(e.length + 1).fill(-1);
- let ei = 0;
- for (let oi = 0; oi < o.length; oi++) {
- if (ei < e.length && o[oi] === e[ei]) { m[ei] = oi; ei++; }
- }
- m[ei] = o.length;
- m[0] = 0;
- let lastSet = 0;
- for (let i = 0; i < e.length; i++) { if (m[i] !== -1) lastSet = i; else m[i] = m[lastSet]; }
- if (m[e.length] === -1) m[e.length] = o.length;
- const clamp = (v) => Math.min(Math.max(v, 0), e.length);
- for (const f of spans) { f[0] = m[clamp(f[0])]; f[1] = m[clamp(f[1])]; }
- });
-}
-
-/** Python indexing: negatives count from the end; out of range is "no character". */
-const at = (t, i) => { const k = i < 0 ? i + t.length : i; return k >= 0 && k < t.length ? t[k] : -1; };
-
-/** Nudge span ends so punctuation stays with the words it belongs to. */
-export function fixPunc(text, segments, prepend, append, nopend) {
- segments.forEach((s, l) => {
- if (!s.length) return;
- const t = toCodePoints(text[l]);
- for (let k = 0; k < s.length; k++) {
- const p = s[k], f = k + 1 < s.length ? s[k + 1] : s[k];
- const connected = f[0] === p[1];
- for (let loop = 0; loop <= 20; loop++) {
- if (p[1] < t.length && append.has(at(t, p[1]))) p[1] += 1;
- else if (prepend.has(at(t, p[1] - 1))) p[1] -= 1;
- else if ((p[1] > 0 && nopend.has(at(t, p[1] - 1))) ||
- (p[1] < t.length && nopend.has(at(t, p[1]))) ||
- (p[1] < t.length - 1 && nopend.has(at(t, p[1] + 1)))) {
- let start = p[1] - 1, end = p[1];
- if (p[1] < t.length - 1) {
- const here = at(t, p[1]);
- if ((nopend.has(at(t, p[1] + 1)) && 0x4e00 > here) || here > 0x9faf) end += 1;
- }
- while (start > 0 && nopend.has(at(t, start))) start -= 1;
- while (end < t.length - 1 && nopend.has(at(t, end))) end += 1;
-
- if (prepend.has(at(t, start))) { if (p[1] === start) break; p[1] = start; }
- else if (append.has(at(t, start))) { if (p[1] === start + 1) break; p[1] = start + 1; }
- else if (end < t.length && prepend.has(at(t, end))) { if (p[1] === end) break; p[1] = end; }
- else if (end < t.length && append.has(at(t, end))) { if (p[1] === end + 1) break; p[1] = end + 1; }
- else break;
- } else break;
- }
- if (connected) f[0] = p[1];
- }
- });
-}
-
-/* ---------------------------------------------------------------------- subs */
-
-export const START_PUNC = '『「((《⦅[{"\'“¿' + '\'“"¿([{-『「(〈《〔【{[⦅<<‘“〝※';
-export const END_PUNC = '\'"・.。!!??::”>>⦆)]}』」)〉》〕】}]’〟/\~〜~;;─―–-➡';
-export const OTHER_PUNC = '* ,,、…';
-export const NOPEND = 'うぁぃぅぇぉっゃゅょゎゕゖァィゥェォヵㇰヶㇱㇲッㇳㇴㇵㇶㇷㇷ゚ㇸㇹㇺャュョㇻㇼㇽㇾㇿヮ… ';
-export const UNMATCHED = '*';
-
-const cpSet = (s) => new Set(toCodePoints(s));
-export const PREPEND_SET = cpSet(START_PUNC);
-export const APPEND_SET = cpSet(END_PUNC + OTHER_PUNC);
-export const NOPEND_SET = cpSet(NOPEND);
-
-const PUNCT = new Set(START_PUNC + END_PUNC + OTHER_PUNC);
-const START = new Set(START_PUNC), END = new Set(END_PUNC);
-
-function pySlice(cps, a, b) {
- const n = cps.length, fixIndex = (v) => Math.min(Math.max(v < 0 ? v + n : v, 0), n);
- return fromCodePoints(cps, fixIndex(a), fixIndex(b));
-}
-
-/** One cue per transcript segment: timed by the recogniser, worded by the book. */
-export function toSubs(text, subs, alignment, offset = 0) {
- const flat = [];
- alignment.forEach((spans, line) => spans.forEach((span) => flat.push({ span, line })));
- const lines = text.map(toCodePoints);
- const out = [];
- let start = 0, end = 0;
- subs.forEach((s, si) => {
- while (end < flat.length && flat[end].span[2] === si) end++;
- let body = '';
- for (let k = start; k < end; k++) body += pySlice(lines[flat[k].line], flat[k].span[0], flat[k].span[1]);
- out.push({ text: body.trim() ? body : UNMATCHED + s.text, start: s.start + offset, end: s.end + offset });
- start = end;
- });
- return out;
-}
-
-const punctIndices = (s) => { const r = []; for (let i = 0; i < s.length; i++) if (PUNCT.has(s[i])) r.push(i); return r; };
-const countNonPunct = (s) => { let n = 0; for (let i = 0; i < s.length; i++) if (!PUNCT.has(s[i])) n++; return n; };
-const doubleComma = (a, b) => a[a.length - 1] === '、' && b[b.length - 1] === '、';
-
-/** Move a stranded character or two across a cue boundary. Cues are mutated, as in the original. */
-export function shiftAlign(segments) {
- const fresh = [];
- let startIndex = 0; // read by the second pass too, as in the original
- segments.forEach((segment, i) => {
- let text = segment.text;
- const idx = punctIndices(text);
- if (!idx.length) { fresh.push(segment); return; }
- startIndex = idx[0];
- const nonPunc = countNonPunct(text.slice(0, startIndex));
- if (nonPunc === 0 || countNonPunct(text.slice(startIndex + 1)) === 0) { fresh.push(segment); return; }
- const prev = fresh[fresh.length - 1];
- if (nonPunc <= 2 && i > 0 && fresh.length && !END.has(prev.text[prev.text.length - 1]) &&
- !doubleComma(prev.text, text.slice(0, startIndex + 1))) {
- prev.text += text.slice(0, startIndex + 1);
- text = text.slice(startIndex + 1);
- }
- fresh.push({ text, start: segment.start, end: segment.end });
- });
-
- const final = [];
- for (let i = 0; i < fresh.length; i++) {
- const segment = fresh[i];
- let text = segment.text;
- const idx = punctIndices(text);
- if (idx.length) {
- const lastIndex = idx[idx.length - 1], tail = text.slice(lastIndex);
- if (countNonPunct(tail) === 0 || tail.length === text.length) { final.push(segment); continue; }
- if (countNonPunct(tail) <= 2 && i + 1 < fresh.length && !END.has(fresh[i + 1].text[0]) &&
- !doubleComma(fresh[i + 1].text.slice(0, 1), text.slice(0, startIndex + 1))) {
- // The original reaches into the *input* list here. Kept: the fixtures depend on it.
- const next = segments[i + 1];
- next.text = text.slice(lastIndex + 1) + next.text;
- text = text.slice(0, lastIndex + 1);
- final.push({ text, start: segment.start, end: segment.end });
- fresh[i + 1] = next;
- continue;
- }
- }
- final.push({ text, start: segment.start, end: segment.end });
- }
-
- if (final.length >= 2) {
- const stray = /(」「(.{1,2})、$|」「(.{1})、$)/u;
- const adjusted = [];
- final.forEach((segment, i) => {
- const m = stray.exec(segment.text);
- if (m && i < final.length - 1) {
- final[i + 1].text = m[0] + final[i + 1].text;
- segment.text = segment.text.slice(0, m.index);
- }
- if (segment.text && END.has(segment.text[0]) && i > 0) {
- adjusted[adjusted.length - 1].text += segment.text[0];
- segment.text = segment.text.slice(1);
- }
- if (segment.text && START.has(segment.text[segment.text.length - 1]) && i < final.length - 1) {
- final[i + 1].text = segment.text[segment.text.length - 1] + final[i + 1].text;
- segment.text = segment.text.slice(0, -1);
- }
- adjusted.push(segment);
- });
- }
- return final.map((c) => ({ text: c.text.trim(), start: c.start, end: c.end }));
-}
-
-/* ------------------------------------------------------------------ pipeline */
-
-const EXACT_CELL_LIMIT = 64_000_000;
-
-function concat(parts) {
- const out = new Int32Array(parts.reduce((n, p) => n + p.length, 0));
- let o = 0;
- for (const p of parts) { out.set(p, o); o += p.length; }
- return out;
-}
-
-export function align(transcript, paragraphs, lang, { anchoredOnly = false, exactCells } = {}) {
- if (!transcript.length) return [];
- const subsClean = transcript.map((s) => toCodePoints(lang.clean(s.text)));
- const textClean = paragraphs.map((p) => toCodePoints(lang.clean(p)));
- const query = concat(subsClean), target = concat(textClean);
- if (!target.length || !query.length) {
- return transcript.map((s) => ({ text: UNMATCHED + s.text, start: s.start, end: s.end }));
- }
- const path = !anchoredOnly && cells(target.length, query.length) <= EXACT_CELL_LIMIT
- ? gotoh(target, query) : anchoredAlign(target, query, exactCells ? { exactCells } : {});
- const spans = alignSub(path, textClean, subsClean);
- fix(lang, paragraphs, textClean, spans);
- fixPunc(paragraphs, spans, PREPEND_SET, APPEND_SET, NOPEND_SET);
- return shiftAlign(toSubs(paragraphs, transcript, spans));
-}
-
-// A paragraph counts as heard when this share of it agrees with the transcript...
-const HEARD = 0.20;
-// ...and unheard ones are dropped only in runs this long: one mangled heading
-// was still read aloud; a page of nothing was not.
-const MIN_UNREAD_RUN = 240;
-
-/** The paragraphs that were actually narrated: front matter, notes and the like removed. */
-export function narratedOnly(transcript, paragraphs, lang) {
- const textClean = paragraphs.map((p) => toCodePoints(lang.clean(p)));
- const target = concat(textClean);
- const query = concat(transcript.map((s) => toCodePoints(lang.clean(s.text))));
- if (!target.length || !query.length) return paragraphs;
-
- const path = anchoredAlign(target, query);
- const agreed = new Uint8Array(target.length);
- for (let k = 1; k < path.t.length; k++) {
- const t0 = path.t[k - 1], q0 = path.q[k - 1], dt = path.t[k] - t0;
- if (dt > 0 && path.q[k] - q0 > 0) {
- for (let d = 0; d < dt; d++) agreed[t0 + d] = target[t0 + d] === query[q0 + d] ? 1 : 0;
- }
- }
-
- let o = 0;
- const heard = textClean.map((cps) => {
- let hits = 0;
- for (let i = o; i < o + cps.length; i++) hits += agreed[i];
- o += cps.length;
- return cps.length === 0 || hits >= HEARD * cps.length;
- });
-
- const keep = paragraphs.map(() => true);
- for (let i = 0; i < paragraphs.length;) {
- if (heard[i]) { i++; continue; }
- let j = i, chars = 0;
- while (j < paragraphs.length && (!heard[j] || textClean[j].length === 0)) { chars += textClean[j].length; j++; }
- if (chars >= MIN_UNREAD_RUN) for (let k = i; k < j; k++) keep[k] = false;
- i = j;
- }
- const out = paragraphs.filter((_, k) => keep[k]);
- return out.length ? out : paragraphs;
-}
-
-/** A whole book at once. No chapter matching: the alignment itself says what was read. */
-export function alignBook(transcript, paragraphs, lang) {
- const narrated = narratedOnly(transcript, paragraphs, lang);
- const cues = align(transcript, narrated, lang);
- const matched = cues.filter((c) => !c.text.startsWith(UNMATCHED)).length;
- return {
- cues,
- paragraphsUsed: narrated.length,
- paragraphsDropped: paragraphs.length - narrated.length,
- matchRate: cues.length ? matched / cues.length : 0,
- };
-}
-
-/* ----------------------------------------------------------------------- srt */
-
-export function stamp(seconds) {
- const ms = Math.round(Math.max(seconds, 0) * 1000);
- const pad = (v, n = 2) => String(v).padStart(n, '0');
- return `${pad(Math.floor(ms / 3600000))}:${pad(Math.floor(ms / 60000) % 60)}:${pad(Math.floor(ms / 1000) % 60)},${pad(ms % 1000, 3)}`;
-}
-
-/** One line of text per cue, always: Hoshi Reader reads exactly the third line of each block. */
-export function writeSrt(cues) {
- let out = '', n = 0;
- for (const cue of cues) {
- const text = cue.text.replace(/\s*[\r\n]+\s*/g, ' ').trim();
- if (!text) continue;
- out += `${++n}\n${stamp(cue.start)} --> ${stamp(cue.end)}\n${text}\n\n`;
- }
- return out;
-}
diff --git a/src/engine/align.worker.js b/src/engine/align.worker.js
deleted file mode 100644
index cf1e1a7..0000000
--- a/src/engine/align.worker.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/* Copied from equwal/subplz-web frontend/engine (AGPL-3.0, same author). */
-/* Alignment off the main thread: a long book is a few seconds of solid work. */
-import { alignBook, language, writeSrt } from './align.js';
-
-self.onmessage = ({ data }) => {
- try {
- const result = alignBook(data.transcript, data.paragraphs, language(data.language));
- self.postMessage({ ok: true, ...result, srt: writeSrt(result.cues) });
- } catch (e) {
- self.postMessage({ ok: false, error: String(e?.message || e) });
- }
-};
diff --git a/src/engine/asr.js b/src/engine/asr.js
deleted file mode 100644
index bc6eb3b..0000000
--- a/src/engine/asr.js
+++ /dev/null
@@ -1,89 +0,0 @@
-/* Copied from equwal/subplz-web frontend/engine (AGPL-3.0, same author).
- * Adapted: transformers.js and the model load from a configurable asset
- * base URL instead of this origin's /vendor/.
- */
-
-/* Speech recognition in the browser: Whisper-tiny on the ONNX runtime, through
- * transformers.js. WebGPU where the browser has it (many times faster than real
- * time on an ordinary laptop), WebAssembly threads where it does not.
- *
- * The transcript only has to be good enough to line up against a book we
- * already have the words of, which is why the smallest model will do.
- */
-
-const MODEL = 'whisper-tiny';
-
-/** transformers.js, loaded once from `assets` (a URL that ends with "/"). */
-let loaded = null;
-
-async function transformers(assets) {
- loaded ??= import(/* @vite-ignore */ `${assets}transformers/transformers.min.js`).then((m) => {
- const { env } = m;
- env.backends.onnx.wasm.wasmPaths = `${assets}transformers/`;
- // The model weights live under <assets>/models/whisper-tiny/.
- env.remoteHost = `${assets}models/`;
- env.remotePathTemplate = '{model}/';
- env.allowRemoteModels = true;
- env.allowLocalModels = false;
- return m;
- });
- return loaded;
-}
-
-export async function hasWebGpu() {
- try { return !!(navigator.gpu && await navigator.gpu.requestAdapter()); } catch { return false; }
-}
-
-export class Recogniser {
- #pipe;
- device;
-
- /** @param onProgress ({file, loaded, total}) while the ~120 MB of weights download (cached after). */
- static async open(assets, onProgress) {
- const { pipeline } = await transformers(assets);
- const r = new Recogniser();
- r.device = (await hasWebGpu()) ? 'webgpu' : 'wasm';
- const options = (device) => ({
- device,
- // Full-precision encoder: quantising it is what ruins accuracy. The
- // decoder tolerates 4-bit well and is where the time goes.
- dtype: device === 'webgpu'
- ? { encoder_model: 'fp32', decoder_model_merged: 'q4' }
- : { encoder_model: 'fp32', decoder_model_merged: 'q8' },
- progress_callback: (p) => { if (p.status === 'progress') onProgress?.(p); },
- });
- try {
- r.#pipe = await pipeline('automatic-speech-recognition', MODEL, options(r.device));
- } catch (e) {
- if (r.device !== 'webgpu') throw e;
- // A GPU that is listed but cannot run the model: fall back rather than fail.
- r.device = 'wasm';
- r.#pipe = await pipeline('automatic-speech-recognition', MODEL, options('wasm'));
- }
- return r;
- }
-
- /**
- * @param pcm 16 kHz mono Float32Array
- * @param language a Whisper code ("ja", "ru", ...), or null to let it decide
- * @returns [{text, start, end}] with times offset by `offset` seconds
- */
- async transcribe(pcm, language, offset = 0) {
- const out = await this.#pipe(pcm, {
- return_timestamps: true,
- chunk_length_s: 30,
- stride_length_s: 5,
- task: 'transcribe',
- ...(language ? { language } : {}),
- });
- const segments = [];
- for (const c of out.chunks ?? []) {
- const text = (c.text ?? '').trim();
- const [s, e] = c.timestamp ?? [];
- if (!text || s == null) continue;
- // The last segment of a buffer can come back without an end.
- segments.push({ text, start: offset + s, end: offset + (e ?? pcm.length / 16000) });
- }
- return segments;
- }
-}
diff --git a/src/engine/job.d.ts b/src/engine/job.d.ts
deleted file mode 100644
index 9de17d8..0000000
--- a/src/engine/job.d.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-// Types for the parts of job.js that the app uses.
-
-import type { BookAlignment } from './align.js';
-
-export interface JobStatus {
- phase: 'preparing' | 'transcribing' | 'aligning' | 'done';
- detail: string;
- /** Progress in [0, 1]. */
- fraction: number;
- /** Seconds left, once the speed is known. */
- eta?: number | null;
- /** Audio seconds transcribed per wall-clock second. */
- speed?: number | null;
- indeterminate?: boolean;
-}
-
-export interface JobResult extends BookAlignment {
- srt: string;
- srtName: string;
- stem: string;
- duration: number;
- segments: number;
-}
-
-export interface JobOptions {
- audio: File[];
- paragraphs: string[];
- /** Whisper code, such as "ja". */
- language: string;
- /** Asset base URL. Must end with "/". */
- assets: string;
- onStatus?: (status: JobStatus) => void;
-}
-
-export class Cancelled extends Error {}
-
-export class Job {
- constructor(options: JobOptions);
- /** "webgpu" or "wasm", set once the model is open. */
- device?: string;
- result: JobResult | null;
- run(): Promise<JobResult>;
- cancel(): void;
- close(): void;
-}
-
-export function clock(seconds: number): string;
diff --git a/src/engine/job.js b/src/engine/job.js
deleted file mode 100644
index fe744c2..0000000
--- a/src/engine/job.js
+++ /dev/null
@@ -1,159 +0,0 @@
-/* Copied from equwal/subplz-web frontend/engine (AGPL-3.0, same author).
- * Adapted: the paragraphs come in as a string list (the OCR lines), so the
- * book reader is gone; the video and read-along epub outputs are gone; the
- * engine assets load from a configurable base URL.
- */
-
-/* One conversion, start to finish, in this tab.
- *
- * audio --ffmpeg--> two-minute chunks --whisper--> rough transcript
- * OCR lines ------> paragraphs
- * transcript + paragraphs --align--> subtitles (.srt)
- *
- * Nothing leaves the machine. The transcript is saved to IndexedDB after every
- * chunk, because transcribing a book takes hours and tabs get closed: opening
- * the same files again carries on from the last chunk.
- */
-import { Media, quietestPoint, SAMPLE_RATE } from './media.js';
-import { Recogniser } from './asr.js';
-
-const CHUNK_SECONDS = 120;
-
-export class Cancelled extends Error { constructor() { super('Stopped.'); } }
-
-/* ------------------------------------------------------------ saved progress */
-
-const db = () => new Promise((resolve, reject) => {
- const open = indexedDB.open('subread', 1);
- open.onupgradeneeded = () => open.result.createObjectStore('transcripts');
- open.onsuccess = () => resolve(open.result);
- open.onerror = () => reject(open.error);
-});
-
-async function store(mode, fn) {
- const d = await db();
- return new Promise((resolve, reject) => {
- const tx = d.transaction('transcripts', mode);
- const req = fn(tx.objectStore('transcripts'));
- tx.oncomplete = () => resolve(req?.result);
- tx.onerror = () => reject(tx.error);
- });
-}
-
-/** The same files picked again resume, whatever route they were picked by. */
-const keyFor = (files, language) => files.map((f) => `${f.name}|${f.size}`).join('//') + `#${language}`;
-
-export async function savedProgress(files, language) {
- try { return (await store('readonly', (s) => s.get(keyFor(files, language)))) ?? null; } catch { return null; }
-}
-
-/* ------------------------------------------------------------------- the job */
-
-export class Job {
- #media = null;
- #paths = [];
- #cancelled = false;
-
- constructor({ audio, paragraphs, language, assets, onStatus }) {
- this.audio = audio; // File[], in playback order
- this.paragraphs = paragraphs; // string[], the book text lines
- this.language = language; // Whisper code; always explicit, see asr.js
- this.assets = assets; // URL that ends with "/", see asr.js and media.js
- this.onStatus = onStatus ?? (() => {});
- this.result = null;
- }
-
- cancel() { this.#cancelled = true; }
- #check() { if (this.#cancelled) throw new Cancelled(); }
- #status(phase, detail, fraction, extra = {}) { this.onStatus({ phase, detail, fraction, ...extra }); }
-
- async run() {
- if (!this.paragraphs.length) throw new Error('The book has no text. Run OCR first.');
-
- this.#status('preparing', 'Starting the audio decoder', 0);
- this.#media = await Media.open(this.assets);
- const parts = [];
- for (const [i, file] of this.audio.entries()) {
- const path = await this.#media.mount(file, `/in${i}`);
- parts.push({ path, ...(await this.#media.probe(path)) });
- this.#paths.push(path);
- }
- const total = parts.reduce((n, p) => n + p.duration, 0);
-
- const transcript = await this.#transcribe(parts, total);
- this.#check();
-
- this.#status('aligning', 'Matching the book to the narration', 0.97);
- const aligned = await alignInWorker(transcript, this.paragraphs, this.language);
- const stem = this.audio[0].name.replace(/\.[^.]+$/, '');
- this.result = {
- ...aligned, stem, duration: total, parts,
- srtName: `${stem}.${this.language}.srt`,
- segments: transcript.length,
- };
- this.#status('done', 'Done', 1);
- return this.result;
- }
-
- async #transcribe(parts, total) {
- const key = keyFor(this.audio, this.language);
- const saved = (await savedProgress(this.audio, this.language)) ?? { doneUntil: 0, segments: [], complete: false };
- if (saved.complete) return saved.segments;
-
- this.#status('preparing', 'Loading the speech model', 0, { indeterminate: true });
- const asr = await Recogniser.open(this.assets, (p) => {
- if (p.total) this.#status('preparing', 'Downloading the speech model (once)', p.loaded / p.total * 0.02);
- });
- this.device = asr.device;
-
- const resumedAt = saved.doneUntil, started = performance.now();
- let base = 0; // where the current part starts on the whole book's clock
- for (const part of parts) {
- let pos = Math.max(0, saved.doneUntil - base);
- while (pos < part.duration - 0.05) {
- this.#check();
- let pcm = await this.#media.pcm(part.path, pos, CHUNK_SECONDS);
- if (!pcm.length) break;
- const last = pos + pcm.length / SAMPLE_RATE >= part.duration - 0.5;
- // Cut where the narrator pauses, so no word straddles two chunks.
- if (!last) pcm = pcm.subarray(0, quietestPoint(pcm));
-
- const at = base + pos;
- saved.segments.push(...await asr.transcribe(pcm, this.language, at));
- pos += pcm.length / SAMPLE_RATE;
- saved.doneUntil = base + pos;
- await store('readwrite', (s) => s.put(saved, key)).catch(() => {});
-
- const elapsed = (performance.now() - started) / 1000;
- const speed = (saved.doneUntil - resumedAt) / elapsed;
- this.#status('transcribing', `Listening: ${clock(saved.doneUntil)} of ${clock(total)}`,
- 0.02 + 0.94 * saved.doneUntil / total,
- { eta: elapsed > 10 ? (total - saved.doneUntil) / speed : null, speed: elapsed > 10 ? speed : null });
- }
- base += part.duration;
- }
- saved.complete = true;
- await store('readwrite', (s) => s.put(saved, key)).catch(() => {});
- return saved.segments;
- }
-
- close() { this.#media?.close(); }
-}
-
-function alignInWorker(transcript, paragraphs, language) {
- return new Promise((resolve, reject) => {
- const worker = new Worker(new URL('./align.worker.js', import.meta.url), { type: 'module' });
- worker.onmessage = ({ data }) => {
- worker.terminate();
- if (data.ok) resolve(data); else reject(new Error(data.error));
- };
- worker.onerror = (e) => { worker.terminate(); reject(new Error(e.message || 'The aligner crashed.')); };
- worker.postMessage({ transcript, paragraphs, language });
- });
-}
-
-export function clock(seconds) {
- const s = Math.max(0, Math.floor(seconds)), pad = (v) => String(v).padStart(2, '0');
- return s >= 3600 ? `${Math.floor(s / 3600)}:${pad(Math.floor(s / 60) % 60)}:${pad(s % 60)}`
- : `${Math.floor(s / 60)}:${pad(s % 60)}`;
-}
diff --git a/src/engine/media.js b/src/engine/media.js
deleted file mode 100644
index d000fea..0000000
--- a/src/engine/media.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/* Copied from equwal/subplz-web frontend/engine (AGPL-3.0, same author).
- * Adapted: the ffmpeg wrapper comes from the @ffmpeg/ffmpeg package (its
- * worker must be same-origin), and the 32 MB core loads from a configurable
- * asset base URL.
- */
-
-/* Reading and writing media in the browser, with ffmpeg compiled to WebAssembly.
- *
- * The visitor's file is never copied anywhere: it is mounted read-only into
- * ffmpeg's filesystem (WORKERFS), which reads straight from the File on disk.
- * That is what makes a 600 MB audiobook workable - the only audio ever held in
- * memory is the couple of minutes being transcribed.
- *
- * ffmpeg runs in its own worker; everything here is async message-passing.
- */
-import { FFmpeg } from '@ffmpeg/ffmpeg';
-
-const RATE = 16000;
-
-export class Media {
- #ffmpeg = new FFmpeg();
- #log = [];
- #mounted = [];
-
- /** Start ffmpeg with the single-thread core at `<assets>ffmpeg/ffmpeg-core.js`. */
- static async open(assets, onLog) {
- const m = new Media();
- m.#ffmpeg.on('log', ({ message }) => {
- m.#log.push(message);
- if (m.#log.length > 400) m.#log.shift();
- onLog?.(message);
- });
- await m.#ffmpeg.load({ coreURL: `${assets}ffmpeg/ffmpeg-core.js` });
- return m;
- }
-
- /** Make `file` readable by ffmpeg at the returned path, without copying it. */
- async mount(file, dir) {
- await this.#ffmpeg.createDir(dir).catch(() => {});
- await this.#ffmpeg.mount('WORKERFS', { files: [file] }, dir);
- this.#mounted.push(dir);
- return `${dir}/${file.name}`;
- }
-
- /** Duration in seconds and chapter marks, read from ffmpeg's own banner. */
- async probe(path) {
- this.#log.length = 0;
- await this.#ffmpeg.exec(['-hide_banner', '-i', path]); // "fails": no output given. Expected.
- const text = this.#log.join('\n');
- const d = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(text);
- if (!d) throw new Error('This file does not look like audio ffmpeg can read.');
- const audio = /Stream #\d+:\d+.*?: Audio: ([a-z0-9_]+)/i.exec(text);
- return {
- duration: (+d[1]) * 3600 + (+d[2]) * 60 + (+d[3]),
- codec: audio ? audio[1].toLowerCase() : null,
- chapters: [...text.matchAll(/Chapter #\d+:\d+: start ([\d.]+), end ([\d.]+)/g)]
- .map((c) => ({ start: +c[1], end: +c[2] })),
- };
- }
-
- /** `seconds` of audio from `start`, as the 16 kHz mono float PCM the speech model takes. */
- async pcm(path, start, seconds) {
- const out = 'chunk.f32';
- const code = await this.#ffmpeg.exec([
- '-hide_banner', '-v', 'error',
- // Tolerate damaged files: drop what cannot be decoded, keep going.
- '-err_detect', 'ignore_err', '-fflags', '+discardcorrupt',
- '-ss', String(start), '-t', String(seconds), '-i', path,
- '-map', '0:a:0', '-vn', '-sn', '-ac', '1', '-ar', String(RATE), '-f', 'f32le', out,
- ]);
- if (code !== 0) throw new Error(`Could not decode the audio at ${Math.round(start)}s.`);
- const bytes = await this.#ffmpeg.readFile(out);
- await this.#ffmpeg.deleteFile(out);
- return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
- }
-
- close() { this.#ffmpeg.terminate(); }
-}
-
-/**
- * Where to cut a chunk: the quietest quarter second in its last few seconds,
- * so that no word is split across two transcriptions.
- * Returns a sample index into `pcm`.
- */
-export function quietestPoint(pcm, searchSeconds = 12, windowSeconds = 0.25) {
- const window = Math.floor(windowSeconds * RATE);
- const from = Math.max(0, pcm.length - searchSeconds * RATE);
- if (pcm.length - from <= window * 2) return pcm.length;
- let energy = 0;
- for (let i = from; i < from + window; i++) energy += Math.abs(pcm[i]);
- let best = energy, bestAt = from;
- for (let i = from; i + window < pcm.length; i++) {
- energy += Math.abs(pcm[i + window]) - Math.abs(pcm[i]);
- if (energy < best) { best = energy; bestAt = i + 1; }
- }
- return bestAt + (window >> 1);
-}
-
-export const SAMPLE_RATE = RATE;
diff --git a/src/hit-test.ts b/src/hit-test.ts
new file mode 100644
index 0000000..0c516b2
--- /dev/null
+++ b/src/hit-test.ts
@@ -0,0 +1,94 @@
+// Tap on the page. Pure: no DOM.
+//
+// 1. `tokenAt` finds the token under a tap.
+// 2. `scanText` reads that token and the ones after it on the same line.
+// 3. `lookupText` is the text that goes to the dictionary app.
+
+import type { OcrToken } from './align';
+
+/** Longest scan string, in characters. */
+export const SCAN_LENGTH = 16;
+
+/** Longest lookup text, in characters. */
+export const LOOKUP_LENGTH = 40;
+
+/** When the line ends within this many characters, the next line is added. */
+export const LOOKUP_SHORT_LINE = 4;
+
+/** Distance from a point to a box. Zero inside the box. */
+function boxDistance(box: OcrToken['bbox'], x: number, y: number): number {
+ const dx = Math.max(box.x0 - x, 0, x - box.x1);
+ const dy = Math.max(box.y0 - y, 0, y - box.y1);
+ return Math.hypot(dx, dy);
+}
+
+/** Distance from a point to the center of a box. */
+function centerDistance(box: OcrToken['bbox'], x: number, y: number): number {
+ return Math.hypot((box.x0 + box.x1) / 2 - x, (box.y0 + box.y1) / 2 - y);
+}
+
+/**
+ * Index of the token on `page` nearest to (x, y), or -1 when none is
+ * within `tolerance` pixels. Coordinates are in page pixels. OCR boxes
+ * overlap, so among boxes at the same distance the one with the nearest
+ * center wins.
+ */
+export function tokenAt(
+ tokens: readonly OcrToken[],
+ page: number,
+ x: number,
+ y: number,
+ tolerance: number,
+): number {
+ let best = -1;
+ let bestDist = tolerance;
+ let bestCenter = Infinity;
+ tokens.forEach((t, i) => {
+ if (t.page !== page) return;
+ const d = boxDistance(t.bbox, x, y);
+ if (d > bestDist) return;
+ const c = centerDistance(t.bbox, x, y);
+ if (d < bestDist || c < bestCenter) {
+ best = i;
+ bestDist = d;
+ bestCenter = c;
+ }
+ });
+ return best;
+}
+
+/** Text of token `start` and the following tokens on its line, up to `max` characters. */
+export function scanText(tokens: readonly OcrToken[], start: number, max = SCAN_LENGTH): string {
+ const first = tokens[start];
+ if (!first) return '';
+ let text = '';
+ for (let i = start; i < tokens.length && tokens[i]!.line === first.line; i++) {
+ if (text.length + tokens[i]!.text.length > max) break;
+ text += tokens[i]!.text;
+ }
+ return text;
+}
+
+/** Index of the first token after the line of token `start`, or -1 at the end. */
+function nextLine(tokens: readonly OcrToken[], start: number): number {
+ const line = tokens[start]!.line;
+ let i = start;
+ while (i < tokens.length && tokens[i]!.line === line) i++;
+ return i < tokens.length ? i : -1;
+}
+
+/**
+ * The text for a dictionary lookup: from the tapped character to the end
+ * of its line. When the line ends within `LOOKUP_SHORT_LINE` characters,
+ * the next line of the same page follows, so a word that wraps is whole.
+ */
+export function lookupText(tokens: readonly OcrToken[], start: number): string {
+ const first = tokens[start];
+ if (!first) return '';
+ let text = scanText(tokens, start, LOOKUP_LENGTH);
+ if (text.length > LOOKUP_SHORT_LINE) return text;
+ const next = nextLine(tokens, start);
+ if (next < 0 || tokens[next]!.page !== first.page) return text;
+ text += scanText(tokens, next, LOOKUP_LENGTH - text.length);
+ return text;
+}
diff --git a/src/http.ts b/src/http.ts
deleted file mode 100644
index bb721f9..0000000
--- a/src/http.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-// The two transports behind `Http` (see cloud.ts).
-//
-// Web: `fetch` with relative paths. In development the Vite proxy forwards
-// /api to the server, so the cookie is same-origin.
-//
-// Android: the WebView origin is https://localhost and the server sends no
-// CORS headers, so a browser fetch fails. `CapacitorHttp.request` runs the
-// request natively. Its cookies live in the WebView cookie store, which
-// persists across restarts. A multipart body goes over the bridge as
-// base64 entries, the same shape Capacitor's own fetch patch uses.
-
-import { Capacitor, CapacitorHttp } from '@capacitor/core';
-import type { Http, HttpRequest } from './cloud';
-
-/** Default server per platform: the dev proxy on the web, the real host in the app. */
-export const DEFAULT_SERVER = Capacitor.isNativePlatform() ? 'https://subread.space' : '';
-
-/** Default engine asset base per platform: the dev proxy on the web, the real host in the app. */
-export function defaultAssets(): string {
- return Capacitor.isNativePlatform()
- ? 'https://subread.space/vendor/'
- : `${location.origin}/vendor/`;
-}
-
-/** Bytes per `String.fromCharCode` call. More overflows the argument list. */
-const CHUNK = 0x8000;
-
-/** Read a file as base64. The whole file is in memory while it is encoded. */
-export async function fileToBase64(file: Blob): Promise<string> {
- const bytes = new Uint8Array(await file.arrayBuffer());
- let binary = '';
- for (let i = 0; i < bytes.length; i += CHUNK) {
- binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
- }
- return btoa(binary);
-}
-
-export interface FormEntry {
- key: string;
- value: string;
- type: 'base64File';
- contentType: string;
- fileName: string;
-}
-
-/** The multipart entries that the native HTTP plugin writes as `files` parts. */
-export async function formEntries(files: readonly File[]): Promise<FormEntry[]> {
- const out: FormEntry[] = [];
- for (const file of files) {
- out.push({
- key: 'files',
- value: await fileToBase64(file),
- type: 'base64File',
- contentType: file.type || 'application/octet-stream',
- fileName: file.name,
- });
- }
- return out;
-}
-
-export function webHttp(base: string): Http {
- return async (req: HttpRequest) => {
- const init: RequestInit = { method: req.method, credentials: 'same-origin' };
- if (req.files) {
- const form = new FormData();
- for (const f of req.files) form.append('files', f, f.name);
- init.body = form;
- } else if (req.json !== undefined) {
- init.headers = { 'Content-Type': 'application/json' };
- init.body = JSON.stringify(req.json);
- }
- const res = await fetch(base + req.path, init);
- return { status: res.status, text: await res.text() };
- };
-}
-
-export function nativeHttp(base: string): Http {
- return async (req: HttpRequest) => {
- const headers: Record<string, string> = {};
- let data: unknown;
- let dataType: 'formData' | undefined;
- if (req.files) {
- data = await formEntries(req.files);
- dataType = 'formData';
- } else if (req.json !== undefined) {
- headers['Content-Type'] = 'application/json';
- data = JSON.stringify(req.json);
- }
- const res = await CapacitorHttp.request({
- url: base + req.path,
- method: req.method,
- headers,
- ...(data !== undefined ? { data } : {}),
- ...(dataType ? { dataType } : {}),
- responseType: 'text',
- readTimeout: 120_000,
- connectTimeout: 30_000,
- });
- const text = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
- return { status: res.status, text };
- };
-}
-
-export function createHttp(base: string): Http {
- return Capacitor.isNativePlatform() ? nativeHttp(base) : webHttp(base);
-}
diff --git a/src/local-audio.ts b/src/local-audio.ts
deleted file mode 100644
index 9ce504d..0000000
--- a/src/local-audio.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-// Word pronunciations from a Yomitan local-audio `android.db`.
-//
-// The native side (android/app/.../LocalAudioPlugin.java) reads the SQLite
-// file. This module holds the typed bridge and the pure helpers that pick
-// which clips to offer for a lookup result.
-
-import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
-
-export interface LocalAudioStatus {
- available: boolean;
- /** Absolute path of the db in use, or null when none exists. */
- path: string | null;
- sizeBytes: number;
-}
-
-/** One row of the `entries` table. */
-export interface LocalAudioEntry {
- source: string;
- speaker: string | null;
- display: string | null;
- file: string;
-}
-
-export interface ImportProgress {
- copied: number;
- total: number;
-}
-
-export interface LocalAudioPlugin {
- status(): Promise<LocalAudioStatus>;
- /** Opens the system file picker and copies the picked file into the app. */
- importDb(): Promise<LocalAudioStatus>;
- /** Deletes the imported copy. */
- remove(): Promise<LocalAudioStatus>;
- lookup(options: {
- expression: string;
- reading?: string;
- }): Promise<{ entries: LocalAudioEntry[] }>;
- /** One clip as base64. The MIME type follows from the file name; see `mimeFor`. */
- audio(options: { file: string; source: string }): Promise<{ data: string }>;
- addListener(
- eventName: 'importProgress',
- listener: (progress: ImportProgress) => void,
- ): Promise<PluginListenerHandle>;
-}
-
-/** Where the user can put the file by hand, relative to the shared storage root. */
-export const MANUAL_PATH = 'Android/data/com.equwal.scansubread/files/android.db';
-
-export const isAndroid = Capacitor.getPlatform() === 'android';
-
-/** On the web only `status` exists, and it reports "not available". */
-const webImpl: Pick<LocalAudioPlugin, 'status'> = {
- status: async () => ({ available: false, path: null, sizeBytes: 0 }),
-};
-
-export const LocalAudio = registerPlugin<LocalAudioPlugin>('LocalAudio', { web: () => webImpl });
-
-/** MIME type of a clip from its file extension. */
-export function mimeFor(file: string): string {
- const dot = file.lastIndexOf('.');
- const ext = dot < 0 ? '' : file.slice(dot + 1).toLowerCase();
- switch (ext) {
- case 'mp3':
- return 'audio/mpeg';
- case 'ogg':
- case 'opus':
- return 'audio/ogg';
- default:
- return 'application/octet-stream';
- }
-}
-
-/** A word to look up: the expression with its reading, if any. */
-export interface Word {
- expression: string;
- reading: string;
-}
-
-/** Unique words in order of first appearance. Several results may share one word. */
-export function uniqueWords(words: readonly Word[]): Word[] {
- const seen = new Set<string>();
- const out: Word[] = [];
- for (const w of words) {
- const key = `${w.expression}\t${w.reading}`;
- if (seen.has(key)) continue;
- seen.add(key);
- out.push({ expression: w.expression, reading: w.reading });
- }
- return out;
-}
-
-/** One play button: what to show and what to fetch. */
-export interface AudioChoice {
- label: string;
- source: string;
- file: string;
-}
-
-/** One choice per source, the first stored entry of each. The label is `display` or the source name. */
-export function choices(entries: readonly LocalAudioEntry[]): AudioChoice[] {
- const seen = new Set<string>();
- const out: AudioChoice[] = [];
- for (const e of entries) {
- if (seen.has(e.source)) continue;
- seen.add(e.source);
- out.push({ label: e.display || e.source, source: e.source, file: e.file });
- }
- return out;
-}
-
-/** Data URL for one clip. */
-export function dataUrl(file: string, base64: string): string {
- return `data:${mimeFor(file)};base64,${base64}`;
-}
diff --git a/src/lookup.ts b/src/lookup.ts
deleted file mode 100644
index ce6265a..0000000
--- a/src/lookup.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-// Tap-to-look-up. Pure: no DOM, no database.
-//
-// 1. `tokenAt` finds the OCR token under a tap.
-// 2. `scanText` reads that token and the ones after it on the same line.
-// 3. `lookup` finds the longest prefix of that text that is a dictionary
-// term, after deinflection. Shorter matches follow.
-
-import type { OcrToken } from './align';
-import { deinflect } from './deinflect';
-import type { DictTerm } from './yomitan';
-
-/** Longest scan string, in characters. */
-export const SCAN_LENGTH = 16;
-
-/** Most results that `lookup` returns. */
-const MAX_RESULTS = 20;
-
-/** Distance from a point to a box. Zero inside the box. */
-function boxDistance(box: OcrToken['bbox'], x: number, y: number): number {
- const dx = Math.max(box.x0 - x, 0, x - box.x1);
- const dy = Math.max(box.y0 - y, 0, y - box.y1);
- return Math.hypot(dx, dy);
-}
-
-/** Distance from a point to the center of a box. */
-function centerDistance(box: OcrToken['bbox'], x: number, y: number): number {
- return Math.hypot((box.x0 + box.x1) / 2 - x, (box.y0 + box.y1) / 2 - y);
-}
-
-/**
- * Index of the token on `page` nearest to (x, y), or -1 when none is
- * within `tolerance` pixels. Coordinates are in OCR page pixels. OCR
- * boxes overlap, so among boxes at the same distance the one with the
- * nearest center wins.
- */
-export function tokenAt(
- tokens: readonly OcrToken[],
- page: number,
- x: number,
- y: number,
- tolerance: number,
-): number {
- let best = -1;
- let bestDist = tolerance;
- let bestCenter = Infinity;
- tokens.forEach((t, i) => {
- if (t.page !== page) return;
- const d = boxDistance(t.bbox, x, y);
- if (d > bestDist) return;
- const c = centerDistance(t.bbox, x, y);
- if (d < bestDist || c < bestCenter) {
- best = i;
- bestDist = d;
- bestCenter = c;
- }
- });
- return best;
-}
-
-/** Text of token `start` and the following tokens on its line, up to `max` characters. */
-export function scanText(tokens: readonly OcrToken[], start: number, max = SCAN_LENGTH): string {
- const first = tokens[start];
- if (!first) return '';
- let text = '';
- for (let i = start; i < tokens.length && tokens[i]!.line === first.line; i++) {
- if (text.length + tokens[i]!.text.length > max) break;
- text += tokens[i]!.text;
- }
- return text;
-}
-
-export interface LookupResult<T extends DictTerm = DictTerm> {
- /** The part of the scan text that matched. */
- surface: string;
- term: T;
- /** Names of the deinflection rules applied. Empty for an exact match. */
- rules: string[];
-}
-
-/** Terms whose expression or reading equals `key`. */
-export type TermFinder<T extends DictTerm = DictTerm> = (key: string) => Promise<readonly T[]>;
-
-/** True when the term may take the deinflection (its rules allow the part of speech). */
-function posAllows(term: DictTerm, pos: string | undefined): boolean {
- if (!pos || term.rules === '') return true;
- return term.rules.split(' ').includes(pos);
-}
-
-/**
- * Dictionary matches for the prefixes of `text`, longest prefix first.
- * Within one prefix, exact matches come before deinflected ones, then
- * higher scores first.
- */
-export async function lookup<T extends DictTerm>(
- text: string,
- find: TermFinder<T>,
-): Promise<LookupResult<T>[]> {
- const results: LookupResult<T>[] = [];
- const chars = [...text];
- for (let len = chars.length; len > 0 && results.length < MAX_RESULTS; len--) {
- const surface = chars.slice(0, len).join('');
- const found: LookupResult<T>[] = [];
- const seen = new Set<string>();
- for (const cand of deinflect(surface)) {
- for (const term of await find(cand.term)) {
- if (!posAllows(term, cand.pos)) continue;
- const key = `${term.expression}\t${term.reading}\t${term.glossary.join('\n')}`;
- if (seen.has(key)) continue;
- seen.add(key);
- found.push({ surface, term, rules: cand.rules });
- }
- }
- found.sort((a, b) => a.rules.length - b.rules.length || b.term.score - a.term.score);
- results.push(...found);
- }
- return results.slice(0, MAX_RESULTS);
-}
-
-/** A finder over an in-memory term list. Used by tests and small dictionaries. */
-export function memoryFinder(terms: readonly DictTerm[]): TermFinder {
- const byKey = new Map<string, DictTerm[]>();
- const add = (key: string, term: DictTerm) => {
- const list = byKey.get(key);
- if (list) {
- if (!list.includes(term)) list.push(term);
- } else byKey.set(key, [term]);
- };
- for (const term of terms) {
- add(term.expression, term);
- if (term.reading) add(term.reading, term);
- }
- return async (key) => byKey.get(key) ?? [];
-}
diff --git a/src/main.ts b/src/main.ts
index 4ce3358..3e217cb 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,24 +1,7 @@
-// UI wiring. Alignment logic lives in align.ts, lookup logic in lookup.ts.
+// UI wiring. Alignment logic lives in align.ts, the tap in hit-test.ts.
import { alignCuesToTokens, type OcrToken, type TokenSpan } from './align';
-import { setupAudiobook } from './audiobook';
-import {
- deleteDictionary,
- importDictionary,
- listDictionaries,
- storedFinder,
- type FoundTerm,
-} from './dictdb';
-import {
- choices,
- dataUrl,
- isAndroid,
- LocalAudio,
- uniqueWords,
- type LocalAudioStatus,
- type Word,
-} from './local-audio';
-import { lookup, scanText, tokenAt, type LookupResult, type TermFinder } from './lookup';
+import { lookupText, tokenAt } from './hit-test';
import { createOcr } from './ocr';
import { loadPdf, type PdfDoc } from './pdf';
import { cueIndexAt, parseSubtitles, type Cue } from './subtitles';
@@ -29,8 +12,6 @@ const OCR_WIDTH = 1600;
/** How far from a character a tap may land, as a share of the page width. */
const TAP_TOLERANCE = 0.015;
-const DESKTOP = window.matchMedia('(min-width: 900px)');
-
function el<T extends HTMLElement>(id: string): T {
const e = document.getElementById(id);
if (!e) throw new Error(`Missing element #${id}`);
@@ -42,12 +23,7 @@ const ui = {
panel: el<HTMLElement>('panel'),
pdfFile: el<HTMLInputElement>('pdf-file'),
subFile: el<HTMLInputElement>('sub-file'),
- dictFile: el<HTMLInputElement>('dict-file'),
- dicts: el<HTMLUListElement>('dicts'),
- laStatus: el<HTMLDivElement>('la-status'),
- laImport: el<HTMLButtonElement>('la-import'),
- laRemove: el<HTMLButtonElement>('la-remove'),
- laProgress: el<HTMLProgressElement>('la-progress'),
+ audioFile: el<HTMLInputElement>('audio-file'),
lang: el<HTMLSelectElement>('lang'),
maxPages: el<HTMLInputElement>('max-pages'),
run: el<HTMLButtonElement>('run'),
@@ -61,7 +37,6 @@ const ui = {
viewer: el<HTMLDivElement>('viewer'),
page: el<HTMLDivElement>('page'),
overlay: el<HTMLDivElement>('overlay'),
- popup: el<HTMLDivElement>('popup'),
};
interface PageSize {
@@ -80,12 +55,6 @@ const state = {
activeCue: -1,
busy: false,
renderSeq: 0,
- /** Term finder over the stored dictionaries. Null when none is imported. */
- finder: null as TermFinder<FoundTerm> | null,
- /** True when a local-audio android.db is in place. */
- localAudio: false,
- /** Counts popups, so audio buttons from a stale lookup are dropped. */
- popupSeq: 0,
};
function setStatus(text: string): void {
@@ -125,170 +94,23 @@ ui.pdfFile.addEventListener('change', async () => {
updateRunButton();
});
-/** Load subtitles from a file or from a finished job. `source` names where they came from. */
-function loadSubtitles(text: string, source: string): void {
- state.cues = parseSubtitles(text);
+ui.subFile.addEventListener('change', async () => {
+ const file = ui.subFile.files?.[0];
+ if (!file) return;
+ state.cues = parseSubtitles(await file.text());
state.spans = [];
state.activeCue = -1;
- setStatus(`${state.cues.length} cues loaded from ${source}.`);
+ setStatus(`${state.cues.length} cues loaded from ${file.name}.`);
alignIfReady();
renderCueList();
-}
-
-ui.subFile.addEventListener('change', async () => {
- const file = ui.subFile.files?.[0];
- if (!file) return;
- loadSubtitles(await file.text(), file.name);
-});
-
-const audiobook = setupAudiobook({
- tokens: () => state.tokens,
- ocrLang: () => ui.lang.value,
- loadSrt: loadSubtitles,
-});
-
-// --- Dictionaries ---
-
-async function refreshDictionaries(): Promise<void> {
- const rows = await listDictionaries();
- state.finder = rows.length > 0 ? await storedFinder() : null;
- ui.dicts.replaceChildren(
- ...rows.map((row) => {
- const li = document.createElement('li');
- const name = document.createElement('span');
- name.textContent = `${row.title} (${row.termCount} terms)`;
- const del = document.createElement('button');
- del.textContent = 'Delete';
- del.addEventListener('click', async () => {
- del.disabled = true;
- setStatus(`Deleting ${row.title}...`);
- await deleteDictionary(row.id);
- setStatus(`Deleted ${row.title}.`);
- await refreshDictionaries();
- });
- li.append(name, del);
- return li;
- }),
- );
-}
-
-ui.dictFile.addEventListener('change', async () => {
- const files = [...(ui.dictFile.files ?? [])];
- ui.dictFile.value = '';
- for (const file of files) {
- try {
- setStatus(`Reading ${file.name}...`);
- const zip = new Uint8Array(await file.arrayBuffer());
- const row = await importDictionary(zip, setStatus);
- setStatus(`Imported ${row.title}: ${row.termCount} terms.`);
- } catch (err) {
- setStatus(`Import of ${file.name} failed: ${String(err)}`);
- }
- }
- await refreshDictionaries();
-});
-
-// --- Local audio (android.db) ---
-
-function showLocalAudioStatus(status: LocalAudioStatus): void {
- state.localAudio = status.available;
- ui.laImport.disabled = !isAndroid;
- ui.laRemove.disabled = !status.available;
- if (!isAndroid) {
- ui.laStatus.textContent = 'Local audio: Android only.';
- } else if (status.available) {
- const mb = (status.sizeBytes / (1024 * 1024)).toFixed(1);
- ui.laStatus.textContent = `Local audio: ${status.path} (${mb} MB)`;
- } else {
- ui.laStatus.textContent = 'Local audio: not set up.';
- }
-}
-
-async function refreshLocalAudio(): Promise<void> {
- const status = await LocalAudio.status();
- console.log('LocalAudio status', JSON.stringify(status));
- showLocalAudioStatus(status);
-}
-
-ui.laImport.addEventListener('click', async () => {
- ui.laImport.disabled = true;
- ui.laProgress.hidden = false;
- ui.laProgress.value = 0;
- try {
- ui.laStatus.textContent = 'Local audio: importing...';
- showLocalAudioStatus(await LocalAudio.importDb());
- } catch (err) {
- ui.laStatus.textContent = `Local audio: ${(err as Error).message ?? String(err)}`;
- ui.laRemove.disabled = !state.localAudio;
- } finally {
- ui.laProgress.hidden = true;
- ui.laImport.disabled = false;
- }
});
-ui.laRemove.addEventListener('click', async () => {
- ui.laRemove.disabled = true;
- try {
- showLocalAudioStatus(await LocalAudio.remove());
- } catch (err) {
- ui.laStatus.textContent = `Local audio: ${(err as Error).message ?? String(err)}`;
- }
+ui.audioFile.addEventListener('change', () => {
+ const file = ui.audioFile.files?.[0];
+ if (!file) return;
+ ui.audio.src = URL.createObjectURL(file);
});
-if (isAndroid) {
- void LocalAudio.addListener('importProgress', ({ copied, total }) => {
- ui.laProgress.value = total > 0 ? copied / total : 0;
- const mb = (copied / (1024 * 1024)).toFixed(0);
- ui.laStatus.textContent = `Local audio: importing, ${mb} MB copied...`;
- });
-}
-
-/** Play one pronunciation clip. The plugin returns it as base64. */
-async function playClip(file: string, source: string): Promise<void> {
- const { data } = await LocalAudio.audio({ file, source });
- await new Audio(dataUrl(file, data)).play();
-}
-
-/** Key of the audio slot map: one per expression and reading. */
-function wordKey(word: Word): string {
- return `${word.expression}\t${word.reading}`;
-}
-
-/**
- * Add a play button per audio source under each entry. Runs after the
- * popup is on screen. `slots` maps a word key to the entry containers.
- */
-async function addAudioButtons(
- seq: number,
- words: readonly Word[],
- slots: Map<string, HTMLElement[]>,
-): Promise<void> {
- for (const word of uniqueWords(words)) {
- let entries;
- try {
- ({ entries } = await LocalAudio.lookup({
- expression: word.expression,
- ...(word.reading ? { reading: word.reading } : {}),
- }));
- } catch (err) {
- console.warn('LocalAudio lookup failed', err);
- return;
- }
- if (seq !== state.popupSeq) return; // A newer popup replaced this one.
- for (const slot of slots.get(wordKey(word)) ?? []) {
- for (const choice of choices(entries)) {
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.textContent = `▶ ${choice.label}`;
- btn.addEventListener('click', () => {
- playClip(choice.file, choice.source).catch((err) => setStatus(String(err)));
- });
- slot.append(btn);
- }
- }
- }
-}
-
// --- OCR and alignment ---
ui.run.addEventListener('click', async () => {
@@ -316,8 +138,7 @@ ui.run.addEventListener('click', async () => {
} finally {
await ocr.terminate();
}
- setStatus(`Done. ${state.tokens.length} characters read. Tap a word to look it up.`);
- audiobook.onTokens();
+ setStatus(`Done. ${state.tokens.length} characters read. Tap a word on the page.`);
alignIfReady();
renderCueList();
state.activeCue = -1;
@@ -389,7 +210,6 @@ async function showPage(index: number): Promise<void> {
const seq = ++state.renderSeq;
state.currentPage = index;
ui.pageLabel.textContent = `Page ${index + 1} / ${pdf.numPages}`;
- hidePopup();
// Render at device resolution, up to the OCR width, so zoom stays sharp.
const dpr = window.devicePixelRatio || 1;
const width = Math.round(Math.min(OCR_WIDTH, Math.max(300, ui.viewer.clientWidth - 16) * dpr));
@@ -434,98 +254,24 @@ function drawBoxes(): void {
ui.prev.addEventListener('click', () => void showPage(state.currentPage - 1));
ui.next.addEventListener('click', () => void showPage(state.currentPage + 1));
-// --- Tap to look up ---
+// --- Tap on a word ---
-/** Tap on the page: look up the word under the tap. */
-ui.page.addEventListener('click', async (e) => {
+/** Tap on the page: show the text under the tap. */
+ui.page.addEventListener('click', (e) => {
const canvas = ui.page.querySelector('canvas');
const size = state.pageSizes[state.currentPage];
- if (!canvas || !size) {
- hidePopup();
- return;
- }
+ if (!canvas || !size) return;
const rect = canvas.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * size.width;
const y = ((e.clientY - rect.top) / rect.height) * size.height;
const t = tokenAt(state.tokens, state.currentPage, x, y, TAP_TOLERANCE * size.width);
- if (t < 0) {
- hidePopup();
- return;
- }
- const text = scanText(state.tokens, t);
- if (!state.finder) {
- showPopup(e, text, []);
- return;
- }
- showPopup(e, text, await lookup(text, state.finder));
+ if (t < 0) return;
+ setStatus(`Tapped: "${lookupText(state.tokens, t)}"`);
});
-function showPopup(e: MouseEvent, text: string, results: LookupResult<FoundTerm>[]): void {
- const seq = ++state.popupSeq;
- const slots = new Map<string, HTMLElement[]>();
- const entries = results.map((r) => {
- const term = r.term;
- const div = document.createElement('div');
- div.className = 'entry';
- const head = document.createElement('div');
- head.className = 'head';
- head.textContent = term.expression;
- const reading = document.createElement('div');
- reading.className = 'reading';
- reading.textContent = term.reading && term.reading !== term.expression ? term.reading : '';
- const meta = document.createElement('div');
- meta.className = 'meta';
- const via = r.rules.length > 0 ? ` (${r.surface}: ${r.rules.join(' < ')})` : '';
- meta.textContent = `${term.dictTitle}${via}`;
- const list = document.createElement('ul');
- for (const line of term.glossary) {
- const li = document.createElement('li');
- li.textContent = line;
- list.append(li);
- }
- const audio = document.createElement('div');
- audio.className = 'audio';
- div.append(head, reading, meta, audio, list);
- const key = wordKey(term);
- slots.set(key, [...(slots.get(key) ?? []), audio]);
- return div;
- });
- if (entries.length === 0) {
- const p = document.createElement('div');
- p.className = 'meta';
- p.textContent = state.finder
- ? `No entry for "${text}".`
- : 'Import a dictionary to look up words.';
- entries.push(p);
- }
- ui.popup.replaceChildren(...entries);
- ui.popup.hidden = false;
- if (DESKTOP.matches) {
- const width = ui.popup.offsetWidth;
- ui.popup.style.left = `${Math.min(e.clientX + 12, window.innerWidth - width - 8)}px`;
- ui.popup.style.top = `${Math.min(e.clientY + 12, window.innerHeight - ui.popup.offsetHeight - 8)}px`;
- } else {
- ui.popup.style.left = '';
- ui.popup.style.top = '';
- }
- // The audio lookup runs after the popup is on screen, so the popup stays quick.
- if (state.localAudio && results.length > 0) {
- void addAudioButtons(
- seq,
- results.map((r) => r.term),
- slots,
- );
- }
-}
-
-function hidePopup(): void {
- ui.popup.hidden = true;
-}
-
-// A tap outside the popup closes it. A tap outside the drawer closes the drawer.
+// A tap outside the drawer closes the drawer.
document.addEventListener('click', (e) => {
const target = e.target as Node;
- if (!ui.popup.contains(target) && !ui.page.contains(target)) hidePopup();
if (
document.body.classList.contains('menu-open') &&
!ui.panel.contains(target) &&
@@ -544,9 +290,3 @@ ui.audio.addEventListener('timeupdate', () => {
});
updateRunButton();
-void refreshDictionaries().catch((err) =>
- setStatus(`Cannot open dictionary store: ${String(err)}`),
-);
-void refreshLocalAudio().catch((err) => {
- ui.laStatus.textContent = `Local audio: ${String(err)}`;
-});
diff --git a/src/srt-cache.ts b/src/srt-cache.ts
deleted file mode 100644
index 17b391f..0000000
--- a/src/srt-cache.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-// Finished subtitles per audio file, in IndexedDB. Thin browser layer.
-//
-// Key: audio file name and size. A book is aligned once; the next time the
-// same audio file is picked, the subtitles load from here.
-
-import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
-
-interface Schema extends DBSchema {
- srt: { key: string; value: string };
-}
-
-let dbPromise: Promise<IDBPDatabase<Schema>> | undefined;
-
-function db(): Promise<IDBPDatabase<Schema>> {
- dbPromise ??= openDB<Schema>('scan-subread-srt', 1, {
- upgrade(d) {
- d.createObjectStore('srt');
- },
- });
- return dbPromise;
-}
-
-/** The cache key of an audio file. */
-export function audioKey(file: Pick<File, 'name' | 'size'>): string {
- return `${file.name}|${file.size}`;
-}
-
-export async function getSrt(key: string): Promise<string | undefined> {
- return (await db()).get('srt', key);
-}
-
-export async function putSrt(key: string, srt: string): Promise<void> {
- await (await db()).put('srt', srt, key);
-}
diff --git a/src/style.css b/src/style.css
index 96a7ad3..c585608 100644
--- a/src/style.css
+++ b/src/style.css
@@ -85,24 +85,6 @@ body.menu-open #panel {
min-height: 1.2em;
}
-#dicts {
- margin: 0;
- padding: 0;
- list-style: none;
- font-size: 0.85rem;
-}
-
-#dicts li {
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-#dicts li span {
- flex: 1;
-}
-
-#local-audio,
#audiobook {
display: flex;
flex-direction: column;
@@ -110,38 +92,11 @@ body.menu-open #panel {
font-size: 0.85rem;
}
-#local-audio h2,
#audiobook h2 {
font-size: 0.95rem;
margin: 0;
}
-#local-audio .row,
-#audiobook .row {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
-}
-
-#local-audio code,
-#la-status,
-#ab-status,
-#ab-account {
- word-break: break-all;
-}
-
-#ab-status a {
- color: inherit;
-}
-
-#audiobook details {
- font-size: 0.85rem;
-}
-
-#audiobook details label {
- margin-top: 0.4rem;
-}
-
#audio {
width: 100%;
}
@@ -222,62 +177,7 @@ body.menu-open #panel {
background: var(--highlight-weak);
}
-/* Lookup result: a bottom sheet on phones. */
-#popup {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- max-height: 45vh;
- max-height: 45dvh;
- overflow-y: auto;
- box-sizing: border-box;
- padding: 0.75rem 1rem;
- background: Canvas;
- border-top: 1px solid #8884;
- box-shadow: 0 -2px 12px #0004;
- z-index: 20;
- font-size: 0.95rem;
-}
-
-.entry + .entry {
- margin-top: 0.6rem;
- padding-top: 0.6rem;
- border-top: 1px solid #8884;
-}
-
-.entry .head {
- font-size: 1.15rem;
-}
-
-.entry .reading,
-.entry .meta {
- font-size: 0.8rem;
- opacity: 0.75;
-}
-
-.entry ul {
- margin: 0.2rem 0 0;
- padding-left: 1.2rem;
-}
-
-/* Pronunciation buttons: one per audio source. */
-.entry .audio {
- display: flex;
- flex-wrap: wrap;
- gap: 0.3rem;
-}
-
-.entry .audio:empty {
- display: none;
-}
-
-.entry .audio button {
- font-size: 0.8rem;
- padding: 0.1rem 0.5rem;
-}
-
-/* Desktop layout: panel and page side by side, popup near the click. */
+/* Desktop layout: panel and page side by side. */
@media (min-width: 900px) {
body {
display: grid;
@@ -312,16 +212,4 @@ body.menu-open #panel {
#viewer {
padding: 1rem;
}
-
- #popup {
- position: absolute;
- left: auto;
- right: auto;
- bottom: auto;
- width: 22rem;
- max-height: 50vh;
- border: 1px solid #8884;
- border-radius: 6px;
- box-shadow: 0 2px 12px #0006;
- }
}
diff --git a/src/yomitan.ts b/src/yomitan.ts
deleted file mode 100644
index 57519a5..0000000
--- a/src/yomitan.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-// Yomitan dictionary zip reader. Pure: no DOM, no database.
-//
-// A Yomitan dictionary is a zip with `index.json` and `term_bank_N.json`
-// files. Each term bank is an array of rows:
-// [expression, reading, definitionTags, rules, score, glossary, sequence, termTags]
-// A glossary entry is a string, a structured-content object, an image
-// object, or a text object. This module keeps plain text only.
-//
-// The zip is read one entry at a time, so a large dictionary does not
-// need all its banks in memory at once.
-
-import { unzipSync } from 'fflate';
-
-export interface DictIndex {
- title: string;
- revision: string;
-}
-
-/** One dictionary term as stored and looked up. */
-export interface DictTerm {
- expression: string;
- reading: string;
- /** Space-separated part-of-speech rules: "v1", "v5", "adj-i", ... */
- rules: string;
- score: number;
- glossary: string[];
-}
-
-/** Names of the entries in a zip. */
-export function listZipEntries(zip: Uint8Array): string[] {
- const names: string[] = [];
- unzipSync(zip, {
- filter: (file) => {
- names.push(file.name);
- return false;
- },
- });
- return names;
-}
-
-/** Decompress one entry of a zip. */
-export function readZipEntry(zip: Uint8Array, name: string): Uint8Array {
- const files = unzipSync(zip, { filter: (file) => file.name === name });
- const data = files[name];
- if (!data) throw new Error(`Zip entry not found: ${name}`);
- return data;
-}
-
-function readJson(zip: Uint8Array, name: string): unknown {
- return JSON.parse(new TextDecoder().decode(readZipEntry(zip, name)));
-}
-
-/** Parse `index.json`. */
-export function parseIndex(json: unknown): DictIndex {
- if (typeof json !== 'object' || json === null) throw new Error('index.json is not an object');
- const { title, revision } = json as Record<string, unknown>;
- if (typeof title !== 'string' || title.length === 0) throw new Error('index.json has no title');
- return { title, revision: typeof revision === 'string' ? revision : '' };
-}
-
-/** Parse one term bank. Rows with a wrong shape are skipped. */
-export function parseTermBank(json: unknown): DictTerm[] {
- if (!Array.isArray(json)) throw new Error('term bank is not an array');
- const terms: DictTerm[] = [];
- for (const row of json) {
- if (!Array.isArray(row) || typeof row[0] !== 'string') continue;
- const expression = row[0];
- const reading = typeof row[1] === 'string' ? row[1] : '';
- const rules = typeof row[3] === 'string' ? row[3] : '';
- const score = typeof row[4] === 'number' ? row[4] : 0;
- const glossary = Array.isArray(row[5]) ? row[5].map(glossaryText).filter((s) => s !== '') : [];
- terms.push({ expression, reading, rules, score, glossary });
- }
- return terms;
-}
-
-/** Plain text of one glossary entry. Images give an empty string. */
-export function glossaryText(entry: unknown): string {
- return contentText(entry).replace(/\s+/g, ' ').trim();
-}
-
-/** Tags whose content starts a new line in plain text. */
-const BLOCK_TAGS = new Set(['div', 'p', 'li', 'br', 'tr', 'ul', 'ol', 'details', 'summary']);
-
-function contentText(node: unknown): string {
- if (typeof node === 'string') return node;
- if (Array.isArray(node)) return node.map(contentText).join('');
- if (typeof node !== 'object' || node === null) return '';
- const obj = node as Record<string, unknown>;
- if (obj['type'] === 'image' || obj['tag'] === 'img') return '';
- if (obj['type'] === 'text') return typeof obj['text'] === 'string' ? obj['text'] : '';
- const inner = contentText(obj['content']);
- return typeof obj['tag'] === 'string' && BLOCK_TAGS.has(obj['tag']) ? ` ${inner} ` : inner;
-}
-
-/** A parsed dictionary: its index and a reader for each term bank. */
-export interface DictionaryZip {
- index: DictIndex;
- /** Term bank entry names in numeric order. */
- termBanks: string[];
- readTermBank(name: string): DictTerm[];
-}
-
-const TERM_BANK = /^term_bank_(\d+)\.json$/;
-
-/** Open a Yomitan dictionary zip. Banks are read on demand. */
-export function openDictionaryZip(zip: Uint8Array): DictionaryZip {
- const names = listZipEntries(zip);
- if (!names.includes('index.json')) throw new Error('Not a Yomitan dictionary: no index.json');
- const index = parseIndex(readJson(zip, 'index.json'));
- const termBanks = names
- .filter((n) => TERM_BANK.test(n))
- .sort((a, b) => Number(TERM_BANK.exec(a)![1]) - Number(TERM_BANK.exec(b)![1]));
- return {
- index,
- termBanks,
- readTermBank: (name) => parseTermBank(readJson(zip, name)),
- };
-}
diff --git a/test/book-text.test.ts b/test/book-text.test.ts
index d154664..9a5b632 100644
--- a/test/book-text.test.ts
+++ b/test/book-text.test.ts
@@ -1,10 +1,18 @@
import fc from 'fast-check';
import { describe, expect, it } from 'vitest';
import type { OcrToken } from '../src/align';
-import { bookText, joinWords, paragraphs, whisperCode } from '../src/book-text';
+import { bookText, joinWords, whisperCode } from '../src/book-text';
const box = { x0: 0, y0: 0, x1: 1, y1: 1 };
+/** The non-blank lines of a book text, trimmed. */
+function paragraphs(text: string): string[] {
+ return text
+ .split('\n')
+ .map((l) => l.trim())
+ .filter((l) => l.length > 0);
+}
+
/** Tokens for one page: `lines` is a list of lines, each a list of words. */
function pageTokens(page: number, lines: readonly (readonly string[])[]): OcrToken[] {
const out: OcrToken[] = [];
@@ -80,12 +88,6 @@ describe('bookText', () => {
});
});
-describe('paragraphs', () => {
- it('keeps non-blank lines, trimmed', () => {
- expect(paragraphs('a\n\n b \n\n\nc\n')).toEqual(['a', 'b', 'c']);
- });
-});
-
describe('whisperCode', () => {
it('maps tesseract languages to Whisper codes', () => {
expect(whisperCode('jpn')).toBe('ja');
diff --git a/test/cloud.test.ts b/test/cloud.test.ts
deleted file mode 100644
index 19d4b58..0000000
--- a/test/cloud.test.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import {
- accountLine,
- ApiError,
- fetchSrt,
- pollUntilDone,
- startJob,
- tokenFrom,
- upload,
- type Http,
- type HttpRequest,
- type JobOut,
-} from '../src/cloud';
-import { parseSubtitles } from '../src/subtitles';
-
-const job = (status: string, extra: Partial<JobOut> = {}): JobOut => ({
- id: 'job_1',
- status,
- stage: status,
- progress: 0,
- language: 'ja',
- error: null,
- artifacts: [],
- ...extra,
-});
-
-const json = (status: number, body: unknown) => ({ status, text: JSON.stringify(body) });
-
-/** A fake transport: one canned answer per call, in order, plus the log of requests. */
-function fakeHttp(answers: { status: number; text: string }[]): { http: Http; log: HttpRequest[] } {
- const log: HttpRequest[] = [];
- const http: Http = async (req) => {
- log.push(req);
- const next = answers.shift();
- if (!next) throw new Error(`No canned answer for ${req.method} ${req.path}`);
- return next;
- };
- return { http, log };
-}
-
-const noSleep = async (): Promise<void> => {};
-
-describe('pollUntilDone', () => {
- it('polls queued, running, then returns the succeeded job', async () => {
- const { http, log } = fakeHttp([
- json(200, job('queued')),
- json(200, job('running', { progress: 0.5 })),
- json(200, job('succeeded', { progress: 1 })),
- ]);
- const seen: string[] = [];
- const done = await pollUntilDone(http, 'job_1', {
- intervalMs: 5000,
- sleep: noSleep,
- onStatus: (j) => seen.push(j.status),
- });
- expect(done.status).toBe('succeeded');
- expect(seen).toEqual(['queued', 'running', 'succeeded']);
- expect(log.map((r) => r.path)).toEqual([
- '/api/jobs/job_1',
- '/api/jobs/job_1',
- '/api/jobs/job_1',
- ]);
- });
-
- it('returns a failed job with its error', async () => {
- const { http } = fakeHttp([json(200, job('failed', { error: 'No speech found.' }))]);
- const done = await pollUntilDone(http, 'job_1', { intervalMs: 0, sleep: noSleep });
- expect(done.status).toBe('failed');
- expect(done.error).toBe('No speech found.');
- });
-
- it('stops when the signal is aborted', async () => {
- const ctrl = new AbortController();
- const { http } = fakeHttp([json(200, job('running'))]);
- await expect(
- pollUntilDone(http, 'job_1', {
- intervalMs: 0,
- sleep: async () => ctrl.abort(),
- signal: ctrl.signal,
- }),
- ).rejects.toMatchObject({ name: 'AbortError' });
- });
-});
-
-describe('startJob', () => {
- it('sends the language and returns the job', async () => {
- const { http, log } = fakeHttp([json(200, job('queued'))]);
- const out = await startJob(http, 'job_1', 'ja');
- expect(out.status).toBe('queued');
- expect(log[0]).toMatchObject({
- method: 'POST',
- path: '/api/jobs/job_1/start',
- json: { language: 'ja' },
- });
- });
-
- it('throws ApiError 402 with the server detail when there is no credit', async () => {
- const { http } = fakeHttp([json(402, { detail: 'No credit left.' })]);
- const err = await startJob(http, 'job_1', 'ja').catch((e: unknown) => e);
- expect(err).toBeInstanceOf(ApiError);
- expect((err as ApiError).status).toBe(402);
- expect((err as ApiError).message).toBe('No credit left.');
- });
-});
-
-describe('upload', () => {
- it('posts both files in the `files` field', async () => {
- const audio = new File(['x'], 'book.wav', { type: 'audio/wav' });
- const book = new File(['a\n'], 'book.txt', { type: 'text/plain' });
- const { http, log } = fakeHttp([json(200, { job: job('draft'), detected: {} })]);
- const out = await upload(http, audio, book);
- expect(out.id).toBe('job_1');
- expect(log[0]?.files?.map((f) => f.name)).toEqual(['book.wav', 'book.txt']);
- });
-});
-
-describe('fetchSrt', () => {
- it('returns SRT that the subtitle parser reads', async () => {
- const srt = '1\n00:00:01,000 --> 00:00:03,500\n朝の光\n\n';
- const { http } = fakeHttp([{ status: 200, text: srt }]);
- const cues = parseSubtitles(await fetchSrt(http, 'job_1'));
- expect(cues).toEqual([{ start: 1, end: 3.5, text: '朝の光' }]);
- });
-});
-
-describe('tokenFrom', () => {
- it('reads the token out of a sign-in link or takes the bare token', () => {
- expect(tokenFrom('https://subread.space/?login=abc123')).toBe('abc123');
- expect(tokenFrom(' abc123 \n')).toBe('abc123');
- });
-});
-
-describe('accountLine', () => {
- const base = {
- id: 'acc',
- signed_in: false,
- email: null,
- credits: 1,
- free_credits: 1,
- cloud_allowed: true,
- billing_enabled: true,
- email_sign_in_available: true,
- };
-
- it('shows the sign-in state and the credits', () => {
- expect(accountLine(base)).toBe('Not signed in. Credits: 1 (1 free).');
- expect(accountLine({ ...base, signed_in: true, email: 'a@b.c', credits: 3 })).toBe(
- 'Signed in as a@b.c. Credits: 3 (1 free).',
- );
- });
-
- it('hides credits when billing is off', () => {
- expect(accountLine({ ...base, billing_enabled: false })).toBe('Not signed in.');
- });
-});
diff --git a/test/deinflect.test.ts b/test/deinflect.test.ts
deleted file mode 100644
index 2ab0e37..0000000
--- a/test/deinflect.test.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import fc from 'fast-check';
-import { describe, expect, it } from 'vitest';
-import { deinflect } from '../src/deinflect';
-
-function terms(word: string): string[] {
- return deinflect(word).map((c) => c.term);
-}
-
-describe('deinflect examples', () => {
- it.each([
- ['食べました', '食べる'],
- ['書いて', '書く'],
- ['読まない', '読む'],
- ['高くない', '高い'],
- ['走った', '走る'],
- ['行った', '行く'],
- ['見られる', '見る'],
- ['話せる', '話す'],
- ['食べたい', '食べる'],
- ['食べませんでした', '食べる'],
- ['高くなかった', '高い'],
- ['泣いていた', '泣く'],
- ['しました', 'する'],
- ])('%s → %s', (input, expected) => {
- expect(terms(input)).toContain(expected);
- });
-
- it('records the rule chain and the part of speech', () => {
- const c = deinflect('食べました').find((c) => c.term === '食べる');
- expect(c).toMatchObject({ rules: ['polite past', 'polite'], pos: 'v1' });
- });
-
- it('keeps the input first, with no rules', () => {
- expect(deinflect('猫')[0]).toEqual({ term: '猫', rules: [] });
- });
-});
-
-const KANA = [
- ...'あいうえおかきくけこさしすせそたちつてとなにぬねのまみむめもらりるれろわんっ',
- ...'べ見食行高',
-];
-const word = fc
- .array(fc.constantFrom(...KANA), { minLength: 0, maxLength: 12 })
- .map((c) => c.join(''));
-
-describe('deinflect properties', () => {
- it('always includes the input', () => {
- fc.assert(
- fc.property(word, (w) => {
- expect(terms(w)).toContain(w);
- }),
- );
- });
-
- it('never produces a candidate longer than the input plus two', () => {
- fc.assert(
- fc.property(word, (w) => {
- for (const t of terms(w)) expect(t.length).toBeLessThanOrEqual(w.length + 2);
- }),
- );
- });
-
- it('never repeats a candidate', () => {
- fc.assert(
- fc.property(word, (w) => {
- const ts = terms(w);
- expect(new Set(ts).size).toBe(ts.length);
- }),
- );
- });
-
- it('applies at most three rules', () => {
- fc.assert(
- fc.property(word, (w) => {
- for (const c of deinflect(w)) expect(c.rules.length).toBeLessThanOrEqual(3);
- }),
- );
- });
-});
diff --git a/test/engine.test.ts b/test/engine.test.ts
deleted file mode 100644
index 7c11b00..0000000
--- a/test/engine.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-// One smoke test for the copied engine: it is tested in its own repository.
-import { describe, expect, it } from 'vitest';
-import { alignBook, language, writeSrt } from '../src/engine/align.js';
-import { parseSubtitles } from '../src/subtitles';
-
-describe('engine align.js', () => {
- it('aligns a tiny transcript to three paragraphs and writes SRT the parser reads', () => {
- const paragraphs = [
- '朝の光が丘の上に昇った。',
- '村の人々は目を覚ました。',
- 'パン屋は店を開けた。',
- ];
- // A rough transcript: no punctuation, one recognition slip.
- const transcript = [
- { text: '朝の光が丘の上に昇った', start: 0, end: 2.5 },
- { text: '村の人々は目を覚ました', start: 2.5, end: 5 },
- { text: 'パン屋は店をあけた', start: 5, end: 7.5 },
- ];
- const result = alignBook(transcript, paragraphs, language('ja'));
- expect(result.cues).toHaveLength(3);
- expect(result.matchRate).toBe(1);
- expect(result.cues.map((c) => c.text)).toEqual(paragraphs);
-
- const cues = parseSubtitles(writeSrt(result.cues));
- expect(cues).toEqual([
- { start: 0, end: 2.5, text: paragraphs[0] },
- { start: 2.5, end: 5, text: paragraphs[1] },
- { start: 5, end: 7.5, text: paragraphs[2] },
- ]);
- });
-});
diff --git a/test/lookup.test.ts b/test/hit-test.test.ts
similarity index 54%
rename from test/lookup.test.ts
rename to test/hit-test.test.ts
index 6e8379a..8f94808 100644
--- a/test/lookup.test.ts
+++ b/test/hit-test.test.ts
@@ -1,8 +1,7 @@
+import fc from 'fast-check';
import { describe, expect, it } from 'vitest';
import type { OcrToken } from '../src/align';
-import { lookup, memoryFinder, scanText, tokenAt } from '../src/lookup';
-import { openDictionaryZip, type DictTerm } from '../src/yomitan';
-import { buildTestDictionaryZip } from '../fixtures/test-dict';
+import { LOOKUP_LENGTH, lookupText, scanText, tokenAt } from '../src/hit-test';
/** Tokens for one page: `lines` laid out left to right, 10 px per character. */
function tokensOf(lines: string[], page = 0): OcrToken[] {
@@ -60,46 +59,40 @@ describe('scanText', () => {
});
});
-describe('lookup', () => {
- const dict = openDictionaryZip(buildTestDictionaryZip());
- const terms: DictTerm[] = dict.termBanks.flatMap((n) => dict.readTermBank(n));
- const find = memoryFinder(terms);
-
- it('returns the longest match first', async () => {
- const results = await lookup('吾輩は猫である', find);
- expect(results[0]).toMatchObject({ surface: '吾輩', rules: [] });
- expect(results[0]!.term.glossary).toEqual(['I, me (archaic, pompous)']);
+describe('lookupText', () => {
+ it('reads to the end of the line', () => {
+ const tokens = tokensOf(['吾輩は猫である', '名前はまだ無い']);
+ expect(lookupText(tokens, 2)).toBe('は猫である');
});
- it('matches a deinflected verb', async () => {
- const results = await lookup('泣いていた事', find);
- expect(results[0]).toMatchObject({
- surface: '泣いていた',
- rules: ['past', 'progressive', 'te'],
- });
- expect(results[0]!.term.expression).toBe('泣く');
+ it('adds the next line when the line ends within four characters', () => {
+ const tokens = tokensOf(['吾輩は猫である', '名前はまだ無い']);
+ expect(lookupText(tokens, 4)).toBe('である名前はまだ無い');
+ expect(lookupText(tokens, 3)).toBe('猫である名前はまだ無い');
});
- it('matches a reading', async () => {
- const results = await lookup('ねこ', find);
- expect(results.map((r) => r.term.expression)).toEqual(['猫']);
+ it('does not cross to the next page', () => {
+ const tokens = [...tokensOf(['abcdef'], 0), ...tokensOf(['ghijkl'], 1)];
+ expect(lookupText(tokens, 4)).toBe('ef');
+ expect(lookupText(tokens, 10)).toBe('kl');
});
- it('checks the part of speech of a deinflected match', async () => {
- // 猫 has no verb rules, so 猫る is not accepted through the "negative" rule.
- const catVerb: DictTerm = {
- expression: '猫る',
- reading: '',
- rules: 'v5',
- score: 0,
- glossary: [],
- };
- const results = await lookup('猫ない', memoryFinder([...terms, catVerb]));
- expect(results.map((r) => r.term.expression)).toEqual(['猫']);
+ it('stops at the lookup length', () => {
+ const tokens = tokensOf(['a'.repeat(60), 'b'.repeat(60)]);
+ expect(lookupText(tokens, 0)).toBe('a'.repeat(LOOKUP_LENGTH));
+ expect(lookupText(tokens, 58)).toBe('aa' + 'b'.repeat(LOOKUP_LENGTH - 2));
});
- it('returns nothing when no prefix matches', async () => {
- expect(await lookup('xyz', find)).toEqual([]);
- expect(await lookup('', find)).toEqual([]);
+ it('never returns more than the lookup length, and always starts with the tapped character', () => {
+ const line = fc.stringMatching(/^[\p{L}]{1,50}$/u);
+ fc.assert(
+ fc.property(fc.array(line, { minLength: 1, maxLength: 4 }), fc.nat(), (lines, pick) => {
+ const tokens = tokensOf(lines);
+ const start = pick % tokens.length;
+ const text = lookupText(tokens, start);
+ expect([...text].length).toBeLessThanOrEqual(LOOKUP_LENGTH);
+ expect(text.startsWith(tokens[start]!.text)).toBe(true);
+ }),
+ );
});
});
diff --git a/test/http.test.ts b/test/http.test.ts
deleted file mode 100644
index a58ae99..0000000
--- a/test/http.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { fileToBase64, formEntries } from '../src/http';
-
-describe('formEntries', () => {
- it('encodes each file as one base64 `files` part', async () => {
- const files = [
- new File([new Uint8Array([0, 1, 2, 255])], 'a.wav', { type: 'audio/wav' }),
- new File(['hi\n'], 'book.txt', { type: 'text/plain' }),
- ];
- const entries = await formEntries(files);
- expect(entries).toEqual([
- {
- key: 'files',
- value: 'AAEC/w==',
- type: 'base64File',
- contentType: 'audio/wav',
- fileName: 'a.wav',
- },
- {
- key: 'files',
- value: 'aGkK',
- type: 'base64File',
- contentType: 'text/plain',
- fileName: 'book.txt',
- },
- ]);
- });
-
- it('falls back to octet-stream for a file without a type', async () => {
- const [entry] = await formEntries([new File(['x'], 'raw')]);
- expect(entry?.contentType).toBe('application/octet-stream');
- });
-});
-
-describe('fileToBase64', () => {
- it('round trips bytes', async () => {
- const bytes = new Uint8Array(300).map((_, i) => i % 256);
- const b64 = await fileToBase64(new Blob([bytes]));
- expect(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))).toEqual(bytes);
- });
-});
diff --git a/test/local-audio.test.ts b/test/local-audio.test.ts
deleted file mode 100644
index 15dae7c..0000000
--- a/test/local-audio.test.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import fc from 'fast-check';
-import { describe, expect, it } from 'vitest';
-import {
- choices,
- dataUrl,
- mimeFor,
- uniqueWords,
- type LocalAudioEntry,
- type Word,
-} from '../src/local-audio';
-
-describe('mimeFor', () => {
- it('maps the known extensions', () => {
- expect(mimeFor('猫.mp3')).toBe('audio/mpeg');
- expect(mimeFor('a/b/猫.MP3')).toBe('audio/mpeg');
- expect(mimeFor('x.ogg')).toBe('audio/ogg');
- expect(mimeFor('x.opus')).toBe('audio/ogg');
- });
-
- it('falls back to octet-stream', () => {
- expect(mimeFor('x.wav')).toBe('application/octet-stream');
- expect(mimeFor('noext')).toBe('application/octet-stream');
- expect(mimeFor('')).toBe('application/octet-stream');
- });
-
- it('builds a data URL', () => {
- expect(dataUrl('x.opus', 'AAAA')).toBe('data:audio/ogg;base64,AAAA');
- });
-});
-
-describe('uniqueWords', () => {
- it('keeps the first of each expression+reading pair, in order', () => {
- const words: Word[] = [
- { expression: '猫', reading: 'ねこ' },
- { expression: '猫', reading: 'ねこ' },
- { expression: '猫', reading: '' },
- { expression: '吾輩', reading: 'わがはい' },
- ];
- expect(uniqueWords(words)).toEqual([
- { expression: '猫', reading: 'ねこ' },
- { expression: '猫', reading: '' },
- { expression: '吾輩', reading: 'わがはい' },
- ]);
- });
-
- it('is idempotent and keeps every pair', () => {
- const word = fc.record({ expression: fc.string(), reading: fc.string() });
- fc.assert(
- fc.property(fc.array(word), (words) => {
- const once = uniqueWords(words);
- expect(uniqueWords(once)).toEqual(once);
- const keys = new Set(words.map((w) => `${w.expression}\t${w.reading}`));
- expect(once).toHaveLength(keys.size);
- }),
- );
- });
-});
-
-describe('choices', () => {
- const entries: LocalAudioEntry[] = [
- { source: 'nhk16', speaker: null, display: 'NHK', file: 'nhk16/a.mp3' },
- { source: 'jpod', speaker: null, display: null, file: 'jpod/b.mp3' },
- { source: 'nhk16', speaker: null, display: 'NHK', file: 'nhk16/c.mp3' },
- { source: 'forvo', speaker: 'kaoring', display: '', file: 'forvo/d.opus' },
- ];
-
- it('gives one choice per source, first entry wins, display or source as label', () => {
- expect(choices(entries)).toEqual([
- { label: 'NHK', source: 'nhk16', file: 'nhk16/a.mp3' },
- { label: 'jpod', source: 'jpod', file: 'jpod/b.mp3' },
- { label: 'forvo', source: 'forvo', file: 'forvo/d.opus' },
- ]);
- });
-
- it('returns nothing for no entries', () => {
- expect(choices([])).toEqual([]);
- });
-
- it('never repeats a source and keeps the stored order', () => {
- const entry = fc.record({
- source: fc.constantFrom('a', 'b', 'c'),
- speaker: fc.option(fc.string(), { nil: null }),
- display: fc.option(fc.string(), { nil: null }),
- file: fc.string(),
- });
- fc.assert(
- fc.property(fc.array(entry), (rows) => {
- const out = choices(rows);
- const sources = out.map((c) => c.source);
- expect(new Set(sources).size).toBe(sources.length);
- const firstSeen = [...new Set(rows.map((r) => r.source))];
- expect(sources).toEqual(firstSeen);
- }),
- );
- });
-});
diff --git a/test/yomitan.test.ts b/test/yomitan.test.ts
deleted file mode 100644
index 00cb44a..0000000
--- a/test/yomitan.test.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { strToU8, zipSync } from 'fflate';
-import { describe, expect, it } from 'vitest';
-import { glossaryText, openDictionaryZip, parseTermBank } from '../src/yomitan';
-import { buildTestDictionaryZip, TEST_DICT_TERMS } from '../fixtures/test-dict';
-
-describe('openDictionaryZip', () => {
- it('reads the index and the term banks in order', () => {
- const dict = openDictionaryZip(buildTestDictionaryZip());
- expect(dict.index).toEqual({ title: 'Test dictionary', revision: '2026-09-24' });
- expect(dict.termBanks).toEqual(['term_bank_1.json', 'term_bank_2.json']);
- const terms = dict.termBanks.flatMap((name) => dict.readTermBank(name));
- expect(terms).toHaveLength(TEST_DICT_TERMS.length);
- expect(terms[1]).toEqual({
- expression: '猫',
- reading: 'ねこ',
- rules: '',
- score: 50,
- glossary: ['cat'],
- });
- });
-
- it('flattens structured content and drops images', () => {
- const dict = openDictionaryZip(buildTestDictionaryZip());
- const miru = dict.readTermBank('term_bank_2.json').find((t) => t.expression === '見る');
- expect(miru).toMatchObject({ reading: 'みる', rules: 'v1' });
- expect(miru!.glossary).toEqual(['to see, to look, to watch to examine']);
- });
-
- it('sorts banks by number, not by name', () => {
- const zip = zipSync({
- 'index.json': strToU8(JSON.stringify({ title: 'T', revision: '1' })),
- 'term_bank_10.json': strToU8('[]'),
- 'term_bank_2.json': strToU8('[]'),
- 'tag_bank_1.json': strToU8('[]'),
- });
- expect(openDictionaryZip(zip).termBanks).toEqual(['term_bank_2.json', 'term_bank_10.json']);
- });
-
- it('rejects a zip without index.json', () => {
- const zip = zipSync({ 'term_bank_1.json': strToU8('[]') });
- expect(() => openDictionaryZip(zip)).toThrow(/index\.json/);
- });
-});
-
-describe('parseTermBank', () => {
- it('skips rows with a wrong shape', () => {
- const terms = parseTermBank([['a', 'b', '', '', 1, ['x'], 1, ''], 'junk', [42]]);
- expect(terms.map((t) => t.expression)).toEqual(['a']);
- });
-});
-
-describe('glossaryText', () => {
- it('handles strings, text objects, nested content and images', () => {
- expect(glossaryText('plain')).toBe('plain');
- expect(glossaryText({ type: 'text', text: 'txt' })).toBe('txt');
- expect(glossaryText({ type: 'image', path: 'a.png' })).toBe('');
- expect(
- glossaryText({
- type: 'structured-content',
- content: {
- tag: 'ul',
- content: [
- { tag: 'li', content: 'one' },
- { tag: 'li', content: 'two' },
- ],
- },
- }),
- ).toBe('one two');
- });
-});
diff --git a/vite.config.ts b/vite.config.ts
index 94d4615..26f2679 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,37 +1,7 @@
import { defineConfig } from 'vite';
-/**
- * The dev proxy makes the subread.space API and its engine assets
- * same-origin in the browser, so the app uses the same relative paths on
- * the web as it does in the Android app (where native HTTP takes over).
- */
-const SUBREAD = 'https://subread.space';
-
export default defineConfig({
build: {
target: 'es2022',
},
- worker: {
- format: 'es',
- },
- optimizeDeps: {
- // Pre-bundling would break the worker URL inside the ffmpeg wrapper.
- exclude: ['@ffmpeg/ffmpeg'],
- },
- server: {
- proxy: {
- '/api': {
- target: SUBREAD,
- changeOrigin: true,
- cookieDomainRewrite: 'localhost',
- // The SRT download redirects to a presigned URL. Follow it here, so
- // the browser sees one same-origin answer.
- followRedirects: true,
- },
- '/vendor': {
- target: SUBREAD,
- changeOrigin: true,
- },
- },
- },
});