diff --git a/apps/readest-app/src/helpers/shortcuts.ts b/apps/readest-app/src/helpers/shortcuts.ts
index 0cd80f47..c0f82173 100644
--- a/apps/readest-app/src/helpers/shortcuts.ts
+++ b/apps/readest-app/src/helpers/shortcuts.ts
@@ -118,11 +118,6 @@ const DEFAULT_SHORTCUTS = {
description: _('Dictionary Lookup'),
section: 'Selection',
},
- onWikipediaSelection: {
- keys: ['ctrl+w', 'cmd+w'],
- description: _('Wikipedia Lookup'),
- section: 'Selection',
- },
onReadAloudSelection: {
keys: ['ctrl+r', 'cmd+r'],
description: _('Read Aloud Selection'),
diff --git a/apps/readest-app/src/hooks/useFileSelector.ts b/apps/readest-app/src/hooks/useFileSelector.ts
index 11b1f067..dd77a8e1 100644
--- a/apps/readest-app/src/hooks/useFileSelector.ts
+++ b/apps/readest-app/src/hooks/useFileSelector.ts
@@ -139,6 +139,11 @@ export const FILE_SELECTION_PRESETS = {
extensions: ['ttf', 'otf', 'woff', 'woff2'],
dialogTitle: _('Select Fonts'),
},
+ dictionaries: {
+ accept: '.mdx, .mdd, .ifo, .idx, .dict, .dz, .syn',
+ extensions: ['mdx', 'mdd', 'ifo', 'idx', 'dict', 'dz', 'syn'],
+ dialogTitle: _('Select Dictionary Files'),
+ },
covers: {
accept: '.png, .jpg, .jpeg, .gif',
extensions: ['png', 'jpg', 'jpeg', 'gif'],
diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts
index c3a9a4b7..3ab0bff7 100644
--- a/apps/readest-app/src/services/appService.ts
+++ b/apps/readest-app/src/services/appService.ts
@@ -21,9 +21,12 @@ import { getOSPlatform } from '@/utils/misc';
import { ProgressHandler } from '@/utils/transfer';
import { CustomTextureInfo } from '@/styles/textures';
import { CustomFont, CustomFontInfo } from '@/styles/fonts';
+import type { ImportedDictionary } from './dictionaries/types';
+import type { SelectedFile } from '@/hooks/useFileSelector';
import * as BookSvc from './bookService';
import * as CloudSvc from './cloudService';
+import * as DictSvc from './dictionaries/dictionaryService';
import * as FontSvc from './fontService';
import * as ImageSvc from './imageService';
import * as LibrarySvc from './libraryService';
@@ -219,6 +222,14 @@ export abstract class BaseAppService implements AppService {
return ImageSvc.deleteImage(this.fs, texture);
}
+ async importDictionaries(files: SelectedFile[]): Promise
{
+ return DictSvc.importDictionaries(this.fs, files);
+ }
+
+ async deleteDictionary(dict: ImportedDictionary): Promise {
+ return DictSvc.deleteDictionary(this.fs, dict);
+ }
+
async importBook(
file: string | File,
books: Book[],
diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts
index 52aeb557..4c80ba2e 100644
--- a/apps/readest-app/src/services/constants.ts
+++ b/apps/readest-app/src/services/constants.ts
@@ -35,6 +35,7 @@ export const LOCAL_BOOKS_SUBDIR = `${DATA_SUBDIR}/Books`;
export const CLOUD_BOOKS_SUBDIR = `${DATA_SUBDIR}/Books`;
export const LOCAL_FONTS_SUBDIR = `${DATA_SUBDIR}/Fonts`;
export const LOCAL_IMAGES_SUBDIR = `${DATA_SUBDIR}/Images`;
+export const LOCAL_DICTIONARIES_SUBDIR = `${DATA_SUBDIR}/Dictionaries`;
export const SETTINGS_FILENAME = 'settings.json';
@@ -109,6 +110,15 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial = {
metadataOthersCollapsed: false,
metadataDescriptionCollapsed: false,
+ customDictionaries: [],
+ dictionarySettings: {
+ providerOrder: ['builtin:wiktionary', 'builtin:wikipedia'],
+ providerEnabled: {
+ 'builtin:wiktionary': true,
+ 'builtin:wikipedia': true,
+ },
+ },
+
kosync: DEFAULT_KOSYNC_SETTINGS,
readwise: DEFAULT_READWISE_SETTINGS,
hardcover: DEFAULT_HARDCOVER_SETTINGS,
diff --git a/apps/readest-app/src/services/dictionaries/dictionaryService.ts b/apps/readest-app/src/services/dictionaries/dictionaryService.ts
new file mode 100644
index 00000000..9692745d
--- /dev/null
+++ b/apps/readest-app/src/services/dictionaries/dictionaryService.ts
@@ -0,0 +1,348 @@
+/**
+ * Dictionary import / delete service.
+ *
+ * Takes a flat list of files chosen via `useFileSelector('dictionaries')`,
+ * groups them into StarDict / MDict bundles by filename stem, writes each
+ * bundle's files into `'Dictionaries'//`, and returns metadata records
+ * for the {@link customDictionaryStore}.
+ *
+ * Mirrors the structure of `src/services/fontService.ts` but handles
+ * multi-file bundles instead of single-file fonts.
+ */
+import type { FileSystem } from '@/types/system';
+import type { SelectedFile } from '@/hooks/useFileSelector';
+import { uniqueId } from '@/utils/misc';
+import { getFilename } from '@/utils/path';
+import type { ImportedDictionary } from './types';
+import { scanEntryOffsets, serializeOffsetsSidecar } from './stardictReader';
+
+/** GZIP magic bytes — used to detect DictZip-compressed `.dict` files. */
+const GZIP_MAGIC = [0x1f, 0x8b, 0x08];
+
+interface SourceFile {
+ /** Filename including extension, e.g. `oald7.idx`. */
+ name: string;
+ /** Stem (lowercase, without final extension). For `.dict.dz` use the stem of `.dict`. */
+ stem: string;
+ /** Final extension (lowercase, no dot). For `oald7.dict.dz`, this is `dz`. */
+ ext: string;
+ /** True when ext is `dz` AND the next-to-last segment is `dict`. */
+ isDictZip: boolean;
+ /** Raw input — Tauri path or browser File. */
+ source: SelectedFile;
+}
+
+interface StarDictGroup {
+ kind: 'stardict';
+ stem: string;
+ ifo: SourceFile;
+ idx: SourceFile;
+ dict: SourceFile;
+ syn?: SourceFile;
+}
+
+interface MDictGroup {
+ kind: 'mdict';
+ stem: string;
+ mdx: SourceFile;
+ mdd: SourceFile[];
+}
+
+type Bundle = StarDictGroup | MDictGroup;
+
+interface GroupResult {
+ bundles: Bundle[];
+ /** Files that didn't form a complete bundle (e.g. `.idx` without matching `.ifo`). */
+ orphans: SourceFile[];
+}
+
+/** Read the source file as a `File` (web) or via the path (Tauri filesystem). */
+async function readSource(fs: FileSystem, source: SelectedFile): Promise {
+ if (source.file) return source.file;
+ if (source.path) {
+ // Open from absolute filesystem path. `'None'` keeps the path as-is.
+ return fs.openFile(source.path, 'None');
+ }
+ throw new Error('SelectedFile has neither path nor file');
+}
+
+function classify(source: SelectedFile): SourceFile {
+ const rawName = source.file?.name ?? (source.path ? getFilename(source.path) : '');
+ const name = rawName;
+ const lower = name.toLowerCase();
+ const lastDot = lower.lastIndexOf('.');
+ const ext = lastDot >= 0 ? lower.slice(lastDot + 1) : '';
+ // Detect `foo.dict.dz` — final ext is `dz`, but the bundle stem is `foo`.
+ const beforeLast = lastDot >= 0 ? lower.slice(0, lastDot) : lower;
+ const isDictZip = ext === 'dz' && beforeLast.endsWith('.dict');
+ let stem: string;
+ if (isDictZip) {
+ // `foo.dict.dz` → stem `foo`
+ stem = beforeLast.slice(0, -'.dict'.length);
+ } else if (lastDot >= 0) {
+ // `foo.idx` → stem `foo`
+ stem = beforeLast;
+ } else {
+ stem = lower;
+ }
+ return { name, stem, ext, isDictZip, source };
+}
+
+/**
+ * Group a flat list of selected files into StarDict and MDict bundles by
+ * stem. Files that don't belong to any complete bundle land in `orphans`.
+ *
+ * Rules:
+ * - StarDict bundle = exactly one `.ifo` + one `.idx` + one `.dict` or
+ * `.dict.dz` (sharing a stem). `.syn` is optional.
+ * - MDict bundle = one `.mdx` + zero or more `.mdd` (sharing a stem).
+ * - A stem with both StarDict markers AND `.mdx` is treated as two bundles.
+ */
+export function groupBundlesByStem(files: SelectedFile[]): GroupResult {
+ const classified = files.map(classify);
+ const byStem = new Map();
+ for (const f of classified) {
+ if (!byStem.has(f.stem)) byStem.set(f.stem, []);
+ byStem.get(f.stem)!.push(f);
+ }
+
+ const bundles: Bundle[] = [];
+ const orphans: SourceFile[] = [];
+ for (const [stem, group] of byStem) {
+ const ifo = group.find((f) => f.ext === 'ifo');
+ const idx = group.find((f) => f.ext === 'idx');
+ const dict = group.find((f) => f.ext === 'dict' || f.isDictZip);
+ const syn = group.find((f) => f.ext === 'syn');
+ const mdx = group.find((f) => f.ext === 'mdx');
+ const mdd = group.filter((f) => f.ext === 'mdd');
+
+ if (ifo && idx && dict) {
+ bundles.push({ kind: 'stardict', stem, ifo, idx, dict, syn });
+ } else if (mdx) {
+ bundles.push({ kind: 'mdict', stem, mdx, mdd });
+ } else {
+ orphans.push(...group);
+ }
+ }
+ return { bundles, orphans };
+}
+
+/** Parse a StarDict `.ifo` (key=value, one per line) into a record. */
+function parseIfo(text: string): Record {
+ const out: Record = {};
+ for (const raw of text.split(/\r?\n/)) {
+ const line = raw.trim();
+ if (!line || !line.includes('=')) continue;
+ const eq = line.indexOf('=');
+ out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
+ }
+ return out;
+}
+
+/** Detect the GZIP magic at the start of a Blob. */
+async function isGzip(file: File): Promise {
+ const head = new Uint8Array(await file.slice(0, 3).arrayBuffer());
+ return head[0] === GZIP_MAGIC[0] && head[1] === GZIP_MAGIC[1] && head[2] === GZIP_MAGIC[2];
+}
+
+async function writeBundleFile(
+ fs: FileSystem,
+ bundleDir: string,
+ filename: string,
+ source: File,
+): Promise {
+ const dst = `${bundleDir}/${filename}`;
+ await fs.writeFile(dst, 'Dictionaries', source);
+}
+
+/** Build a fresh bundle directory `'Dictionaries'//`. */
+async function createBundleDir(fs: FileSystem): Promise {
+ const id = uniqueId();
+ await fs.createDir(id, 'Dictionaries', true);
+ return id;
+}
+
+async function importStarDictBundle(
+ fs: FileSystem,
+ group: StarDictGroup,
+): Promise {
+ const bundleDir = await createBundleDir(fs);
+ const ifoFile = await readSource(fs, group.ifo.source);
+ const idxFile = await readSource(fs, group.idx.source);
+ const dictFile = await readSource(fs, group.dict.source);
+ const synFile = group.syn ? await readSource(fs, group.syn.source) : undefined;
+
+ await writeBundleFile(fs, bundleDir, group.ifo.name, ifoFile);
+ await writeBundleFile(fs, bundleDir, group.idx.name, idxFile);
+ await writeBundleFile(fs, bundleDir, group.dict.name, dictFile);
+ if (synFile && group.syn) {
+ await writeBundleFile(fs, bundleDir, group.syn.name, synFile);
+ }
+
+ // Pre-compute offsets sidecars at import time. Subsequent provider inits
+ // skip the full `.idx` (and `.syn`) scan — the only reads are the small
+ // sidecar plus per-lookup probes. Net effect on cmudict-class bundles:
+ // ~62% init IO reduction.
+ const idxOffsetsName = `${group.idx.stem}.idx.offsets`;
+ {
+ const idxBytes = new Uint8Array(await idxFile.arrayBuffer());
+ const offsets = scanEntryOffsets(idxBytes, /* payloadBytes */ 8);
+ const sidecar = serializeOffsetsSidecar(offsets);
+ // Wrap as a File so writeFile's `string | ArrayBuffer | File` signature
+ // accepts it without an unsafe ArrayBuffer cast (Uint8Array.buffer is
+ // typed `ArrayBufferLike` in TS strict mode).
+ const sidecarFile = new File([new Uint8Array(sidecar)], idxOffsetsName);
+ await fs.writeFile(`${bundleDir}/${idxOffsetsName}`, 'Dictionaries', sidecarFile);
+ }
+ let synOffsetsName: string | undefined;
+ if (synFile && group.syn) {
+ synOffsetsName = `${group.syn.stem}.syn.offsets`;
+ const synBytes = new Uint8Array(await synFile.arrayBuffer());
+ const offsets = scanEntryOffsets(synBytes, /* payloadBytes */ 4);
+ const sidecar = serializeOffsetsSidecar(offsets);
+ const sidecarFile = new File([new Uint8Array(sidecar)], synOffsetsName);
+ await fs.writeFile(`${bundleDir}/${synOffsetsName}`, 'Dictionaries', sidecarFile);
+ }
+
+ const ifoText = await ifoFile.text();
+ const ifo = parseIfo(ifoText);
+ const name = ifo['bookname'] || group.stem;
+ const lang = ifo['lang'] || ifo['idxoffsetlang'] || undefined;
+
+ // v1 scope: only DictZip-compressed `.dict.dz` and single-type sametypesequence ∈ {m, h, x, t}.
+ // Bundles outside this surface as `unsupported` so the popup hides them
+ // and the settings UI shows a clear reason; the import itself still succeeds.
+ let unsupported = false;
+ let unsupportedReason: string | undefined;
+ if (!(await isGzip(dictFile))) {
+ unsupported = true;
+ unsupportedReason = 'Raw .dict files are not supported in v1; please use .dict.dz format.';
+ } else {
+ const seq = ifo['sametypesequence'];
+ if (!seq || seq.length !== 1) {
+ unsupported = true;
+ unsupportedReason = seq
+ ? `Multi-type sametypesequence "${seq}" is not supported in v1.`
+ : 'StarDict bundles without sametypesequence are not supported in v1.';
+ } else if (!'mhxt'.includes(seq)) {
+ unsupported = true;
+ unsupportedReason = `StarDict entry type "${seq}" is not supported in v1.`;
+ }
+ }
+
+ return {
+ id: bundleDir,
+ kind: 'stardict',
+ name,
+ bundleDir,
+ files: {
+ ifo: group.ifo.name,
+ idx: group.idx.name,
+ dict: group.dict.name,
+ syn: group.syn?.name,
+ idxOffsets: idxOffsetsName,
+ synOffsets: synOffsetsName,
+ },
+ lang,
+ addedAt: Date.now(),
+ unsupported: unsupported || undefined,
+ unsupportedReason,
+ };
+}
+
+async function importMdictBundle(fs: FileSystem, group: MDictGroup): Promise {
+ const bundleDir = await createBundleDir(fs);
+ const mdxFile = await readSource(fs, group.mdx.source);
+ const mddFiles = await Promise.all(group.mdd.map((m) => readSource(fs, m.source)));
+
+ await writeBundleFile(fs, bundleDir, group.mdx.name, mdxFile);
+ for (let i = 0; i < group.mdd.length; i++) {
+ await writeBundleFile(fs, bundleDir, group.mdd[i]!.name, mddFiles[i]!);
+ }
+
+ // Parse the MDX header via the forked js-mdict (browser-friendly path).
+ // Loaded lazily so users without MDict imports never pull in the parser.
+ let name = group.stem;
+ let lang: string | undefined;
+ let unsupported = false;
+ let unsupportedReason: string | undefined;
+ try {
+ const { MDX } = await import('js-mdict');
+ const mdx = await MDX.create(mdxFile);
+ const header = mdx.header as Record;
+ if (typeof header['Title'] === 'string' && (header['Title'] as string).trim()) {
+ name = (header['Title'] as string).trim();
+ }
+ if (typeof header['Encoding'] === 'string') {
+ lang = (header['Encoding'] as string).toLowerCase();
+ }
+ // `meta.encrypt` is a bitmap: 0x01 = record block encrypted (needs a
+ // user-supplied passcode/regcode — js-mdict doesn't implement that path),
+ // 0x02 = key info block encrypted (handled transparently via the
+ // ripemd128-based `mdxDecrypt`, no passcode needed). Only bit 0 is
+ // genuinely unsupported.
+ if ((mdx.meta.encrypt & 1) !== 0) {
+ unsupported = true;
+ unsupportedReason =
+ 'This MDX is registered to a specific user (record-block encryption); passcode-protected dictionaries are not supported.';
+ }
+ } catch (err) {
+ const message = (err as Error).message ?? String(err);
+ unsupported = true;
+ if (/encrypted file|user identification/i.test(message)) {
+ unsupportedReason =
+ 'This MDX is registered to a specific user (record-block encryption); passcode-protected dictionaries are not supported.';
+ } else {
+ unsupportedReason = `Failed to parse MDX header: ${message}`;
+ }
+ }
+
+ return {
+ id: bundleDir,
+ kind: 'mdict',
+ name,
+ bundleDir,
+ files: {
+ mdx: group.mdx.name,
+ mdd: group.mdd.map((m) => m.name),
+ },
+ lang,
+ addedAt: Date.now(),
+ unsupported: unsupported || undefined,
+ unsupportedReason,
+ };
+}
+
+export interface ImportDictionariesResult {
+ imported: ImportedDictionary[];
+ /** Filenames that didn't form a valid bundle. */
+ orphanFiles: string[];
+}
+
+/**
+ * Top-level import entry point. Groups the selected files into bundles and
+ * imports each one. Returns the persisted metadata for new entries plus a
+ * list of orphan filenames the caller can surface in a toast.
+ */
+export async function importDictionaries(
+ fs: FileSystem,
+ files: SelectedFile[],
+): Promise {
+ const { bundles, orphans } = groupBundlesByStem(files);
+ const imported: ImportedDictionary[] = [];
+ for (const bundle of bundles) {
+ if (bundle.kind === 'stardict') {
+ imported.push(await importStarDictBundle(fs, bundle));
+ } else {
+ imported.push(await importMdictBundle(fs, bundle));
+ }
+ }
+ return { imported, orphanFiles: orphans.map((o) => o.name) };
+}
+
+/** Remove a dictionary's bundle directory. The metadata is dropped by the caller. */
+export async function deleteDictionary(fs: FileSystem, dict: ImportedDictionary): Promise {
+ if (await fs.exists(dict.bundleDir, 'Dictionaries')) {
+ await fs.removeDir(dict.bundleDir, 'Dictionaries', true);
+ }
+}
diff --git a/apps/readest-app/src/services/dictionaries/providers/mdictProvider.ts b/apps/readest-app/src/services/dictionaries/providers/mdictProvider.ts
new file mode 100644
index 00000000..75d68770
--- /dev/null
+++ b/apps/readest-app/src/services/dictionaries/providers/mdictProvider.ts
@@ -0,0 +1,227 @@
+/**
+ * MDict provider.
+ *
+ * Wraps the forked `js-mdict` `MDX` / `MDD` classes via `MDX.create(blob)` /
+ * `MDD.create(blob)`. Both factories accept any `Blob` whose `slice(start,
+ * end).arrayBuffer()` resolves the bytes — Readest's `NativeFile` (Tauri) and
+ * `RemoteFile` (web) qualify, so initialization reads only header + key index
+ * and lookups read exactly the slice they need.
+ *
+ * Resource resolution: when the rendered MDX HTML references images via
+ * `
`, the provider iterates the rendered DOM after insertion,
+ * calls `mdd.locateBytes(key)` for each path, wraps the bytes in a Blob, and
+ * replaces the `src` with `URL.createObjectURL(blob)`. The provider tracks
+ * every URL it creates and revokes them in `dispose()`.
+ *
+ * Encrypted MDX is detected at `init()` (the constructor sets
+ * `meta.encrypt`) and surfaces as `unsupported`.
+ */
+import type { DictionaryProvider, ImportedDictionary } from '../types';
+import type { DictionaryFileOpener } from './starDictProvider';
+
+interface MDXLookupResult {
+ keyText: string;
+ definition: string | null;
+}
+
+interface MDXMeta {
+ encrypt?: number;
+}
+
+interface MDXHeader {
+ [key: string]: unknown;
+}
+
+interface MDXInstance {
+ meta: MDXMeta;
+ header: MDXHeader;
+ lookup(word: string): MDXLookupResult | Promise;
+}
+
+interface MDDInstance {
+ locateBytes(
+ key: string,
+ ):
+ | { keyText: string; data: Uint8Array | null }
+ | Promise<{ keyText: string; data: Uint8Array | null }>;
+}
+
+export interface CreateMdictProviderArgs {
+ dict: ImportedDictionary;
+ fs: DictionaryFileOpener;
+ /** Localized label override; defaults to the bundle name. */
+ label?: string;
+}
+
+const IMG_SRC_PROTOCOL_RX = /^(?:[a-z]+:|data:|blob:|\/)/i;
+
+/**
+ * Resolve `
` references in the rendered HTML by reading bytes
+ * from the companion `.mdd` file(s) and substituting object URLs. Returns the
+ * URLs that were created so the caller can revoke them in `dispose()`.
+ */
+async function resolveImageResources(
+ container: HTMLElement,
+ mdds: MDDInstance[],
+ signal: AbortSignal,
+ trackedUrls: string[],
+): Promise {
+ if (!mdds.length) return;
+ const imgs = Array.from(container.querySelectorAll('img[src]'));
+ if (!imgs.length) return;
+
+ await Promise.all(
+ imgs.map(async (img) => {
+ if (signal.aborted) return;
+ const src = img.getAttribute('src');
+ if (!src || IMG_SRC_PROTOCOL_RX.test(src)) return;
+ for (const mdd of mdds) {
+ try {
+ const located = await mdd.locateBytes(src);
+ if (signal.aborted) return;
+ if (located.data) {
+ const blob = new Blob([new Uint8Array(located.data)]);
+ const url = URL.createObjectURL(blob);
+ trackedUrls.push(url);
+ img.setAttribute('src', url);
+ return;
+ }
+ } catch (err) {
+ console.warn('mdd.locateBytes failed for', src, err);
+ }
+ }
+ }),
+ );
+}
+
+export const createMdictProvider = ({
+ dict,
+ fs,
+ label,
+}: CreateMdictProviderArgs): DictionaryProvider => {
+ let mdx: MDXInstance | null = null;
+ let mdds: MDDInstance[] = [];
+ let initPromise: Promise | null = null;
+ let initError: Error | null = null;
+ const trackedUrls: string[] = [];
+
+ const initOnce = async (): Promise => {
+ if (mdx) return;
+ if (initError) throw initError;
+ if (!initPromise) {
+ initPromise = (async () => {
+ const { MDX, MDD } = (await import('js-mdict')) as {
+ MDX: { create(file: Blob): Promise };
+ MDD: { create(file: Blob): Promise };
+ };
+
+ if (!dict.files.mdx) {
+ throw new Error('MDict bundle is missing the .mdx file');
+ }
+ const mdxFile = await fs.openFile(`${dict.bundleDir}/${dict.files.mdx}`, 'Dictionaries');
+ let mdxInst: MDXInstance;
+ try {
+ mdxInst = await MDX.create(mdxFile);
+ } catch (err) {
+ const message = (err as Error).message ?? String(err);
+ if (/encrypted file|user identification/i.test(message)) {
+ throw Object.assign(
+ new Error(
+ 'This MDX is registered to a specific user (record-block encryption); passcode-protected dictionaries are not supported.',
+ ),
+ { unsupported: true },
+ );
+ }
+ throw err;
+ }
+ // `meta.encrypt` is a bitmap. Bit 0 (record block encryption) needs a
+ // user passcode and isn't implemented by js-mdict. Bit 1 (key info
+ // block) is handled transparently via the ripemd128-based mdxDecrypt
+ // — those dictionaries are fully usable.
+ if ((mdxInst.meta?.encrypt ?? 0) & 1) {
+ throw Object.assign(
+ new Error(
+ 'This MDX is registered to a specific user (record-block encryption); passcode-protected dictionaries are not supported.',
+ ),
+ { unsupported: true },
+ );
+ }
+ const mddNames = dict.files.mdd ?? [];
+ const mddInsts: MDDInstance[] = [];
+ for (const name of mddNames) {
+ try {
+ const mddFile = await fs.openFile(`${dict.bundleDir}/${name}`, 'Dictionaries');
+ mddInsts.push(await MDD.create(mddFile));
+ } catch (err) {
+ console.warn('Failed to open MDD resource bundle', name, err);
+ }
+ }
+ mdx = mdxInst;
+ mdds = mddInsts;
+ })().catch((err) => {
+ initError = err instanceof Error ? err : new Error(String(err));
+ initPromise = null;
+ throw initError;
+ });
+ }
+ return initPromise;
+ };
+
+ return {
+ id: dict.id,
+ kind: 'mdict',
+ label: label ?? dict.name,
+ async lookup(word, ctx) {
+ try {
+ await initOnce();
+ } catch (err) {
+ const e = err as { unsupported?: boolean; message?: string };
+ if (e.unsupported) {
+ return { ok: false, reason: 'unsupported', message: e.message };
+ }
+ return {
+ ok: false,
+ reason: 'error',
+ message: `Failed to load dictionary: ${(err as Error).message}`,
+ };
+ }
+ if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
+ if (!mdx) return { ok: false, reason: 'error', message: 'MDX not initialized' };
+
+ try {
+ const result = await mdx.lookup(word);
+ if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
+ if (!result.definition) return { ok: false, reason: 'empty' };
+
+ const headword = document.createElement('h1');
+ headword.textContent = result.keyText || word;
+ headword.className = 'text-lg font-bold';
+ ctx.container.appendChild(headword);
+
+ const body = document.createElement('div');
+ body.innerHTML = result.definition;
+ body.className = 'mt-2 text-sm';
+ ctx.container.appendChild(body);
+
+ await resolveImageResources(body, mdds, ctx.signal, trackedUrls);
+ return { ok: true, headword: result.keyText, sourceLabel: dict.name };
+ } catch (err) {
+ return {
+ ok: false,
+ reason: 'error',
+ message: (err as Error).message,
+ };
+ }
+ },
+ dispose() {
+ for (const url of trackedUrls) {
+ try {
+ URL.revokeObjectURL(url);
+ } catch {
+ // ignore — already revoked or ephemeral environment without object URLs
+ }
+ }
+ trackedUrls.length = 0;
+ },
+ };
+};
diff --git a/apps/readest-app/src/services/dictionaries/providers/starDictProvider.ts b/apps/readest-app/src/services/dictionaries/providers/starDictProvider.ts
new file mode 100644
index 00000000..dacb39ea
--- /dev/null
+++ b/apps/readest-app/src/services/dictionaries/providers/starDictProvider.ts
@@ -0,0 +1,195 @@
+/**
+ * StarDict provider.
+ *
+ * Uses {@link StarDictReader} (this repo) instead of `foliate-js/dict.js`'s
+ * `StarDict` class. The upstream `DictZip.read` assumes per-chunk
+ * independent deflate streams (BFINAL=1 + Z_FULL_FLUSH boundaries) — but
+ * many real-world `.dict.dz` files are a single continuous deflate stream
+ * with the FEXTRA/RA index pointing at *uncompressed* offsets, which makes
+ * per-chunk inflate fail with `unexpected EOF`. Our reader gunzips the
+ * whole file once and slices by offset, which works for both variants and
+ * for raw uncompressed `.dict` files.
+ *
+ * v1 supports only single-character `sametypesequence` ∈ {m, h, x, t}:
+ * - `m` plain text (rendered with newline preservation)
+ * - `h`/`x` HTML/XHTML (set via innerHTML)
+ * - `t` phonetic (rendered as italic)
+ *
+ * Multi-type sequences, synonym-only bundles, image/audio types are
+ * flagged `unsupported` at import time and filtered out before this
+ * provider is instantiated.
+ */
+import type { DictionaryProvider, ImportedDictionary } from '../types';
+import type { BaseDir } from '@/types/system';
+import { StarDictReader, type StarDictEntry } from '../stardictReader';
+
+/** Subset of the file API the provider needs. Both `AppService` and `FileSystem` satisfy this. */
+export interface DictionaryFileOpener {
+ openFile(path: string, base: BaseDir): Promise;
+}
+
+const SAFE_TYPES = new Set(['m', 'h', 'x', 't']);
+
+const decoder = new TextDecoder('utf-8');
+
+const renderEntry = (
+ container: HTMLElement,
+ word: string,
+ bytes: Uint8Array,
+ type: string,
+ isAdditional: boolean,
+): void => {
+ const text = decoder.decode(bytes);
+
+ if (!isAdditional) {
+ const h1 = document.createElement('h1');
+ h1.textContent = word;
+ h1.className = 'text-lg font-bold';
+ container.appendChild(h1);
+ } else {
+ const h2 = document.createElement('h2');
+ h2.textContent = word;
+ h2.className = 'text-base font-semibold mt-4';
+ container.appendChild(h2);
+ }
+
+ if (type === 'h' || type === 'x') {
+ const div = document.createElement('div');
+ div.innerHTML = text;
+ div.className = 'mt-2 text-sm';
+ container.appendChild(div);
+ return;
+ }
+ if (type === 't') {
+ const em = document.createElement('em');
+ em.textContent = text;
+ em.className = 'mt-2 block text-sm italic not-eink:opacity-85';
+ container.appendChild(em);
+ return;
+ }
+ // 'm' plain — preserve newlines.
+ const pre = document.createElement('pre');
+ pre.textContent = text;
+ pre.className = 'mt-2 whitespace-pre-wrap break-words text-sm font-sans';
+ container.appendChild(pre);
+};
+
+export interface CreateStarDictProviderArgs {
+ dict: ImportedDictionary;
+ fs: DictionaryFileOpener;
+ /** Localized label override; defaults to the bundle name. */
+ label?: string;
+}
+
+/**
+ * Build a StarDict provider for one imported bundle. The provider lazily
+ * initializes its reader on first lookup so users with many dictionaries
+ * don't pay the parse + inflate cost up front for tabs they never open.
+ */
+export const createStarDictProvider = ({
+ dict,
+ fs,
+ label,
+}: CreateStarDictProviderArgs): DictionaryProvider => {
+ let reader: StarDictReader | null = null;
+ let initPromise: Promise | null = null;
+ let initError: Error | null = null;
+
+ const initOnce = async (): Promise => {
+ if (reader) return reader;
+ if (initError) throw initError;
+ if (!initPromise) {
+ initPromise = (async () => {
+ if (!dict.files.ifo || !dict.files.idx || !dict.files.dict) {
+ throw new Error('StarDict bundle is missing required files');
+ }
+ // Open every bundle file in parallel. Sidecars are optional —
+ // older imports won't have them; the reader falls back to
+ // scanning the source file when they're missing.
+ const [ifoFile, idxFile, dictFile, synFile, idxOffsetsFile, synOffsetsFile] =
+ await Promise.all([
+ fs.openFile(`${dict.bundleDir}/${dict.files.ifo}`, 'Dictionaries'),
+ fs.openFile(`${dict.bundleDir}/${dict.files.idx}`, 'Dictionaries'),
+ fs.openFile(`${dict.bundleDir}/${dict.files.dict}`, 'Dictionaries'),
+ dict.files.syn
+ ? fs.openFile(`${dict.bundleDir}/${dict.files.syn}`, 'Dictionaries')
+ : Promise.resolve(undefined),
+ dict.files.idxOffsets
+ ? fs
+ .openFile(`${dict.bundleDir}/${dict.files.idxOffsets}`, 'Dictionaries')
+ .catch(() => undefined)
+ : Promise.resolve(undefined),
+ dict.files.synOffsets
+ ? fs
+ .openFile(`${dict.bundleDir}/${dict.files.synOffsets}`, 'Dictionaries')
+ .catch(() => undefined)
+ : Promise.resolve(undefined),
+ ]);
+
+ const r = new StarDictReader();
+ await r.load({
+ ifo: ifoFile,
+ idx: idxFile,
+ dict: dictFile,
+ syn: synFile,
+ idxOffsets: idxOffsetsFile,
+ synOffsets: synOffsetsFile,
+ });
+ reader = r;
+ return r;
+ })().catch((err) => {
+ initError = err instanceof Error ? err : new Error(String(err));
+ initPromise = null;
+ throw initError;
+ });
+ }
+ return initPromise;
+ };
+
+ return {
+ id: dict.id,
+ kind: 'stardict',
+ label: label ?? dict.name,
+ async lookup(word, ctx) {
+ let r: StarDictReader;
+ try {
+ r = await initOnce();
+ } catch (err) {
+ return {
+ ok: false,
+ reason: 'error',
+ message: `Failed to load dictionary: ${(err as Error).message}`,
+ };
+ }
+ if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
+
+ const seq = r.ifo['sametypesequence'];
+ if (!seq || seq.length !== 1 || !SAFE_TYPES.has(seq)) {
+ return {
+ ok: false,
+ reason: 'unsupported',
+ message: 'StarDict format outside v1 support',
+ };
+ }
+
+ try {
+ let entry: StarDictEntry | undefined = await r.lookup(word);
+ if (!entry) entry = await r.resolveSynonym(word);
+ if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
+ if (!entry) return { ok: false, reason: 'empty' };
+
+ const bytes = await r.read(entry);
+ if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
+ if (!bytes.length) return { ok: false, reason: 'empty' };
+ renderEntry(ctx.container, entry.word, bytes, seq, false);
+ return { ok: true, headword: entry.word, sourceLabel: r.ifo['bookname'] || dict.name };
+ } catch (err) {
+ return {
+ ok: false,
+ reason: 'error',
+ message: (err as Error).message,
+ };
+ }
+ },
+ };
+};
diff --git a/apps/readest-app/src/services/dictionaries/providers/wikipediaProvider.ts b/apps/readest-app/src/services/dictionaries/providers/wikipediaProvider.ts
new file mode 100644
index 00000000..6f319e32
--- /dev/null
+++ b/apps/readest-app/src/services/dictionaries/providers/wikipediaProvider.ts
@@ -0,0 +1,111 @@
+/**
+ * Built-in Wikipedia provider.
+ *
+ * Looks up the selection text in `.wikipedia.org`'s REST summary API
+ * and renders the title block (with optional thumbnail-as-background) plus
+ * the rendered HTML extract.
+ *
+ * Extracted from the legacy `WikipediaPopup.tsx`. The legacy popup used
+ * `document.querySelector('main')` and `document.querySelector('footer')`
+ * — which would break inside a multi-tab popup where those globals point
+ * at the wrong tab. This provider writes into `ctx.container` instead.
+ * The footer is rendered by the shell; this provider's outcome carries
+ * `sourceLabel` so the shell shows attribution.
+ */
+import type { DictionaryProvider, DictionaryLookupOutcome } from '../types';
+import { BUILTIN_PROVIDER_IDS } from '../types';
+import { stubTranslation as _ } from '@/utils/misc';
+import { isTauriAppPlatform } from '@/services/environment';
+
+const isTauri = isTauriAppPlatform();
+
+export const wikipediaProvider: DictionaryProvider = {
+ id: BUILTIN_PROVIDER_IDS.wikipedia,
+ kind: 'builtin',
+ label: _('Wikipedia'),
+ async lookup(word, ctx): Promise {
+ const bookLang = typeof ctx.lang === 'string' ? ctx.lang : ctx.lang?.[0];
+ const langCode = bookLang ? bookLang.split('-')[0]! : 'en';
+ try {
+ const response = await fetch(
+ `https://${langCode}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(word)}`,
+ { signal: ctx.signal },
+ );
+ if (!response.ok) {
+ return { ok: false, reason: 'error', message: `HTTP ${response.status}` };
+ }
+ const data = await response.json();
+ if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
+
+ const hgroup = document.createElement('hgroup');
+ hgroup.style.color = 'white';
+ hgroup.style.backgroundPosition = 'center center';
+ hgroup.style.backgroundSize = 'cover';
+ hgroup.style.backgroundColor = 'rgba(0, 0, 0, .4)';
+ hgroup.style.backgroundBlendMode = 'darken';
+ hgroup.style.borderRadius = '6px';
+ hgroup.style.padding = '12px';
+ hgroup.style.marginBottom = '12px';
+ hgroup.style.minHeight = '100px';
+
+ const h1 = document.createElement('h1');
+ h1.innerHTML = data.titles?.display ?? word;
+ h1.className = 'text-lg font-bold';
+ hgroup.append(h1);
+
+ if (data.description) {
+ const description = document.createElement('p');
+ description.innerText = data.description;
+ hgroup.appendChild(description);
+ }
+
+ if (data.thumbnail?.source) {
+ hgroup.style.backgroundImage = `url("${data.thumbnail.source}")`;
+ }
+
+ const contentDiv = document.createElement('div');
+ contentDiv.innerHTML = data.extract_html ?? '';
+ contentDiv.className = 'p-2 text-sm';
+ if (data.dir) contentDiv.dir = data.dir;
+
+ ctx.container.append(hgroup, contentDiv);
+
+ // "Read on Wikipedia" link. The REST summary endpoint returns
+ // `content_urls.{desktop,mobile}.page` pointing at the canonical
+ // article. Fall back to a constructed URL if the API ever stops
+ // sending content_urls (it has been stable for years, but be safe).
+ const articleUrl: string =
+ (data.content_urls?.desktop?.page as string | undefined) ??
+ (data.content_urls?.mobile?.page as string | undefined) ??
+ `https://${langCode}.wikipedia.org/wiki/${encodeURIComponent(word)}`;
+
+ const linkWrapper = document.createElement('p');
+ linkWrapper.className = 'mt-3 px-2 text-sm';
+ const link = document.createElement('a');
+ link.href = articleUrl;
+ // Skip target="_blank" on Tauri. iOS WebView dispatches a separate
+ // "open externally" path for `_blank` anchors that goes through the
+ // shell scope and fails with "Operation not permitted" — even when
+ // a click handler `preventDefault`s. The popup's container click
+ // handler routes the click through `openUrl` instead.
+ if (!isTauri) link.target = '_blank';
+ link.rel = 'noopener noreferrer';
+ link.className = 'not-eink:text-primary underline';
+ link.textContent = _('Read on Wikipedia →');
+ linkWrapper.appendChild(link);
+ ctx.container.append(linkWrapper);
+
+ return { ok: true, headword: word, sourceLabel: 'Wikipedia (CC BY-SA)' };
+ } catch (error) {
+ if ((error as { name?: string }).name === 'AbortError') {
+ return { ok: false, reason: 'error', message: 'aborted' };
+ }
+ console.error('Wikipedia lookup failed', error);
+ return {
+ ok: false,
+ reason: 'error',
+ message: error instanceof Error ? error.message : String(error),
+ };
+ }
+ },
+};
diff --git a/apps/readest-app/src/services/dictionaries/providers/wiktionaryProvider.ts b/apps/readest-app/src/services/dictionaries/providers/wiktionaryProvider.ts
new file mode 100644
index 00000000..b7317def
--- /dev/null
+++ b/apps/readest-app/src/services/dictionaries/providers/wiktionaryProvider.ts
@@ -0,0 +1,186 @@
+/**
+ * Built-in Wiktionary provider.
+ *
+ * Looks up the headword in en.wiktionary.org's REST API. For CJK headwords
+ * (lang code starts with `zh`/`zho`), falls back to {@link fetchChineseDefinition}
+ * which scrapes Wiktionary's wikitext for pinyin + meanings.
+ *
+ * In-popup link interception: any `a[rel="mw:WikiLink"]` in a definition is
+ * rewritten to call `ctx.onNavigate(title)` instead of navigating away. The
+ * shell uses this to push onto the per-tab history.
+ *
+ * Extracted from the legacy `WiktionaryPopup.tsx`. The fetch + DOM-build
+ * code is functionally identical; the only change is writing into
+ * `ctx.container` instead of a global `` element so the renderer can
+ * coexist with other tabs in the same popup.
+ */
+import type { DictionaryProvider, DictionaryLookupOutcome } from '../types';
+import { BUILTIN_PROVIDER_IDS } from '../types';
+import { fetchChineseDefinition } from '../chineseDict';
+import { normalizedLangCode } from '@/utils/lang';
+import { stubTranslation as _ } from '@/utils/misc';
+
+type Definition = {
+ definition: string;
+ examples?: string[];
+};
+
+type Result = {
+ partOfSpeech: string;
+ definitions: Definition[];
+ language: string;
+};
+
+const interceptDictLinks = (
+ definitionHtml: string,
+ onNavigate?: (word: string) => void,
+): HTMLElement[] => {
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = definitionHtml;
+ const links = wrapper.querySelectorAll('a[rel="mw:WikiLink"]');
+ links.forEach((link) => {
+ const title = link.getAttribute('title');
+ if (!title) return;
+ link.addEventListener('click', (event) => {
+ event.preventDefault();
+ onNavigate?.(title);
+ });
+ link.className = 'not-eink:text-primary underline cursor-pointer';
+ });
+ return Array.from(wrapper.childNodes) as HTMLElement[];
+};
+
+const renderChinese = async (
+ word: string,
+ container: HTMLElement,
+ signal: AbortSignal,
+): Promise => {
+ const entry = await fetchChineseDefinition(word);
+ if (signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
+ if (!entry) return { ok: false, reason: 'empty' };
+
+ const hgroup = document.createElement('hgroup');
+ const h1 = document.createElement('h1');
+ h1.textContent = entry.word;
+ h1.className = 'text-lg font-bold';
+ hgroup.append(h1);
+
+ if (entry.pinyin) {
+ const pinyinEl = document.createElement('p');
+ pinyinEl.textContent = entry.pinyin;
+ pinyinEl.className = 'text-base italic not-eink:opacity-85';
+ hgroup.append(pinyinEl);
+ }
+
+ const langEl = document.createElement('p');
+ langEl.textContent = 'Chinese';
+ langEl.className = 'text-sm italic not-eink:opacity-75';
+ hgroup.append(langEl);
+ container.append(hgroup);
+
+ entry.definitions.forEach(({ partOfSpeech, meanings }) => {
+ const h2 = document.createElement('h2');
+ h2.textContent = partOfSpeech;
+ h2.className = 'text-base font-semibold mt-4';
+ const ol = document.createElement('ol');
+ ol.className = 'pl-8 list-decimal';
+ meanings.forEach((meaning) => {
+ const li = document.createElement('li');
+ li.textContent = meaning;
+ ol.appendChild(li);
+ });
+ container.appendChild(h2);
+ container.appendChild(ol);
+ });
+
+ return { ok: true, headword: entry.word, sourceLabel: 'Wiktionary (CC BY-SA)' };
+};
+
+const renderWiktionary = async (
+ word: string,
+ language: string | undefined,
+ container: HTMLElement,
+ signal: AbortSignal,
+ onNavigate?: (word: string) => void,
+): Promise => {
+ const response = await fetch(
+ `https://en.wiktionary.org/api/rest_v1/page/definition/${encodeURIComponent(word)}`,
+ { signal },
+ );
+ if (!response.ok) {
+ return { ok: false, reason: 'error', message: `HTTP ${response.status}` };
+ }
+ const json = await response.json();
+ if (signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
+ const results: Result[] | undefined = language
+ ? json[language] || json['en']
+ : json[Object.keys(json)[0]!];
+ if (!results || results.length === 0) {
+ return { ok: false, reason: 'empty' };
+ }
+
+ const hgroup = document.createElement('hgroup');
+ const h1 = document.createElement('h1');
+ h1.textContent = word;
+ h1.className = 'text-lg font-bold';
+ const p = document.createElement('p');
+ p.textContent = results[0]!.language;
+ p.className = 'text-sm italic not-eink:opacity-75';
+ hgroup.append(h1, p);
+ container.append(hgroup);
+
+ results.forEach(({ partOfSpeech, definitions }: Result) => {
+ const h2 = document.createElement('h2');
+ h2.textContent = partOfSpeech;
+ h2.className = 'text-base font-semibold mt-4';
+ const ol = document.createElement('ol');
+ ol.className = 'pl-8 list-decimal';
+ definitions.forEach(({ definition, examples }: Definition) => {
+ if (!definition) return;
+ const li = document.createElement('li');
+ const processed = interceptDictLinks(definition, onNavigate);
+ li.append(...processed);
+ if (examples) {
+ const ul = document.createElement('ul');
+ ul.className = 'pl-8 list-disc text-sm italic not-eink:opacity-75';
+ examples.forEach((example) => {
+ const exampleLi = document.createElement('li');
+ exampleLi.innerHTML = example;
+ ul.appendChild(exampleLi);
+ });
+ li.appendChild(ul);
+ }
+ ol.appendChild(li);
+ });
+ container.appendChild(h2);
+ container.appendChild(ol);
+ });
+
+ return { ok: true, headword: word, sourceLabel: 'Wiktionary (CC BY-SA)' };
+};
+
+export const wiktionaryProvider: DictionaryProvider = {
+ id: BUILTIN_PROVIDER_IDS.wiktionary,
+ kind: 'builtin',
+ label: _('Wiktionary'),
+ async lookup(word, ctx) {
+ const langCode = typeof ctx.lang === 'string' ? ctx.lang : ctx.lang?.[0];
+ const isChinese = langCode ? normalizedLangCode(langCode) === 'zh' : false;
+ try {
+ if (isChinese) {
+ return await renderChinese(word, ctx.container, ctx.signal);
+ }
+ return await renderWiktionary(word, langCode, ctx.container, ctx.signal, ctx.onNavigate);
+ } catch (error) {
+ if ((error as { name?: string }).name === 'AbortError') {
+ return { ok: false, reason: 'error', message: 'aborted' };
+ }
+ console.error('Wiktionary lookup failed', error);
+ return {
+ ok: false,
+ reason: 'error',
+ message: error instanceof Error ? error.message : String(error),
+ };
+ }
+ },
+};
diff --git a/apps/readest-app/src/services/dictionaries/registry.ts b/apps/readest-app/src/services/dictionaries/registry.ts
new file mode 100644
index 00000000..1d8e18fd
--- /dev/null
+++ b/apps/readest-app/src/services/dictionaries/registry.ts
@@ -0,0 +1,110 @@
+/**
+ * Dictionary provider registry.
+ *
+ * Returns the ordered list of {@link DictionaryProvider} instances visible
+ * in the lookup popup, given the user's current `dictionarySettings` (order,
+ * enable flags), the imported-dictionary metadata, and the filesystem
+ * accessor used by import-backed providers (StarDict / MDict) to lazily open
+ * their bundle files.
+ *
+ * Provider instances are cached at module scope (keyed by id) so subsequent
+ * lookups within the same session reuse parsed indexes / object URLs. This
+ * cache is **not** in zustand: provider instances and their object URLs are
+ * runtime-only and non-serializable; storing them alongside metadata would
+ * pollute the synced settings shape.
+ */
+import type { DictionaryProvider, DictionarySettings, ImportedDictionary } from './types';
+import { BUILTIN_PROVIDER_IDS } from './types';
+import { wiktionaryProvider } from './providers/wiktionaryProvider';
+import { wikipediaProvider } from './providers/wikipediaProvider';
+import { createStarDictProvider, type DictionaryFileOpener } from './providers/starDictProvider';
+import { createMdictProvider } from './providers/mdictProvider';
+
+const instanceCache = new Map();
+
+interface RegistryArgs {
+ settings: DictionarySettings;
+ dictionaries: ImportedDictionary[];
+ /**
+ * Required when the provider order contains imported (stardict / mdict)
+ * dictionaries — their providers open files via this accessor on first
+ * lookup. Builtin-only callers (e.g. tests) may omit it.
+ */
+ fs?: DictionaryFileOpener;
+}
+
+const builtinFor = (id: string): DictionaryProvider | undefined => {
+ if (id === BUILTIN_PROVIDER_IDS.wiktionary) return wiktionaryProvider;
+ if (id === BUILTIN_PROVIDER_IDS.wikipedia) return wikipediaProvider;
+ return undefined;
+};
+
+const getOrCreate = (
+ id: string,
+ dict: ImportedDictionary | undefined,
+ fs: DictionaryFileOpener | undefined,
+): DictionaryProvider | undefined => {
+ const cached = instanceCache.get(id);
+ if (cached) return cached;
+ const builtin = builtinFor(id);
+ if (builtin) {
+ instanceCache.set(id, builtin);
+ return builtin;
+ }
+ if (!dict) return undefined;
+ if (!fs) return undefined;
+ if (dict.kind === 'stardict') {
+ const provider = createStarDictProvider({ dict, fs });
+ instanceCache.set(id, provider);
+ return provider;
+ }
+ if (dict.kind === 'mdict') {
+ const provider = createMdictProvider({ dict, fs });
+ instanceCache.set(id, provider);
+ return provider;
+ }
+ return undefined;
+};
+
+/**
+ * Returns the ordered list of enabled providers ready for the popup.
+ * - Filters out disabled ids.
+ * - Filters out imported entries that are soft-deleted, unavailable on this
+ * device, or flagged unsupported.
+ * - Preserves the order in `settings.providerOrder`.
+ */
+export const getEnabledProviders = ({
+ settings,
+ dictionaries,
+ fs,
+}: RegistryArgs): DictionaryProvider[] => {
+ const dictById = new Map(dictionaries.map((d) => [d.id, d]));
+ const out: DictionaryProvider[] = [];
+ for (const id of settings.providerOrder) {
+ if (settings.providerEnabled[id] === false) continue;
+ if (id.startsWith('builtin:')) {
+ const provider = getOrCreate(id, undefined, undefined);
+ if (provider) out.push(provider);
+ continue;
+ }
+ const dict = dictById.get(id);
+ if (!dict) continue;
+ if (dict.deletedAt || dict.unavailable || dict.unsupported) continue;
+ const provider = getOrCreate(id, dict, fs);
+ if (provider) out.push(provider);
+ }
+ return out;
+};
+
+/** Drop a single provider from the cache (e.g. after dictionary deletion). */
+export const evictProvider = (id: string): void => {
+ const cached = instanceCache.get(id);
+ cached?.dispose?.();
+ instanceCache.delete(id);
+};
+
+/** Drop everything. Test helper. */
+export const __resetRegistryForTests = (): void => {
+ for (const [, provider] of instanceCache) provider.dispose?.();
+ instanceCache.clear();
+};
diff --git a/apps/readest-app/src/services/dictionaries/stardictReader.ts b/apps/readest-app/src/services/dictionaries/stardictReader.ts
new file mode 100644
index 00000000..978c472a
--- /dev/null
+++ b/apps/readest-app/src/services/dictionaries/stardictReader.ts
@@ -0,0 +1,624 @@
+/**
+ * Self-contained StarDict reader with lazy random-access binary search
+ * across all three bundle parts: `.idx`, `.syn`, and `.dict.dz`.
+ *
+ * Replaces `foliate-js/dict.js`'s `StarDict` / `DictZip`. The upstream
+ * `DictZip.read` calls `inflateSync` on each chunk, but per-chunk DictZip
+ * data ends at `Z_FULL_FLUSH` boundaries (BFINAL=0) — `inflateSync`
+ * rejects those with `unexpected EOF`. fflate's streaming `Inflate` class
+ * accepts non-final input and emits chunk bytes via `ondata`; that's what
+ * we use here.
+ *
+ * # `.idx` / `.syn`: lazy random-access binary search
+ *
+ * Each is a sorted list of variable-length records:
+ * `\0`
+ * (`.idx` payload = 8 bytes; `.syn` payload = 4 bytes.)
+ *
+ * Eagerly parsing all entries into JS objects is heap-expensive: cmudict's
+ * 105K entries cost ~10 MB. We instead:
+ *
+ * 1. Scan the bytes once at init to find every entry's start offset.
+ * Stored as an `Int32Array` (cmudict: 420 KB). The raw bytes are
+ * then dropped — the original Blob stays alive for slice reads.
+ * 2. At lookup time, binary search the offsets. Each probe reads one
+ * entry's bytes (~16 B) from the Blob, decodes, compares.
+ * 3. LRU-cache decoded entries (default 256).
+ *
+ * `.syn` further defers its offset scan until first synonym fallback —
+ * sessions that never miss the primary index pay nothing for synonyms.
+ * An optional offsets sidecar (see {@link serializeOffsetsSidecar}) lets
+ * init skip the offset scan entirely.
+ *
+ * # `.dict.dz`: lazy chunk decompression
+ *
+ * DictZip files have a FEXTRA/RA subfield listing per-chunk compressed
+ * sizes; chunks are separated by `Z_FULL_FLUSH` so each chunk's
+ * uncompressed bytes are exactly `chlen` long (the last may be shorter).
+ *
+ * At init we parse the FEXTRA, then probe-inflate chunk 0 with streaming
+ * `Inflate` to confirm it works. If yes (the common case for properly-
+ * tooled `.dict.dz` files like cmudict and eng-nld), we keep only the
+ * chunk metadata (~few KB) and the original Blob. Each lookup reads only
+ * the chunks containing the entry's uncompressed range, inflates them
+ * via streaming `Inflate`, and caches the decompressed output (LRU,
+ * default 16 chunks ≈ 1 MB).
+ *
+ * If FEXTRA/RA is missing or the probe fails, we fall back to whole-file
+ * gunzip at init and slice the in-memory buffer thereafter — same as
+ * before.
+ *
+ * Net effect (cmudict):
+ * Init heap before: ~1.3 MB (whole inflated dict) + ~10 MB (parsed idx).
+ * Init heap after: ~420 KB (idx offsets) + chunk metadata (~few KB)
+ * + LRU chunk cache (≤ ~1 MB after warmup).
+ */
+import { gunzipSync, Inflate } from 'fflate';
+
+export interface StarDictEntry {
+ word: string;
+ offset: number;
+ size: number;
+}
+
+const decoder = new TextDecoder('utf-8');
+
+const GZIP_MAGIC = [0x1f, 0x8b];
+
+const isGzip = (bytes: Uint8Array): boolean =>
+ bytes.length >= 2 && bytes[0] === GZIP_MAGIC[0] && bytes[1] === GZIP_MAGIC[1];
+
+/** Parse the key=value `.ifo` text into a record. */
+export function parseIfo(text: string): Record {
+ const out: Record = {};
+ for (const raw of text.split(/\r?\n/)) {
+ const line = raw.trim();
+ if (!line || !line.includes('=')) continue;
+ const eq = line.indexOf('=');
+ out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
+ }
+ return out;
+}
+
+/**
+ * Scan a `.idx` (or `.syn`) byte buffer to find every entry's start offset.
+ * Returns an `Int32Array` of byte offsets — one per entry.
+ *
+ * Each entry: `\0`. The payload is fixed-size:
+ * - `.idx`: 8 bytes (offset:u32be + size:u32be)
+ * - `.syn`: 4 bytes (idx-index:u32be)
+ */
+export function scanEntryOffsets(bytes: Uint8Array, payloadBytes: number): Int32Array {
+ const offsets: number[] = [];
+ let i = 0;
+ while (i < bytes.length) {
+ offsets.push(i);
+ while (i < bytes.length && bytes[i] !== 0) i++;
+ if (i >= bytes.length) break;
+ i += 1 + payloadBytes; // skip null terminator + payload
+ }
+ return new Int32Array(offsets);
+}
+
+// ---------------------------------------------------------------------------
+// Offset sidecar serialization.
+//
+// Format:
+// bytes 0-3: magic 'SDOF' (StarDict OFfsets)
+// bytes 4-7: u32 little-endian version (current = 1)
+// bytes 8+: raw Int32Array little-endian payload (one i32 per entry start)
+//
+// LE byte order is used unconditionally — every platform we ship to (web,
+// Tauri on x86 / ARM64) is LE. If we ever need cross-endian sync, version-bump
+// and add a byte-swap path.
+// ---------------------------------------------------------------------------
+
+const SIDECAR_MAGIC = [0x53, 0x44, 0x4f, 0x46]; // 'SDOF'
+const SIDECAR_VERSION = 1;
+const SIDECAR_HEADER_SIZE = 8;
+
+/** Serialize an offsets array to a single allocation suitable for `fs.writeFile`. */
+export function serializeOffsetsSidecar(offsets: Int32Array): Uint8Array {
+ const out = new Uint8Array(SIDECAR_HEADER_SIZE + offsets.byteLength);
+ out[0] = SIDECAR_MAGIC[0]!;
+ out[1] = SIDECAR_MAGIC[1]!;
+ out[2] = SIDECAR_MAGIC[2]!;
+ out[3] = SIDECAR_MAGIC[3]!;
+ // Version (u32 LE).
+ const view = new DataView(out.buffer);
+ view.setUint32(4, SIDECAR_VERSION, true);
+ // Payload — direct memcpy of the Int32Array's bytes.
+ out.set(
+ new Uint8Array(offsets.buffer, offsets.byteOffset, offsets.byteLength),
+ SIDECAR_HEADER_SIZE,
+ );
+ return out;
+}
+
+/** Parse a sidecar blob. Returns `null` for missing / wrong-magic / wrong-version. */
+export function parseOffsetsSidecar(bytes: Uint8Array): Int32Array | null {
+ if (bytes.length < SIDECAR_HEADER_SIZE) return null;
+ for (let i = 0; i < SIDECAR_MAGIC.length; i++) {
+ if (bytes[i] !== SIDECAR_MAGIC[i]) return null;
+ }
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const version = view.getUint32(4, true);
+ if (version !== SIDECAR_VERSION) return null;
+
+ const payloadLen = bytes.byteLength - SIDECAR_HEADER_SIZE;
+ if (payloadLen % 4 !== 0) return null;
+ // Copy into a freshly-allocated Int32Array so the consumer owns aligned
+ // memory regardless of how `bytes` was sliced upstream.
+ const out = new Int32Array(payloadLen / 4);
+ const src = new Int32Array(bytes.buffer, bytes.byteOffset + SIDECAR_HEADER_SIZE, payloadLen / 4);
+ out.set(src);
+ return out;
+}
+
+const cmpAscii = (a: string, b: string): number => {
+ const x = a.toLowerCase();
+ const y = b.toLowerCase();
+ return x < y ? -1 : x > y ? 1 : 0;
+};
+
+/**
+ * Bounded LRU. Tiny enough we don't bother with a real linked-list
+ * implementation; reusing the Map insertion order is fine.
+ */
+class LRU {
+ private readonly max: number;
+ private readonly map = new Map();
+
+ constructor(max: number) {
+ this.max = max;
+ }
+
+ get(key: K): V | undefined {
+ const v = this.map.get(key);
+ if (v !== undefined) {
+ // Move to end (most-recently-used).
+ this.map.delete(key);
+ this.map.set(key, v);
+ }
+ return v;
+ }
+
+ set(key: K, value: V): void {
+ if (this.map.has(key)) this.map.delete(key);
+ this.map.set(key, value);
+ if (this.map.size > this.max) {
+ // Evict oldest.
+ const first = this.map.keys().next().value as K | undefined;
+ if (first !== undefined) this.map.delete(first);
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// DictZip parsing + lazy chunk decompression.
+//
+// A `.dict.dz` file is a standard gzip with a FEXTRA "RA" subfield
+// listing the compressed length of each chunk. Each chunk's uncompressed
+// length is exactly `chlen` (the last may be shorter). Chunks are
+// separated by `Z_FULL_FLUSH` (BFINAL=0 + sync marker), so each chunk's
+// deflate stream is non-terminating — `inflateSync` rejects them with
+// "unexpected EOF". fflate's streaming `Inflate.push(bytes, false)`
+// handles them correctly and emits chunk bytes via `ondata`.
+// ---------------------------------------------------------------------------
+
+interface DictZipMeta {
+ /** Uncompressed bytes per chunk (last may be shorter). */
+ chlen: number;
+ /** Compressed bytes per chunk; sums to `compressedDataSize`. */
+ chunkSizes: number[];
+ /** Byte offset within the file where compressed chunk data begins. */
+ compressedDataOffset: number;
+}
+
+/**
+ * Parse the gzip header + FEXTRA RA subfield. Returns `null` for
+ * non-gzip files or gzip files without an RA subfield (those need
+ * whole-file gunzip).
+ */
+function parseDictZipHeader(bytes: Uint8Array): DictZipMeta | null {
+ if (bytes.length < 12 || bytes[0] !== 0x1f || bytes[1] !== 0x8b || bytes[2] !== 0x08) {
+ return null;
+ }
+ const flg = bytes[3]!;
+ const hasFEXTRA = (flg & 0b100) !== 0;
+ if (!hasFEXTRA) return null;
+
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const xlen = view.getUint16(10, true);
+ let chlen = 0;
+ let chcnt = 0;
+ const chunkSizes: number[] = [];
+
+ let p = 12;
+ while (p + 4 <= 12 + xlen) {
+ const si1 = bytes[p]!;
+ const si2 = bytes[p + 1]!;
+ const slen = view.getUint16(p + 2, true);
+ if (si1 === 0x52 && si2 === 0x41) {
+ // RA subfield: ver(2) + chlen(2) + chcnt(2) + chcnt × chunkSize(2).
+ const ver = view.getUint16(p + 4, true);
+ if (ver !== 1) return null;
+ chlen = view.getUint16(p + 6, true);
+ chcnt = view.getUint16(p + 8, true);
+ for (let i = 0; i < chcnt; i++) {
+ chunkSizes.push(view.getUint16(p + 10 + 2 * i, true));
+ }
+ }
+ p += 4 + slen;
+ }
+ if (chcnt === 0 || chunkSizes.length !== chcnt) return null;
+
+ // Skip past FEXTRA + optional FNAME, FCOMMENT, FHCRC.
+ let offset = 12 + xlen;
+ if (flg & 0b1000) {
+ while (offset < bytes.length && bytes[offset] !== 0) offset++;
+ offset++;
+ }
+ if (flg & 0b10000) {
+ while (offset < bytes.length && bytes[offset] !== 0) offset++;
+ offset++;
+ }
+ if (flg & 0b10) offset += 2;
+
+ return { chlen, chunkSizes, compressedDataOffset: offset };
+}
+
+/**
+ * Inflate a single DictZip chunk via fflate's streaming `Inflate`. The
+ * chunk's deflate stream ends at `Z_FULL_FLUSH` (BFINAL=0 — `inflateSync`
+ * would reject), so we push with `final=false` and capture the data
+ * emitted by `ondata`. Returns `null` on failure.
+ */
+function inflateChunkStreaming(chunkBytes: Uint8Array): Uint8Array | null {
+ let result: Uint8Array | null = null;
+ try {
+ const inf = new Inflate();
+ inf.ondata = (data, _final) => {
+ // The first `ondata` carries this chunk's uncompressed bytes; any
+ // subsequent calls would be from feeding further input (we don't).
+ if (result === null) result = data;
+ };
+ inf.push(chunkBytes, false);
+ } catch {
+ return null;
+ }
+ if (!result || (result as Uint8Array).length === 0) return null;
+ return result;
+}
+
+class DictZipChunkedDict {
+ private blob: Blob;
+ private meta: DictZipMeta;
+ private chunkOffsets: number[];
+ /** Total uncompressed dict size, derived from chunk count × chlen (last chunk may be shorter; we don't know its exact size yet). */
+ private chunkCache: LRU;
+
+ constructor(blob: Blob, meta: DictZipMeta, cacheSize: number) {
+ this.blob = blob;
+ this.meta = meta;
+ this.chunkCache = new LRU(cacheSize);
+ // Precompute starting compressed offset of each chunk for slice reads.
+ const offsets: number[] = [];
+ let acc = meta.compressedDataOffset;
+ for (const cs of meta.chunkSizes) {
+ offsets.push(acc);
+ acc += cs;
+ }
+ this.chunkOffsets = offsets;
+ }
+
+ /** Read `size` uncompressed bytes starting at `offset`. */
+ async read(offset: number, size: number): Promise {
+ const chlen = this.meta.chlen;
+ const startChunk = Math.floor(offset / chlen);
+ const endChunk = Math.floor((offset + size - 1) / chlen);
+
+ if (startChunk === endChunk) {
+ const chunk = await this.getChunk(startChunk);
+ const local = offset - startChunk * chlen;
+ return chunk.subarray(local, local + size);
+ }
+ // Spans multiple chunks — concatenate.
+ const parts: Uint8Array[] = [];
+ let totalLen = 0;
+ for (let i = startChunk; i <= endChunk; i++) {
+ const chunk = await this.getChunk(i);
+ parts.push(chunk);
+ totalLen += chunk.length;
+ }
+ const combined = new Uint8Array(totalLen);
+ let pos = 0;
+ for (const p of parts) {
+ combined.set(p, pos);
+ pos += p.length;
+ }
+ const local = offset - startChunk * chlen;
+ return combined.subarray(local, local + size);
+ }
+
+ private async getChunk(i: number): Promise {
+ const cached = this.chunkCache.get(i);
+ if (cached) return cached;
+ const start = this.chunkOffsets[i]!;
+ const compressedSize = this.meta.chunkSizes[i]!;
+ const compressed = new Uint8Array(
+ await this.blob.slice(start, start + compressedSize).arrayBuffer(),
+ );
+ const inflated = inflateChunkStreaming(compressed);
+ if (!inflated) throw new Error(`Failed to inflate DictZip chunk ${i}`);
+ this.chunkCache.set(i, inflated);
+ return inflated;
+ }
+}
+
+export interface StarDictReaderOpts {
+ ifo: Blob;
+ idx: Blob;
+ /** Either `.dict.dz` (gzip) or a raw `.dict` (no compression). */
+ dict: Blob;
+ syn?: Blob;
+ /**
+ * Optional `.idx.offsets` sidecar (see {@link serializeOffsetsSidecar}).
+ * When provided and valid, init skips the full `.idx` scan — the only
+ * `.idx` reads are the small per-lookup probes.
+ */
+ idxOffsets?: Blob;
+ /** Optional `.syn.offsets` sidecar — same idea for `.syn`. */
+ synOffsets?: Blob;
+ /** LRU cache size for decoded entries. Defaults to 256. */
+ cacheSize?: number;
+}
+
+export class StarDictReader {
+ ifo: Record = {};
+
+ // Dict-reading mode. Exactly one of these is populated at init:
+ // - `dictChunked`: lazy chunk decompression (proper DictZip files).
+ // - `dictBuffer`: whole-file gunzip in memory (fallback when FEXTRA
+ // is absent or chunk-probe fails).
+ private dictChunked: DictZipChunkedDict | null = null;
+ private dictBuffer: Uint8Array = new Uint8Array();
+
+ // .idx state — populated eagerly at init.
+ private idxBlob: Blob | null = null;
+ /** Byte offset of each entry's start within `.idx`. */
+ private idxOffsets: Int32Array = new Int32Array(0);
+ /** Number of entries (= idxOffsets.length, cached for hot path). */
+ private idxCount = 0;
+
+ // .syn state — populated lazily on first {@link resolveSynonym} call.
+ private synBlob: Blob | null = null;
+ private synOffsets: Int32Array = new Int32Array(0);
+ private synCount = 0;
+ private synBuilt = false;
+ private synBuildPromise: Promise | null = null;
+
+ // LRU caches — keyed by entry index within their respective offset arrays.
+ private idxCache: LRU;
+ private synCache: LRU;
+
+ constructor(cacheSize = 256) {
+ this.idxCache = new LRU(cacheSize);
+ this.synCache = new LRU(cacheSize);
+ }
+
+ async load(opts: StarDictReaderOpts): Promise {
+ const cacheSize = opts.cacheSize ?? 256;
+ if (opts.cacheSize !== undefined) {
+ this.idxCache = new LRU(cacheSize);
+ this.synCache = new LRU(cacheSize);
+ }
+
+ // Read the small files in parallel. We deliberately skip
+ // `opts.dict.arrayBuffer()` here — the dict is read in fragments
+ // below depending on whether lazy chunk mode is viable.
+ const [ifoBuf, idxOffsetsBuf, synOffsetsBuf, dictHeadBuf] = await Promise.all([
+ opts.ifo.arrayBuffer(),
+ opts.idxOffsets ? opts.idxOffsets.arrayBuffer() : Promise.resolve(undefined),
+ opts.synOffsets ? opts.synOffsets.arrayBuffer() : Promise.resolve(undefined),
+ // Read just the gzip header + FEXTRA region (much less than the
+ // whole file). 64 KB is enormously generous for a FEXTRA RA
+ // subfield — even thousands of chunks would fit in a few KB.
+ opts.dict.slice(0, Math.min(opts.dict.size, 64 * 1024)).arrayBuffer(),
+ ]);
+
+ this.ifo = parseIfo(decoder.decode(new Uint8Array(ifoBuf)));
+
+ const offsetBits = this.ifo['idxoffsetbits'] ? parseInt(this.ifo['idxoffsetbits'], 10) : 32;
+ if (offsetBits !== 32) {
+ throw new Error(`StarDict idxoffsetbits=${offsetBits} not supported (only 32)`);
+ }
+
+ // Resolve the .idx offsets from sidecar if available + valid; otherwise
+ // fall back to scanning the raw .idx bytes.
+ let idxOffsets: Int32Array | null = null;
+ if (idxOffsetsBuf) {
+ idxOffsets = parseOffsetsSidecar(new Uint8Array(idxOffsetsBuf));
+ }
+ if (!idxOffsets) {
+ const idxBytes = new Uint8Array(await opts.idx.arrayBuffer());
+ idxOffsets = scanEntryOffsets(idxBytes, /* payloadBytes */ 8);
+ }
+ this.idxOffsets = idxOffsets;
+ this.idxCount = idxOffsets.length;
+ this.idxBlob = opts.idx;
+
+ // Try lazy chunk mode for `.dict.dz`. Parse FEXTRA, probe-inflate
+ // chunk 0; on success, retain only the metadata + Blob. On failure,
+ // fall back to whole-file gunzip.
+ const dictHead = new Uint8Array(dictHeadBuf);
+ if (isGzip(dictHead)) {
+ const meta = parseDictZipHeader(dictHead);
+ if (meta && (await this.probeChunkInflate(opts.dict, meta))) {
+ this.dictChunked = new DictZipChunkedDict(
+ opts.dict,
+ meta,
+ /* chunk LRU size */ Math.max(8, Math.floor(cacheSize / 16)),
+ );
+ } else {
+ // FEXTRA missing or chunk probe failed — gunzip the whole file.
+ const dictBytes = new Uint8Array(await opts.dict.arrayBuffer());
+ this.dictBuffer = gunzipSync(dictBytes);
+ }
+ } else {
+ // Raw .dict (no compression): keep bytes as-is.
+ this.dictBuffer = new Uint8Array(await opts.dict.arrayBuffer());
+ }
+
+ // .syn: keep the Blob, accept its sidecar eagerly if provided. If not,
+ // the offset table is built lazily on first synonym fallback.
+ if (opts.syn) {
+ this.synBlob = opts.syn;
+ if (synOffsetsBuf) {
+ const parsed = parseOffsetsSidecar(new Uint8Array(synOffsetsBuf));
+ if (parsed) {
+ this.synOffsets = parsed;
+ this.synCount = parsed.length;
+ this.synBuilt = true;
+ }
+ }
+ }
+ }
+
+ /**
+ * Read chunk 0 of a candidate DictZip and try to inflate it via the
+ * streaming inflater. If that succeeds and produces ≤ chlen bytes, the
+ * file's chunks are independently decompressible — lazy mode is viable.
+ */
+ private async probeChunkInflate(blob: Blob, meta: DictZipMeta): Promise {
+ if (meta.chunkSizes.length === 0) return false;
+ const cs = meta.chunkSizes[0]!;
+ const start = meta.compressedDataOffset;
+ const compressed = new Uint8Array(await blob.slice(start, start + cs).arrayBuffer());
+ const inflated = inflateChunkStreaming(compressed);
+ return !!inflated && inflated.length > 0 && inflated.length <= meta.chlen;
+ }
+
+ /**
+ * Resolve an entry's bytes from the dict.
+ *
+ * Lazy-chunk mode: reads the chunks containing the entry's uncompressed
+ * range from the dict Blob and streaming-inflates them.
+ * Whole-file mode: in-memory subarray.
+ */
+ async read(entry: StarDictEntry): Promise {
+ if (this.dictChunked) {
+ return this.dictChunked.read(entry.offset, entry.size);
+ }
+ return this.dictBuffer.subarray(entry.offset, entry.offset + entry.size);
+ }
+
+ /** Number of entries — exposed for tests. */
+ get entryCount(): number {
+ return this.idxCount;
+ }
+
+ /**
+ * Decode one `.idx` entry. Each entry's bytes span
+ * `[idxOffsets[i], idxOffsets[i+1])` (or to end-of-file for the last).
+ * Cached in `idxCache`.
+ */
+ private async decodeIdxEntry(i: number): Promise {
+ const cached = this.idxCache.get(i);
+ if (cached) return cached;
+ if (!this.idxBlob) throw new Error('idx blob not loaded');
+ const start = this.idxOffsets[i]!;
+ const end = i + 1 < this.idxCount ? this.idxOffsets[i + 1]! : this.idxBlob.size;
+ const bytes = new Uint8Array(await this.idxBlob.slice(start, end).arrayBuffer());
+
+ let nullPos = 0;
+ while (nullPos < bytes.length && bytes[nullPos] !== 0) nullPos++;
+ const word = decoder.decode(bytes.subarray(0, nullPos));
+ const view = new DataView(bytes.buffer, bytes.byteOffset + nullPos + 1, 8);
+ const offset = view.getUint32(0);
+ const size = view.getUint32(4);
+
+ const entry: StarDictEntry = { word, offset, size };
+ this.idxCache.set(i, entry);
+ return entry;
+ }
+
+ /**
+ * Look up a headword. Returns `undefined` when absent.
+ *
+ * Lazy random-access binary search: log2(N) probes, each reading one
+ * entry's worth of bytes (~16) from the .idx Blob.
+ */
+ async lookup(word: string): Promise {
+ let lo = 0;
+ let hi = this.idxCount - 1;
+ while (lo <= hi) {
+ const mid = (lo + hi) >>> 1;
+ const entry = await this.decodeIdxEntry(mid);
+ const cmp = cmpAscii(word, entry.word);
+ if (cmp === 0) return entry;
+ if (cmp > 0) lo = mid + 1;
+ else hi = mid - 1;
+ }
+ return undefined;
+ }
+
+ private async ensureSynBuilt(): Promise {
+ if (this.synBuilt) return;
+ if (!this.synBlob) {
+ this.synBuilt = true;
+ return;
+ }
+ if (!this.synBuildPromise) {
+ this.synBuildPromise = (async () => {
+ const synBytes = new Uint8Array(await this.synBlob!.arrayBuffer());
+ this.synOffsets = scanEntryOffsets(synBytes, /* payloadBytes */ 4);
+ this.synCount = this.synOffsets.length;
+ this.synBuilt = true;
+ })();
+ }
+ await this.synBuildPromise;
+ }
+
+ private async decodeSynEntry(i: number): Promise<{ syn: string; idxIndex: number }> {
+ const cached = this.synCache.get(i);
+ if (cached) return cached;
+ if (!this.synBlob) throw new Error('syn blob not loaded');
+ const start = this.synOffsets[i]!;
+ const end = i + 1 < this.synCount ? this.synOffsets[i + 1]! : this.synBlob.size;
+ const bytes = new Uint8Array(await this.synBlob.slice(start, end).arrayBuffer());
+
+ let nullPos = 0;
+ while (nullPos < bytes.length && bytes[nullPos] !== 0) nullPos++;
+ const syn = decoder.decode(bytes.subarray(0, nullPos));
+ const view = new DataView(bytes.buffer, bytes.byteOffset + nullPos + 1, 4);
+ const idxIndex = view.getUint32(0);
+
+ const entry = { syn, idxIndex };
+ this.synCache.set(i, entry);
+ return entry;
+ }
+
+ /**
+ * Resolve a synonym to its underlying `.idx` entry. `undefined` when no
+ * `.syn` file is loaded or the synonym isn't present.
+ *
+ * On first call, scans the `.syn` blob to build its offset table.
+ */
+ async resolveSynonym(word: string): Promise {
+ await this.ensureSynBuilt();
+ if (!this.synCount) return undefined;
+
+ let lo = 0;
+ let hi = this.synCount - 1;
+ while (lo <= hi) {
+ const mid = (lo + hi) >>> 1;
+ const entry = await this.decodeSynEntry(mid);
+ const cmp = cmpAscii(word, entry.syn);
+ if (cmp === 0) return this.decodeIdxEntry(entry.idxIndex);
+ if (cmp > 0) lo = mid + 1;
+ else hi = mid - 1;
+ }
+ return undefined;
+ }
+}
diff --git a/apps/readest-app/src/services/dictionaries/types.ts b/apps/readest-app/src/services/dictionaries/types.ts
new file mode 100644
index 00000000..87b9f5ac
--- /dev/null
+++ b/apps/readest-app/src/services/dictionaries/types.ts
@@ -0,0 +1,110 @@
+/**
+ * Pluggable dictionary provider model.
+ *
+ * Built-in providers (Wiktionary, Wikipedia) and importable providers
+ * (StarDict, MDict) all implement {@link DictionaryProvider}. The
+ * {@link DictionaryPopup} renders one tab per enabled provider in user-defined
+ * order; each provider writes lookup output into a per-tab container.
+ */
+
+export type DictionaryProviderKind = 'builtin' | 'stardict' | 'mdict';
+
+export interface DictionaryLookupContext {
+ /** Source language hint, e.g. book primary language code (`en`, `zh`). */
+ lang?: string;
+ /** Cancel signal for in-flight network/IO. */
+ signal: AbortSignal;
+ /** Tab pane to render into. Provider populates this; never touch `document` selectors. */
+ container: HTMLElement;
+ /**
+ * Called when the provider intercepts an in-popup link click and wants the
+ * shell to navigate (push to per-tab history). Optional — providers without
+ * cross-link navigation can ignore it.
+ */
+ onNavigate?(word: string): void;
+}
+
+export type DictionaryLookupOutcome =
+ | { ok: true; headword?: string; sourceLabel?: string }
+ | { ok: false; reason: 'empty' | 'unsupported' | 'error'; message?: string };
+
+export interface DictionaryProvider {
+ /** Stable id, e.g. `builtin:wiktionary`, `stardict:abc123`, `mdict:xyz`. */
+ id: string;
+ kind: DictionaryProviderKind;
+ /** Localized label shown in the tab strip. */
+ label: string;
+ /** Optional eager init (parse index/keylist). Called once on first activation. */
+ init?(): Promise;
+ /** Look up a word; populate `ctx.container`; return outcome. */
+ lookup(word: string, ctx: DictionaryLookupContext): Promise;
+ /** Release object URLs / caches. Called when the provider is removed or replaced. */
+ dispose?(): void;
+}
+
+/**
+ * Persisted metadata for an imported dictionary. The binary files live on disk
+ * under {@link BaseDir} `'Dictionaries'`//; only this metadata syncs.
+ */
+export interface ImportedDictionary {
+ id: string;
+ kind: 'stardict' | 'mdict';
+ /** Display name, derived from `.ifo` `bookname` or `.mdx` header `Title`. */
+ name: string;
+ /** Subdirectory under `'Dictionaries'` containing this bundle's files. */
+ bundleDir: string;
+ /** Filenames inside `bundleDir`. The exact set varies by `kind`. */
+ files: {
+ ifo?: string;
+ idx?: string;
+ dict?: string;
+ syn?: string;
+ /**
+ * Pre-computed offsets sidecar for `.idx`. Generated at import time;
+ * lets `StarDictReader.load` skip the full `.idx` scan. Optional —
+ * existing imports without it fall back to the in-init scan path.
+ */
+ idxOffsets?: string;
+ /** Same idea for `.syn`. */
+ synOffsets?: string;
+ mdx?: string;
+ mdd?: string[];
+ };
+ /** Source language code if known. */
+ lang?: string;
+ /** Wall-clock time of import (ms since epoch). */
+ addedAt: number;
+ /** Soft-delete marker; `undefined` while available. */
+ deletedAt?: number;
+ /**
+ * True when metadata is present (e.g. synced from another device) but the
+ * binary bundle is missing on this device. The settings UI surfaces a
+ * "Re-import" affordance; the popup hides the provider.
+ */
+ unavailable?: boolean;
+ /**
+ * True when the bundle imports cleanly but the dictionary format is outside
+ * v1 scope (e.g. multi-type StarDict, raw `.dict` instead of `.dict.dz`,
+ * encrypted MDX). Provider returns `{ ok: false, reason: 'unsupported' }`.
+ */
+ unsupported?: boolean;
+ /** Human-readable reason when `unsupported` is true. */
+ unsupportedReason?: string;
+}
+
+export interface DictionarySettings {
+ /** Provider id order shown in the popup tab strip. Includes builtin ids. */
+ providerOrder: string[];
+ /** Per-id enable flag. Builtins seeded `true`. */
+ providerEnabled: Record;
+ /** Last-used tab id; `undefined` falls back to first enabled provider. */
+ defaultProviderId?: string;
+}
+
+/** Stable ids for the built-in providers. */
+export const BUILTIN_PROVIDER_IDS = {
+ wiktionary: 'builtin:wiktionary',
+ wikipedia: 'builtin:wikipedia',
+} as const;
+
+export type BuiltinProviderId = (typeof BUILTIN_PROVIDER_IDS)[keyof typeof BUILTIN_PROVIDER_IDS];
diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts
index bbc05dfa..d37cd540 100644
--- a/apps/readest-app/src/services/nativeAppService.ts
+++ b/apps/readest-app/src/services/nativeAppService.ts
@@ -47,6 +47,7 @@ import { SchemaType } from '@/services/database/migrate';
import {
DATA_SUBDIR,
LOCAL_BOOKS_SUBDIR,
+ LOCAL_DICTIONARIES_SUBDIR,
LOCAL_FONTS_SUBDIR,
LOCAL_IMAGES_SUBDIR,
SETTINGS_FILENAME,
@@ -92,7 +93,7 @@ const getPathResolver = ({
const getCustomBasePrefixSync = isCustomBaseDir
? (baseDir: BaseDir) => {
return () => {
- const dataDirs = ['Settings', 'Data', 'Books', 'Fonts', 'Images'];
+ const dataDirs = ['Settings', 'Data', 'Books', 'Fonts', 'Images', 'Dictionaries'];
const leafDir = dataDirs.includes(baseDir) ? '' : baseDir;
return leafDir ? `${customRootDir}/${leafDir}` : customRootDir!;
};
@@ -164,6 +165,15 @@ const getPathResolver = ({
: `${LOCAL_IMAGES_SUBDIR}${path ? `/${path}` : ''}`,
base,
};
+ case 'Dictionaries':
+ return {
+ baseDir: customBaseDir ?? BaseDirectory.AppData,
+ basePrefix: customBasePrefix || appDataDir,
+ fp: customBasePrefixSync
+ ? `${customBasePrefixSync()}/${LOCAL_DICTIONARIES_SUBDIR}${path ? `/${path}` : ''}`
+ : `${LOCAL_DICTIONARIES_SUBDIR}${path ? `/${path}` : ''}`,
+ base,
+ };
case 'None':
return {
baseDir: 0,
diff --git a/apps/readest-app/src/services/nodeAppService.ts b/apps/readest-app/src/services/nodeAppService.ts
index 71a6a690..48d73929 100644
--- a/apps/readest-app/src/services/nodeAppService.ts
+++ b/apps/readest-app/src/services/nodeAppService.ts
@@ -11,6 +11,7 @@ import { BaseAppService } from './appService';
import {
DATA_SUBDIR,
LOCAL_BOOKS_SUBDIR,
+ LOCAL_DICTIONARIES_SUBDIR,
LOCAL_FONTS_SUBDIR,
LOCAL_IMAGES_SUBDIR,
} from './constants';
@@ -99,7 +100,14 @@ const getPathResolver = ({ customRootDir }: { customRootDir?: string } = {}) =>
const isCustomBaseDir = Boolean(customRootDir);
const getCustomBasePrefix = isCustomBaseDir
? (base: BaseDir) => {
- const dataDirs: BaseDir[] = ['Settings', 'Data', 'Books', 'Fonts', 'Images'];
+ const dataDirs: BaseDir[] = [
+ 'Settings',
+ 'Data',
+ 'Books',
+ 'Fonts',
+ 'Images',
+ 'Dictionaries',
+ ];
const leafDir = dataDirs.includes(base) ? '' : base;
return leafDir ? `${customRootDir}/${leafDir}` : customRootDir!;
}
@@ -165,6 +173,15 @@ const getPathResolver = ({ customRootDir }: { customRootDir?: string } = {}) =>
: `${LOCAL_IMAGES_SUBDIR}${fp ? `/${fp}` : ''}`,
base,
};
+ case 'Dictionaries':
+ return {
+ baseDir: 0,
+ basePrefix: async () => custom ?? getAppDataDir(),
+ fp: custom
+ ? `${custom}/${LOCAL_DICTIONARIES_SUBDIR}${fp ? `/${fp}` : ''}`
+ : `${LOCAL_DICTIONARIES_SUBDIR}${fp ? `/${fp}` : ''}`,
+ base,
+ };
case 'None':
return {
baseDir: 0,
diff --git a/apps/readest-app/src/services/settingsService.ts b/apps/readest-app/src/services/settingsService.ts
index c3880513..99b3beeb 100644
--- a/apps/readest-app/src/services/settingsService.ts
+++ b/apps/readest-app/src/services/settingsService.ts
@@ -148,6 +148,14 @@ export async function loadSettings(ctx: Context): Promise {
settings.localBooksDir = await ctx.fs.getPrefix('Books');
+ // Coerce stale `'wikipedia'` quick-action to `'dictionary'`. The Wikipedia
+ // annotation tool was removed; Wikipedia is now reachable as a tab inside
+ // the unified dictionary popup. Without this guard, users who had set the
+ // quick action to wikipedia would get a no-op.
+ if ((settings.globalViewSettings.annotationQuickAction as string) === 'wikipedia') {
+ settings.globalViewSettings.annotationQuickAction = 'dictionary';
+ }
+
if (!settings.kosync.deviceId) {
settings.kosync.deviceId = uuidv4();
await saveSettings(ctx.fs, settings);
diff --git a/apps/readest-app/src/services/webAppService.ts b/apps/readest-app/src/services/webAppService.ts
index f5ec07e4..c482b023 100644
--- a/apps/readest-app/src/services/webAppService.ts
+++ b/apps/readest-app/src/services/webAppService.ts
@@ -9,6 +9,7 @@ import { BaseAppService } from './appService';
import {
DATA_SUBDIR,
LOCAL_BOOKS_SUBDIR,
+ LOCAL_DICTIONARIES_SUBDIR,
LOCAL_FONTS_SUBDIR,
LOCAL_IMAGES_SUBDIR,
} from './constants';
@@ -25,6 +26,8 @@ const resolvePath = (path: string, base: BaseDir): ResolvedPath => {
return { baseDir: 0, basePrefix, fp: `${LOCAL_FONTS_SUBDIR}/${path}`, base };
case 'Images':
return { baseDir: 0, basePrefix, fp: `${LOCAL_IMAGES_SUBDIR}/${path}`, base };
+ case 'Dictionaries':
+ return { baseDir: 0, basePrefix, fp: `${LOCAL_DICTIONARIES_SUBDIR}/${path}`, base };
case 'None':
return { baseDir: 0, basePrefix, fp: path, base };
default:
diff --git a/apps/readest-app/src/store/customDictionaryStore.ts b/apps/readest-app/src/store/customDictionaryStore.ts
new file mode 100644
index 00000000..6a2f8f34
--- /dev/null
+++ b/apps/readest-app/src/store/customDictionaryStore.ts
@@ -0,0 +1,169 @@
+import { create } from 'zustand';
+import { EnvConfigType } from '@/services/environment';
+import type { DictionarySettings, ImportedDictionary } from '@/services/dictionaries/types';
+import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types';
+import { useSettingsStore } from './settingsStore';
+
+const DEFAULT_DICTIONARY_SETTINGS: DictionarySettings = {
+ providerOrder: [BUILTIN_PROVIDER_IDS.wiktionary, BUILTIN_PROVIDER_IDS.wikipedia],
+ providerEnabled: {
+ [BUILTIN_PROVIDER_IDS.wiktionary]: true,
+ [BUILTIN_PROVIDER_IDS.wikipedia]: true,
+ },
+};
+
+interface DictionaryStoreState {
+ /** Imported (non-builtin) dictionaries. Soft-deleted entries are kept until next save. */
+ dictionaries: ImportedDictionary[];
+ settings: DictionarySettings;
+
+ /** Imported entries currently visible (not soft-deleted, sorted by addedAt desc). */
+ getAvailableDictionaries(): ImportedDictionary[];
+ getDictionary(id: string): ImportedDictionary | undefined;
+
+ /** Add (or revive) an imported dictionary. New entries are appended to providerOrder + enabled. */
+ addDictionary(dict: ImportedDictionary): void;
+ /** Soft-delete an imported entry by id; remove from providerOrder + providerEnabled. */
+ removeDictionary(id: string): boolean;
+ /** Replace a subset of provider ids in providerOrder; ignores unknown ids. */
+ reorder(ids: string[]): void;
+ /** Toggle a provider's enabled flag. Both builtin and imported ids are accepted. */
+ setEnabled(id: string, enabled: boolean): void;
+ /** Persist the last-used tab id so the popup re-opens on it. */
+ setDefaultProviderId(id: string | undefined): void;
+
+ /** Hydrate from `settings.customDictionaries` + `settings.dictionarySettings` + check on-disk availability. */
+ loadCustomDictionaries(envConfig: EnvConfigType): Promise;
+ /** Persist current state back into settings (which then syncs to cloud). */
+ saveCustomDictionaries(envConfig: EnvConfigType): Promise;
+}
+
+function toSettingsDict(dict: ImportedDictionary): ImportedDictionary {
+ // Strip transient fields before persisting. `unavailable` is recomputed at
+ // load time from the actual filesystem state, so don't write it.
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { unavailable: _u, ...rest } = dict;
+ return rest;
+}
+
+export const useCustomDictionaryStore = create((set, get) => ({
+ dictionaries: [],
+ settings: { ...DEFAULT_DICTIONARY_SETTINGS },
+
+ getAvailableDictionaries: () =>
+ get()
+ .dictionaries.filter((d) => !d.deletedAt)
+ .sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0)),
+
+ getDictionary: (id) => get().dictionaries.find((d) => d.id === id),
+
+ addDictionary: (dict) => {
+ set((state) => {
+ const existingIdx = state.dictionaries.findIndex((d) => d.id === dict.id);
+ const dictionaries =
+ existingIdx >= 0
+ ? state.dictionaries.map((d, i) =>
+ i === existingIdx ? { ...dict, deletedAt: undefined } : d,
+ )
+ : [...state.dictionaries, dict];
+ const order = state.settings.providerOrder.includes(dict.id)
+ ? state.settings.providerOrder
+ : [...state.settings.providerOrder, dict.id];
+ const enabled = { ...state.settings.providerEnabled };
+ if (!(dict.id in enabled)) enabled[dict.id] = !dict.unsupported;
+ return {
+ dictionaries,
+ settings: { ...state.settings, providerOrder: order, providerEnabled: enabled },
+ };
+ });
+ },
+
+ removeDictionary: (id) => {
+ const dict = get().dictionaries.find((d) => d.id === id);
+ if (!dict) return false;
+ set((state) => ({
+ dictionaries: state.dictionaries.map((d) =>
+ d.id === id ? { ...d, deletedAt: Date.now() } : d,
+ ),
+ settings: {
+ ...state.settings,
+ providerOrder: state.settings.providerOrder.filter((p) => p !== id),
+ providerEnabled: Object.fromEntries(
+ Object.entries(state.settings.providerEnabled).filter(([k]) => k !== id),
+ ),
+ },
+ }));
+ return true;
+ },
+
+ reorder: (ids) => {
+ set((state) => {
+ // Keep only ids that still exist; tail any known ids missing from the input.
+ const known = new Set(state.settings.providerOrder);
+ const filtered = ids.filter((id) => known.has(id));
+ const tail = state.settings.providerOrder.filter((id) => !filtered.includes(id));
+ return {
+ settings: { ...state.settings, providerOrder: [...filtered, ...tail] },
+ };
+ });
+ },
+
+ setEnabled: (id, enabled) => {
+ set((state) => ({
+ settings: {
+ ...state.settings,
+ providerEnabled: { ...state.settings.providerEnabled, [id]: enabled },
+ },
+ }));
+ },
+
+ setDefaultProviderId: (id) => {
+ set((state) => ({
+ settings: { ...state.settings, defaultProviderId: id },
+ }));
+ },
+
+ loadCustomDictionaries: async (envConfig) => {
+ try {
+ const { settings } = useSettingsStore.getState();
+ const persisted = settings?.customDictionaries ?? [];
+ const persistedSettings = settings?.dictionarySettings ?? DEFAULT_DICTIONARY_SETTINGS;
+ const appService = await envConfig.getAppService();
+ const dictionaries = await Promise.all(
+ persisted.map(async (dict) => {
+ if (dict.deletedAt) return dict;
+ const exists = await appService.exists(dict.bundleDir, 'Dictionaries');
+ return exists ? dict : { ...dict, unavailable: true };
+ }),
+ );
+ // Merge defaults to back-fill any missing keys (e.g. new builtin added in a release).
+ const settingsMerged: DictionarySettings = {
+ providerOrder: persistedSettings.providerOrder.length
+ ? persistedSettings.providerOrder
+ : DEFAULT_DICTIONARY_SETTINGS.providerOrder,
+ providerEnabled: {
+ ...DEFAULT_DICTIONARY_SETTINGS.providerEnabled,
+ ...persistedSettings.providerEnabled,
+ },
+ defaultProviderId: persistedSettings.defaultProviderId,
+ };
+ set({ dictionaries, settings: settingsMerged });
+ } catch (error) {
+ console.error('Failed to load custom dictionaries settings:', error);
+ }
+ },
+
+ saveCustomDictionaries: async (envConfig) => {
+ try {
+ const { settings, setSettings, saveSettings } = useSettingsStore.getState();
+ const { dictionaries, settings: dictSettings } = get();
+ settings.customDictionaries = dictionaries.map(toSettingsDict);
+ settings.dictionarySettings = dictSettings;
+ setSettings(settings);
+ saveSettings(envConfig, settings);
+ } catch (error) {
+ console.error('Failed to save custom dictionaries settings:', error);
+ throw error;
+ }
+ },
+}));
diff --git a/apps/readest-app/src/types/annotator.ts b/apps/readest-app/src/types/annotator.ts
index 911b68fe..da20a98a 100644
--- a/apps/readest-app/src/types/annotator.ts
+++ b/apps/readest-app/src/types/annotator.ts
@@ -4,7 +4,6 @@ export type AnnotationToolType =
| 'annotate'
| 'search'
| 'dictionary'
- | 'wikipedia'
| 'translate'
| 'tts'
| 'proofread';
diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts
index b649a332..3b878d49 100644
--- a/apps/readest-app/src/types/settings.ts
+++ b/apps/readest-app/src/types/settings.ts
@@ -5,6 +5,7 @@ import { HighlightColor, HighlightStyle, UserHighlightColor, ViewSettings } from
import { OPDSCatalog } from './opds';
import type { AISettings } from '@/services/ai/types';
import type { NotebookTab } from '@/store/notebookStore';
+import type { DictionarySettings, ImportedDictionary } from '@/services/dictionaries/types';
export type ThemeType = 'light' | 'dark' | 'auto';
export type LibraryViewModeType = 'grid' | 'list';
@@ -109,6 +110,8 @@ export interface SystemSettings {
libraryColumns: number;
customFonts: CustomFont[];
customTextures: CustomTexture[];
+ customDictionaries: ImportedDictionary[];
+ dictionarySettings: DictionarySettings;
opdsCatalogs: OPDSCatalog[];
metadataSeriesCollapsed: boolean;
metadataOthersCollapsed: boolean;
diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts
index 0c35760d..34b66804 100644
--- a/apps/readest-app/src/types/system.ts
+++ b/apps/readest-app/src/types/system.ts
@@ -7,11 +7,14 @@ import { CustomFont, CustomFontInfo } from '@/styles/fonts';
import { CustomTextureInfo } from '@/styles/textures';
import { DatabaseOpts, DatabaseService } from './database';
import { SchemaType } from '@/services/database/migrate';
+import type { ImportedDictionary } from '@/services/dictionaries/types';
+import type { ImportDictionariesResult } from '@/services/dictionaries/dictionaryService';
+import type { SelectedFile } from '@/hooks/useFileSelector';
export type AppPlatform = 'web' | 'tauri' | 'node';
export type OsPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown';
// prettier-ignore
-export type BaseDir = | 'Books' | 'Settings' | 'Data' | 'Fonts' | 'Images' | 'Log' | 'Cache' | 'Temp' | 'None';
+export type BaseDir = | 'Books' | 'Settings' | 'Data' | 'Fonts' | 'Images' | 'Dictionaries' | 'Log' | 'Cache' | 'Temp' | 'None';
export type DeleteAction = 'cloud' | 'local' | 'both';
export type SelectDirectoryMode = 'read' | 'write';
export type DistChannel = 'readest' | 'playstore' | 'appstore' | 'unknown';
@@ -127,6 +130,8 @@ export interface AppService {
deleteFont(font: CustomFont): Promise;
importImage(file?: string | File): Promise;
deleteImage(texture: CustomTextureInfo): Promise;
+ importDictionaries(files: SelectedFile[]): Promise;
+ deleteDictionary(dict: ImportedDictionary): Promise;
importBook(file: string | File, books: Book[], options?: ImportBookOptions): Promise;
refreshBookMetadata(book: Book): Promise;
deleteBook(book: Book, deleteAction: DeleteAction): Promise;
diff --git a/apps/readest-app/tsconfig.json b/apps/readest-app/tsconfig.json
index ee86145e..6ba54009 100644
--- a/apps/readest-app/tsconfig.json
+++ b/apps/readest-app/tsconfig.json
@@ -25,6 +25,8 @@
"@/components/ui/*": ["./src/components/primitives/*"],
"@pdfjs/*": ["./public/vendor/pdfjs/*"],
"@simplecc/*": ["./public/vendor/simplecc/*"],
+ "js-mdict": ["../../packages/js-mdict/src/index.ts"],
+ "fflate": ["./node_modules/fflate"],
"tauri-plugin-turso": ["./src-tauri/plugins/tauri-plugin-turso/guest-js"]
}
},
diff --git a/apps/readest-app/vitest.config.mts b/apps/readest-app/vitest.config.mts
index 7fbaa3a1..c9621aae 100644
--- a/apps/readest-app/vitest.config.mts
+++ b/apps/readest-app/vitest.config.mts
@@ -11,6 +11,13 @@ export default defineConfig({
// source files. foliate-js/pdf.js lives outside that scope, so Vite
// needs an explicit alias to find the vendored pdfjs build.
'@pdfjs': path.resolve(__dirname, 'public/vendor/pdfjs'),
+ // `js-mdict` is consumed via tsconfig paths from `packages/js-mdict/src/`.
+ // Its sources `import 'fflate'` directly — without an alias, vite's
+ // import-analysis walks up from the redirected file location and fails
+ // to find fflate (it's installed only in this app's node_modules).
+ // Pin all `fflate` resolutions to the app's copy to keep js-mdict
+ // self-contained at the source-tree level.
+ fflate: path.resolve(__dirname, 'node_modules/fflate'),
},
},
test: {
diff --git a/packages/js-mdict b/packages/js-mdict
new file mode 160000
index 00000000..3355568e
--- /dev/null
+++ b/packages/js-mdict
@@ -0,0 +1 @@
+Subproject commit 3355568e4439e3118701787384fe20ccbf420bba
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 02c1044a..eb14e937 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -87,6 +87,15 @@ importers:
'@choochmeque/tauri-plugin-sharekit-api':
specifier: ^0.3.0
version: 0.3.0
+ '@dnd-kit/core':
+ specifier: ^6.3.1
+ version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@dnd-kit/sortable':
+ specifier: ^10.0.0
+ version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2(react@19.2.5)
'@emnapi/core':
specifier: ^1.7.1
version: 1.8.1
@@ -243,6 +252,9 @@ importers:
dompurify:
specifier: '>=3.4.0'
version: 3.4.1
+ fflate:
+ specifier: ^0.8.2
+ version: 0.8.2
foliate-js:
specifier: workspace:*
version: link:../../packages/foliate-js
@@ -1243,6 +1255,28 @@ packages:
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
+ '@dnd-kit/accessibility@3.1.1':
+ resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@dnd-kit/core@6.3.1':
+ resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@dnd-kit/sortable@10.0.0':
+ resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/utilities@3.2.2':
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
'@dotenvx/dotenvx@1.31.0':
resolution: {integrity: sha512-GeDxvtjiRuoyWVU9nQneId879zIyNdL05bS7RKiqMkfBSKpHMWHLoRyRqjYWLaXmX/llKO1hTlqHDmatkQAjPA==}
hasBin: true
@@ -10022,6 +10056,31 @@ snapshots:
'@discoveryjs/json-ext@0.5.7': {}
+ '@dnd-kit/accessibility@3.1.1(react@19.2.5)':
+ dependencies:
+ react: 19.2.5
+ tslib: 2.8.1
+
+ '@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1(react@19.2.5)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ tslib: 2.8.1
+
+ '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.5)
+ react: 19.2.5
+ tslib: 2.8.1
+
+ '@dnd-kit/utilities@3.2.2(react@19.2.5)':
+ dependencies:
+ react: 19.2.5
+ tslib: 2.8.1
+
'@dotenvx/dotenvx@1.31.0':
dependencies:
commander: 11.1.0
@@ -10478,7 +10537,7 @@ snapshots:
'@jest/pattern@30.0.1':
dependencies:
- '@types/node': 22.19.7
+ '@types/node': 22.19.17
jest-regex-util: 30.0.1
'@jest/schemas@30.0.5':
@@ -10491,7 +10550,7 @@ snapshots:
'@jest/schemas': 30.0.5
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
- '@types/node': 22.19.7
+ '@types/node': 22.19.17
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -12539,7 +12598,7 @@ snapshots:
'@types/yauzl@2.10.3':
dependencies:
- '@types/node': 22.19.7
+ '@types/node': 22.19.17
optional: true
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260312.1':
@@ -15149,7 +15208,7 @@ snapshots:
jest-mock@30.2.0:
dependencies:
'@jest/types': 30.2.0
- '@types/node': 22.19.7
+ '@types/node': 22.19.17
jest-util: 30.2.0
jest-regex-util@30.0.1: {}
@@ -15157,7 +15216,7 @@ snapshots:
jest-util@30.2.0:
dependencies:
'@jest/types': 30.2.0
- '@types/node': 22.19.7
+ '@types/node': 22.19.17
chalk: 4.1.2
ci-info: 4.4.0
graceful-fs: 4.2.11
@@ -16538,7 +16597,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
- '@types/node': 22.19.7
+ '@types/node': 22.19.17
long: 5.3.2
proxy-addr@2.0.7: