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"
}