diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index e825816..483c86b 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -179,6 +179,10 @@ jobs: ls -la textlingo-desktop/src-tauri/binaries shell: bash + - name: stage PDF offline assets + run: python script/build_pdf_offline_assets.py + shell: bash + - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 559fa02..2e6b8bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -176,6 +176,10 @@ jobs: ls -la textlingo-desktop/src-tauri/binaries shell: bash + - name: stage PDF offline assets + run: python script/build_pdf_offline_assets.py + shell: bash + - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/script/build_pdf_offline_assets.py b/script/build_pdf_offline_assets.py new file mode 100644 index 0000000..90a1150 --- /dev/null +++ b/script/build_pdf_offline_assets.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Stage a subset of babeldoc offline assets into the Tauri resources dir. + +The desktop app ships the DocLayout layout model plus the CJK serif fonts so the +first PDF translation runs without a network download (which is slow/flaky and +made the app look frozen). We deliberately bundle only what the OpenKoto +translation path actually uses — the layout model (always) and one serif font +per CJK target language plus the GoNoto fallback — instead of babeldoc's full +~243 MB font set. + +Run this AFTER `pip install -e ./textlingo-desktop/pdf-sidecar` (which installs +the pinned babeldoc), and BEFORE the Tauri bundle step. +""" + +import shutil +import sys +from pathlib import Path + +from babeldoc.assets.assets import ( + get_doclayout_onnx_model_path, + get_font_and_metadata, +) + +# Serif fonts matching high_level.download_remote_fonts() for zh-CN / zh-TW / +# ja / ko, plus the GoNoto fallback used for every other language. +FONTS = [ + "GoNotoKurrent-Regular.ttf", + "SourceHanSerifCN-Regular.ttf", + "SourceHanSerifTW-Regular.ttf", + "SourceHanSerifJP-Regular.ttf", + "SourceHanSerifKR-Regular.ttf", +] + + +def main() -> int: + repo_root = Path(__file__).resolve().parent.parent + stage = repo_root / "textlingo-desktop" / "src-tauri" / "resources" / "pdf-assets" + (stage / "models").mkdir(parents=True, exist_ok=True) + (stage / "fonts").mkdir(parents=True, exist_ok=True) + + model = Path(get_doclayout_onnx_model_path()) + shutil.copy2(model, stage / "models" / model.name) + print(f"staged model: {model.name}") + + for font in FONTS: + path, _ = get_font_and_metadata(font) + shutil.copy2(Path(path), stage / "fonts" / font) + print(f"staged font: {font}") + + total = sum(p.stat().st_size for p in stage.rglob("*") if p.is_file()) + print(f"offline assets staged at {stage} ({total / 1024 / 1024:.1f} MB)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/textlingo-desktop/package.json b/textlingo-desktop/package.json index 86b647c..912a9af 100644 --- a/textlingo-desktop/package.json +++ b/textlingo-desktop/package.json @@ -1,7 +1,7 @@ { "name": "openkoto-desktop", "private": true, - "version": "0.6.12", + "version": "0.6.13", "type": "module", "scripts": { "dev": "vite", diff --git a/textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/pdf2zh.py b/textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/pdf2zh.py index dd9c4ab..2130034 100644 --- a/textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/pdf2zh.py +++ b/textlingo-desktop/pdf-sidecar/openkoto_pdf_translator/pdf2zh.py @@ -58,6 +58,43 @@ def _emit_openkoto_progress(progress) -> None: pass +def _seed_offline_assets() -> None: + """Copy bundled DocLayout model / fonts into babeldoc's cache so the first + translation does not need to download them. + + The desktop app sets OPENKOTO_OFFLINE_ASSETS_DIR to a resource folder laid + out as ``/models/*.onnx`` and ``/fonts/*.ttf``. Best-effort: any + failure just falls back to babeldoc's normal on-demand download. + """ + src_dir = os.environ.get("OPENKOTO_OFFLINE_ASSETS_DIR") + if not src_dir or not os.path.isdir(src_dir): + return + try: + import shutil + from babeldoc.const import get_cache_file_path + + for sub_folder in ("models", "fonts"): + type_dir = os.path.join(src_dir, sub_folder) + if not os.path.isdir(type_dir): + continue + for name in os.listdir(type_dir): + src = os.path.join(type_dir, name) + if not os.path.isfile(src): + continue + # get_cache_file_path creates the destination sub-folder for us. + dst = str(get_cache_file_path(name, sub_folder)) + if os.path.exists(dst): + continue + shutil.copy2(src, dst) + print( + f"[PDF] seeded offline asset: {sub_folder}/{name}", + file=sys.stderr, + flush=True, + ) + except Exception as e: + print(f"[PDF] offline asset seeding skipped: {e}", file=sys.stderr, flush=True) + + def create_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__, add_help=True) parser.add_argument( @@ -299,6 +336,10 @@ def main(args: Optional[List[str]] = None) -> int: if parsed_args.debug: log.setLevel(logging.DEBUG) + # Restore bundled offline assets (if shipped) before any model/font load so + # the first translation does not block on a network download. + _seed_offline_assets() + if parsed_args.onnx: ModelInstance.value = OnnxModel(parsed_args.onnx) else: diff --git a/textlingo-desktop/src-tauri/.gitignore b/textlingo-desktop/src-tauri/.gitignore index b21bd68..4d47170 100644 --- a/textlingo-desktop/src-tauri/.gitignore +++ b/textlingo-desktop/src-tauri/.gitignore @@ -5,3 +5,8 @@ # Generated by Tauri # will have schema files for capabilities auto-completion /gen/schemas + +# PDF offline assets are generated in CI (script/build_pdf_offline_assets.py), +# not committed (~95 MB). Keep the folder + README, ignore the binaries. +/resources/pdf-assets/models/ +/resources/pdf-assets/fonts/ diff --git a/textlingo-desktop/src-tauri/Cargo.lock b/textlingo-desktop/src-tauri/Cargo.lock index a2b0fbc..99915c1 100644 --- a/textlingo-desktop/src-tauri/Cargo.lock +++ b/textlingo-desktop/src-tauri/Cargo.lock @@ -2859,7 +2859,7 @@ dependencies = [ [[package]] name = "openkoto-desktop" -version = "0.6.12" +version = "0.6.13" dependencies = [ "base64 0.22.1", "chrono", diff --git a/textlingo-desktop/src-tauri/Cargo.toml b/textlingo-desktop/src-tauri/Cargo.toml index 67c930a..5765aa1 100644 --- a/textlingo-desktop/src-tauri/Cargo.toml +++ b/textlingo-desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openkoto-desktop" -version = "0.6.12" +version = "0.6.13" description = "OpenKoto Desktop - AI-powered Article Reader" authors = ["OpenKoto Team"] edition = "2021" diff --git a/textlingo-desktop/src-tauri/resources/pdf-assets/README.md b/textlingo-desktop/src-tauri/resources/pdf-assets/README.md new file mode 100644 index 0000000..01dd113 --- /dev/null +++ b/textlingo-desktop/src-tauri/resources/pdf-assets/README.md @@ -0,0 +1,20 @@ +# PDF offline assets + +This folder ships the babeldoc assets the PDF translator needs so the **first** +translation does not have to download them (DocLayout layout model + CJK serif +fonts). It exists so packaged builds can run PDF translation offline. + +The actual `models/*.onnx` and `fonts/*.ttf` files are **generated in CI**, not +committed (they are ~95 MB). They are produced by: + +``` +python script/build_pdf_offline_assets.py +``` + +which must run after `pip install -e ./textlingo-desktop/pdf-sidecar` and before +the Tauri bundle step. The Rust side points the sidecar at this folder via the +`OPENKOTO_OFFLINE_ASSETS_DIR` env var; the sidecar copies the files into +babeldoc's cache on first run (see `_seed_offline_assets` in `pdf2zh.py`). + +In local dev builds where these files are absent, the sidecar simply falls back +to babeldoc's normal on-demand download. diff --git a/textlingo-desktop/src-tauri/src/commands.rs b/textlingo-desktop/src-tauri/src/commands.rs index 2574dd4..1346de0 100644 --- a/textlingo-desktop/src-tauri/src/commands.rs +++ b/textlingo-desktop/src-tauri/src/commands.rs @@ -3023,6 +3023,22 @@ pub async fn translate_pdf_document( envs.push(("OPENKOTO_BASE_URL", url)); } + // Point the sidecar at bundled offline assets (DocLayout model + CJK fonts) + // so the first translation doesn't block on a network download. Absent in + // dev builds, in which case the sidecar falls back to on-demand download. + if let Ok(resource_dir) = app_handle.path().resource_dir() { + for candidate in ["resources/pdf-assets", "pdf-assets"] { + let dir = resource_dir.join(candidate); + if dir.join("models").is_dir() || dir.join("fonts").is_dir() { + envs.push(( + "OPENKOTO_OFFLINE_ASSETS_DIR", + dir.to_string_lossy().to_string(), + )); + break; + } + } + } + let sidecar = pdf_sidecar::resolve_pdf_sidecar(&app_handle) .map_err(|e| format!("PDF sidecar error: {e}"))?; let cmd = sidecar.program; diff --git a/textlingo-desktop/src-tauri/tauri.conf.json b/textlingo-desktop/src-tauri/tauri.conf.json index e9fed50..14ad58f 100644 --- a/textlingo-desktop/src-tauri/tauri.conf.json +++ b/textlingo-desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenKoto Desktop", - "version": "0.6.12", + "version": "0.6.13", "identifier": "com.openkoto.desktop", "build": { "beforeDevCommand": "npm run dev", @@ -51,6 +51,9 @@ "binaries/ffmpeg", "binaries/openkoto-pdf-translator" ], + "resources": [ + "resources/pdf-assets" + ], "macOS": { "entitlements": "entitlements.plist" }