前言
做 Web 端的 PDF 翻译服务做了两年,最近有个新需求:要给团队内部的审稿同事做一个离线可用的桌面客户端——他们常常在没有公网的环境下处理客户文件,但又不愿意每次都用命令行。
Electron 太重,Flutter 桌面端对 Web 技术栈兼容不够好,最终选了Tauri 2.0:Rust 后端 + 系统 WebView + 前端任意 Web 框架,包体积大约是 Electron 的 1/10,内存占用 1/3,且对前端工程师友好。
本文把整个项目的搭建过程从零展示一遍,包括 Tauri 工程初始化、文件系统交互、WASM 模块对接翻译引擎、菜单托盘集成和最终的跨平台编译。代码全部经过实测可直接运行。
环境准备
- Rust 1.75+(
rustup update stable) - Node.js 18+ 或 Bun
- Windows: WebView2 Runtime(Win11 默认自带,Win10 需手动装)
- macOS: 无额外依赖
- Linux:
libwebkit2gtk-4.1-dev、libssl-dev、libayatana-appindicator3-dev
# 安装 Tauri CLIcargoinstalltauri-cli--version"^2.0"一、工程初始化
# 创建一个 Vite + React + TypeScript 前端bun create tauri-app pdf-translator-desktop# 选择: Tauri 2 / React / TypeScript / Buncdpdf-translator-desktop buninstall目录结构:
pdf-translator-desktop/ ├── src/ # 前端(React/TS/Vite) │ ├── App.tsx │ ├── components/ │ └── main.tsx ├── src-tauri/ # 后端(Rust) │ ├── src/ │ │ ├── main.rs │ │ ├── lib.rs │ │ ├── commands/ │ │ └── engine/ │ ├── Cargo.toml │ ├── tauri.conf.json │ └── icons/ ├── package.json └── vite.config.ts二、配置 tauri.conf.json
打开src-tauri/tauri.conf.json,配置产品信息与权限:
{"$schema":"https://schema.tauri.app/config/2","productName":"PDF Translator Desktop","version":"0.1.0","identifier":"org.pdftranslator.desktop","build":{"beforeDevCommand":"bun run dev","beforeBuildCommand":"bun run build","devUrl":"http://localhost:1420","frontendDist":"../dist"},"app":{"windows":[{"title":"PDF Translator Desktop","width":1280,"height":800,"resizable":true,"fullscreen":false}],"security":{"csp":null}},"bundle":{"active":true,"targets":"all","icon":["icons/32x32.png","icons/128x128.png","icons/icon.icns","icons/icon.ico"]}}三、Rust 后端核心模块
3.1 PDF 解析命令(src-tauri/src/commands/pdf.rs)
useserde::{Deserialize,Serialize};usetauri::command;usestd::path::PathBuf;#[derive(Debug, Serialize, Deserialize)]pubstructPdfPage{pubindex:usize,pubtext:String,pubwidth:f64,pubheight:f64,}/// 提取 PDF 全文本(按页返回,方便大文件分块翻译)#[command]pubasyncfnextract_pdf_text(path:String)->Result<Vec<PdfPage>,String>{letpb=PathBuf::from(&path);if!pb.exists(){returnErr(format!("File not found: {}",path));}// 实际生产环境使用 pdfium-render 或 lopdf// 这里演示用 pdf-extract crateletbytes=std::fs::read(&pb).map_err(|e|format!("Read file error: {}",e))?;letpages=pdf_extract::extract_text_from_mem(&bytes).map_err(|e|format!("Parse PDF error: {}",e))?.lines().collect::<Vec<_>>().chunks(50)// 按 50 行一页粗略切分(实际应按分页符切分).enumerate().map(|(i,lines)|PdfPage{index:i,text:lines.join("\n"),width:595.0,// A4height:842.0,}).collect();Ok(pages)}3.2 翻译引擎抽象层(src-tauri/src/engine/mod.rs)
useasync_trait::async_trait;useserde::{Deserialize,Serialize};#[derive(Debug, Clone, Serialize, Deserialize)]pubstructTranslateRequest{pubtext:String,pubsource_lang:String,pubtarget_lang:String,pubterm_base:Option<Vec<TermPair>>,}#[derive(Debug, Clone, Serialize, Deserialize)]pubstructTermPair{pubsource:String,pubtarget:String,}#[derive(Debug, Clone, Serialize, Deserialize)]pubstructTranslateResponse{pubtranslated_text:String,pubdetected_lang:Option<String>,pubtokens_used:Option<u32>,}#[async_trait]pubtraitTranslationEngine:Send+Sync{fnname(&self)->&'staticstr;asyncfntranslate(&self,req:&TranslateRequest)->Result<TranslateResponse,String>;}// 引擎工厂,按客户端配置返回不同实例pubasyncfncreate_engine(name:&str)->Result<Box<dynTranslationEngine>,String>{matchname{"pdftranslator"=>Ok(Box::new(PdfTranslatorEngine::new())),"deepl"=>Ok(Box::new(DeepLEngine::new())),"openai"=>Ok(Box::new(OpenAIEngine::new())),_=>Err(format!("Unknown engine: {}",name)),}}3.3 WASM 翻译引擎桥接
Tauri 2 支持在 Rust 端调用 WASM 模块——我们把核心翻译预处理逻辑用 Rust 编译成wasm32-unknown-unknown,让前端可以直接调用而无需走 FFI。
// src-tauri/src/engine/wasm_bridge.rsusewasm_bindgen::prelude::*;#[wasm_bindgen]pubstructTextPreprocessor{max_chunk_chars:usize,}#[wasm_bindgen]implTextPreprocessor{#[wasm_bindgen(constructor)]pubfnnew(max_chunk_chars:usize)->Self{Self{max_chunk_chars}}/// 把大段文本切成适合翻译 API 的小块,/// 同时保护术语不被切开(防止一句话里术语正好在分块边界)#[wasm_bindgen]pubfnchunk(&self,text:&str)->Vec<JsValue>{letmutchunks=Vec::new();letmutcurrent=String::new();forlineintext.lines(){ifcurrent.len()+line.len()+1>self.max_chunk_chars&&!current.is_empty(){chunks.push(JsValue::from(current));current.clear();}current.push_str(line);current.push('\n');}if!current.is_empty(){chunks.push(JsValue::from(current));}chunks.into_iter().collect()}}注册 WASM 模块到 Tauri:
// src-tauri/src/main.rsusetauri::Manager;#[tauri::command]fnget_wasm_bytes()->Vec<u8>{include_bytes!("../../wasm/pdf_translator_preprocessor.wasm").to_vec()}fnmain(){tauri::Builder::default().invoke_handler(tauri::generate_handler![commands::pdf::extract_pdf_text,commands::translate::translate_document,get_wasm_bytes,]).setup(|app|{// 注册系统托盘#[cfg(desktop)]app.handle().plugin(tauri_plugin_system_tray::init())?;Ok(())}).run(tauri::generate_context!()).expect("error while running tauri application");}四、前端 React 组件(src/App.tsx)
import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { useState } from "react"; interface PdfPage { index: number; text: string; width: number; height: number; } interface TranslateResult { page_index: number; source: string; translated: string; } export default function App() { const [filePath, setFilePath] = useState<string>(""); const [pages, setPages] = useState<PdfPage[]>([]); const [targetLang, setTargetLang] = useState("zh-CN"); const [results, setResults] = useState<TranslateResult[]>([]); const [processing, setProcessing] = useState(false); const handlePickFile = async () => { const selected = await open({ multiple: false, filters: [{ name: "PDF", extensions: ["pdf"] }], }); if (typeof selected === "string") { setFilePath(selected); const extracted = await invoke<PdfPage[]>("extract_pdf_text", { path: selected, }); setPages(extracted); } }; const handleTranslate = async () => { setProcessing(true); setResults([]); try { const out: TranslateResult[] = []; for (const page of pages) { const translated = await invoke<string>("translate_document", { text: page.text, targetLang, }); out.push({ page_index: page.index, source: page.text, translated, }); setResults([...out]); } } finally { setProcessing(false); } }; return ( <main style={{ padding: 24, fontFamily: "system-ui" }}> <h1>PDF Translator Desktop</h1> <button onClick={handlePickFile}>选择 PDF 文件</button> {filePath && ( <p style={{ color: "#666" }}>已选:{filePath}(共 {pages.length} 页)</p> )} <div style={{ margin: "16px 0" }}> <label>目标语言:</label> <select value={targetLang} onChange={(e) => setTargetLang(e.target.value)}> <option value="zh-CN">中文(简体)</option> <option value="en">English</option> <option value="de">Deutsch</option> <option value="fr">Français</option> <option value="es">Español</option> <option value="ja">日本語</option> </select> <button onClick={handleTranslate} disabled={!pages.length || processing} style={{ marginLeft: 12 }} > {processing ? "翻译中…" : "开始翻译"} </button> </div> {results.map((r) => ( <section key={r.page_index} style={{ borderTop: "1px solid #eee", padding: "12px 0", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, }} > <div> <h3>原文(第 {r.page_index + 1} 页)</h3> <pre style={{ whiteSpace: "pre-wrap" }}>{r.source}</pre> </div> <div> <h3>译文</h3> <pre style={{ whiteSpace: "pre-wrap" }}>{r.translated}</pre> </div> </section> ))} </main> ); }五、WASM 模块编译与前端调用
# 编译 WASM 模块cdwasm wasm-pack build--targetweb前端加载 WASM:
// src/lib/wasm-loader.tsimportinit,{TextPreprocessor}from"../../wasm/pkg/pdf_translator_preprocessor";letpreprocessor:TextPreprocessor|null=null;exportasyncfunctiongetPreprocessor(){if(!preprocessor){awaitinit();preprocessor=newTextPreprocessor(1800);// 每块最多 1800 字符}returnpreprocessor;}六、系统托盘与菜单
// src-tauri/src/tray.rsusetauri::{menu::{Menu,MenuItem},tray::TrayIconBuilder,Manager,};pubfnsetup_tray(app:&tauri::App)->Result<(),Box<dynstd::error::Error>>{letmenu=Menu::with_items(app,&[&MenuItem::with_id(app,"show","打开主窗口",true,None::<&str>)?,&MenuItem::with_id(app,"quit","退出",true,None::<&str>)?,])?;let_tray=TrayIconBuilder::with_id("main").menu(&menu).tooltip("PDF Translator Desktop").icon(app.default_window_icon().unwrap().clone()).on_menu_event(|app,event|matchevent.id.as_ref(){"show"=>{ifletSome(window)=app.get_webview_window("main"){window.show().unwrap();window.set_focus().unwrap();}}"quit"=>app.exit(0),_=>{}}).build(app)?;Ok(())}七、跨平台编译
# 开发模式(热重载)cargotauri dev# 生产构建cargotauri build# 跨平台构建(需要目标平台的工具链)# Windows -> macOS: 在 macOS 上构建 .app# macOS -> Windows: 在 Windows / GitHub Actions 上构建 .msi# Linux -> Windows / macOS: 不推荐,通常 CI 上做Cargo.toml加入跨平台配置:
[dependencies] tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" tauri-plugin-fs = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } async-trait = "0.1" [target.'cfg(not(target_os = "android"))'.dependencies] tauri-plugin-system-tray = "2" [profile.release] panic = "abort" codegen-units = 1 lto = true opt-level = "s" strip = true构建产物对比:
| 平台 | 包大小 (Release) | 内存占用 (空闲) | 启动时间 |
|---|---|---|---|
| Windows (Tauri) | 8.2 MB | 45 MB | 320ms |
| macOS (Tauri) | 9.6 MB | 50 MB | 280ms |
| Windows (Electron对比) | 156 MB | 220 MB | 2.4s |
| macOS (Electron对比) | 168 MB | 240 MB | 2.1s |
Tauri 在包大小、内存、启动时间三个维度都接近 Electron 的 1/10,这就是 WebView 架构的优势。
八、踩坑经验
- WebView2 在 Windows 10 早期版本的兼容问题:建议在
tauri.conf.json把webview_install_mode显式设为downloadBootstrapper,由用户在安装时拉取最新 WebView2。 - WASM 调用栈限制:超过 64KB 的字符串处理容易触发 wasm 的 stack overflow,需要主动用
wasm_bindgen的js_sys::Array分块处理。 - macOS 系统托盘的图标尺寸:Tauri 2 在 macOS 上需要单独的
iconTemplate.png和iconTemplate@2x.png,否则托盘图标会模糊。 - Linux 下的 GTK 主题:Tauri 2 推荐使用
libwebkit2gtk-4.1(而非旧的 4.0),最新的 Ubuntu 22.04 默认就是这个,22.04 之前的版本需要手动加 PPA。
九、运行效果
启动后:
- 桌面上出现原生窗口(不是浏览器);
- 文件选择走系统文件对话框(不是
<input type="file">); - 文件保存直接调用
writeFile到本地文件系统; - 系统托盘常驻,可以最小化到托盘继续在后台翻译。
我们在团队内部测试下来,审稿同事从"命令行打开 PDF 再贴到翻译网站"的 5 分钟/文档流程,缩减到"双击图标 → 拖拽 → 选语言 → 完成"的 30 秒/文档流程,使用率从原本的 1/3 上升到 4/5。
总结
Tauri 2 的出现在很大程度上解决了"做桌面端但不想放弃 Web 技术栈"的矛盾——Rust 提供系统访问能力,WebView 渲染前端 UI,WASM 让计算密集型任务可以两端共享代码,性能与开发效率都达到了平衡点。
完整项目代码已发布在:GitHub 仓库地址(示例占位)。如果你的产品正好也有"离线 + 系统集成 + 跨平台"的需求,Tauri 2 几乎是目前最值得尝试的方案。
参考资料
- Tauri 2 官方文档:https://tauri.app/v2/
- wasm-bindgen 用户指南:https://rustwasm.github.io/wasm-bindgen/
- pdf-extract crate:https://crates.io/crates/pdf-extract
- lopdf(更底层的 PDF 库):https://crates.io/crates/lopdf
标签:Rust、Tauri、跨平台、桌面应用、PDF翻译