将一款已经能用的桌面软件从 PyQt6 v1.1.1 重构到 Electron v2.0.x,难点并不在于把 Python 界面逐页翻译成 HTML。真正需要重新设计的是渲染、文件格式、OCR 生命周期和发布链路。以英语衡水体字帖生成器为例,它既要保留包括描红在内的多种生成模式,又要同时面向 Windows、macOS 和 Linux,因此迁移更像一次产品架构升级,而不是简单换壳。
不要逐个控件翻译,而要重新划分边界
PyQt6 应用通常把窗口、业务状态、文件读写和绘制逻辑放在同一个 Python 进程中。Electron 则天然分成主进程、预加载脚本和渲染进程。如果仍然让页面直接访问文件系统、随意调用 Node.js API,应用虽然能跑,却会失去清晰的安全边界。
可以把字帖工具拆成三层:
- 主进程:窗口创建、打开与保存对话框、打印、应用生命周期。
- 预加载脚本:通过有限的 IPC 接口暴露文件导入、导出等能力。
- 渲染进程:表单、模式切换、Canvas 预览、OCR 结果编辑。
创建窗口时应默认开启上下文隔离,避免为了开发方便而启用完整的 Node.js 能力:
// src/main/index.js
import { app, BrowserWindow } from 'electron'
import { join } from 'node:path'
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 820,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false
}
})
if (process.env.ELECTRON_RENDERER_URL) {
win.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
win.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(createWindow)
目录名称需要根据所用 electron-vite 模板调整。关键不在路径本身,而是不要把 fs、shell 或任意命令执行能力直接挂到页面上。文件保存可以设计成 saveWorkbook(data) 这样的窄接口,并在主进程中校验路径与数据结构。
Canvas 不是画布控件的平替
PyQt 的绘图坐标、字体度量和打印体系与浏览器并不相同。迁移到 Canvas 2D 后,最常见的问题包括预览发虚、导出尺寸变化、字体尚未加载就开始测量,以及高 DPI 屏幕上的线条错位。
一个稳妥的做法是把“纸张逻辑尺寸”和“设备像素尺寸”分开。下面的模块可以直接放进渲染进程,再根据具体字帖规则调整行距、基线和字体:
// src/renderer/src/worksheet.js
export async function renderWorksheet(canvas, options = {}) {
const {
text = 'Practice makes progress.',
fontFamily = 'Hengshui, Arial, sans-serif',
fontSize = 42,
rowHeight = 120
} = options
await document.fonts.load(`${fontSize}px ${fontFamily}`)
// 逻辑画布接近 A4 比例;正式导出时可改为目标 DPI 对应的尺寸。
const width = 1240
const height = 1754
const ratio = window.devicePixelRatio || 1
canvas.width = Math.round(width * ratio)
canvas.height = Math.round(height * ratio)
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const ctx = canvas.getContext('2d')
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
ctx.font = `${fontSize}px ${fontFamily}`
ctx.textBaseline = 'alphabetic'
for (let y = 120; y < height - 80; y += rowHeight) {
ctx.strokeStyle = '#d8d8d8'
ctx.lineWidth = 1
for (let offset = -42; offset <= 42; offset += 28) {
ctx.beginPath()
ctx.moveTo(70, y + offset)
ctx.lineTo(width - 70, y + offset)
ctx.stroke()
}
// 描红示例:降低透明度;其他模式可以替换这一层的绘制策略。
ctx.fillStyle = 'rgba(70, 70, 70, 0.22)'
ctx.fillText(text, 90, y + 14)
}
}
调用方式如下:
import { renderWorksheet } from './worksheet.js'
const canvas = document.querySelector('#worksheet')
await renderWorksheet(canvas, {
text: 'Never stop learning.',
fontSize: 44
})
如果需要打印级导出,不要简单截取页面截图。应使用固定输出分辨率重新绘制,例如按 A4、300 DPI 生成约 2480 × 3508 像素的 Canvas。与此同时,要确认字体授权允许随应用分发或嵌入输出文件。
多种生成模式也不必复制整套绘图代码。可以把纸张网格、文字布局和模式效果拆成独立步骤:描红模式改变透明度,临摹或空白练习模式替换文字层,排版与分页逻辑继续复用。这样后续修正基线时只需改一处。
OCR 和旧数据都要设置迁移边界
tesseract.js 让 OCR 留在本地完成,适合不希望把练习内容上传服务器的桌面工具。不过 OCR 是耗时任务,不应在每次点击时反复创建 worker。更合理的方式是复用实例,并给界面增加进度、取消和人工校对入口。
下面是一个可改造的最小封装,API 细节需要与项目锁定的 tesseract.js 主版本核对:
// src/renderer/src/ocr.js
import { createWorker } from 'tesseract.js'
let workerPromise
function getWorker() {
if (!workerPromise) {
workerPromise = createWorker('eng')
}
return workerPromise
}
export async function recognizeEnglish(image) {
const worker = await getWorker()
const result = await worker.recognize(image)
return result.data.text.trim()
}
export async function closeOcr() {
if (!workerPromise) return
const worker = await workerPromise
await worker.terminate()
workerPromise = undefined
}
OCR 输出不能直接成为最终字帖内容。倾斜照片、手写体、连字符和标点都会影响识别结果,比较安全的流程是“导入图片 → OCR → 文本编辑确认 → 生成字帖”。应用打包后还要专门验证 worker、WASM 和语言数据能否被正确加载,不能只验证开发服务器环境。
旧版 PyQt6 项目若用 pickle 保存设置或模板,Electron 无法自然读取。更重要的是,pickle 不是安全的跨语言交换格式,加载恶意文件可能执行代码。推荐在仍能运行旧版 Python 环境时,将可信文件一次性转换成带版本号的 JSON:
# migrate_pickle.py
import argparse
import json
import pickle
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument('input', type=Path)
parser.add_argument('output', type=Path)
args = parser.parse_args()
# 仅转换你自己生成且确认可信的 pickle 文件。
with args.input.open('rb') as source:
old_data = pickle.load(source)
payload = {
'schemaVersion': 1,
'data': old_data,
}
with args.output.open('w', encoding='utf-8') as target:
json.dump(payload, target, ensure_ascii=False, indent=2, default=str)
print(f'Converted: {args.input} -> {args.output}')
运行方式:
python migrate_pickle.py old-template.pkl template.json
如果 pickle 中包含旧项目自定义类,转换脚本应放进原 PyQt6 环境运行,否则可能因为模块路径变化而无法反序列化。default=str 只适合过渡:日期、路径或复杂对象最好显式转换,以免静默丢失类型信息。新版本则应定义稳定的数据结构和 schemaVersion,以后通过迁移函数逐版升级。
打包成功不等于发布成功
使用 electron-builder 时,可以把平台目标集中放入 electron-builder.yml:
appId: com.example.hengshuiworkbook
productName: Hengshui Workbook
directories:
output: release
files:
- out/**
- resources/**
extraResources:
- from: resources/fonts
to: fonts
win:
target:
- nsis
mac:
target:
- dmg
linux:
target:
- AppImage
- deb
构建命令可以这样执行:
npm run build
npx electron-builder --win
npx electron-builder --mac
npx electron-builder --linux
这些命令并不意味着任意宿主系统都能可靠生成全部安装包。macOS 签名、公证和部分原生依赖通常应在 macOS 构建机上完成;Windows 安装器可以借助 Wine 环境生成,Bottles 能用于隔离这类环境,但它更适合作为工程上的兼容手段,而不是替代持续集成和真机测试。
如果项目包含原生 Node 模块,跨平台构建会进一步受到 CPU 架构、ABI 和工具链影响。纯 JavaScript 的 Canvas 与 tesseract.js 方案能减少一部分障碍,但仍需要分别检查:
- 安装、升级和卸载是否正常;
- 自定义字体、OCR 语言数据和 WASM 文件是否进入安装包;
- Windows 路径、Linux 文件权限和 macOS 沙箱行为是否一致;
- 打印结果与屏幕预览是否保持相同分页;
- x64 与 ARM64 产物是否使用了正确依赖;
- 签名、公证和自动更新策略是否匹配发布渠道。
一条更稳妥的迁移顺序
不要在同一个版本里同时重写界面、文件格式、OCR 和所有生成模式。更安全的节奏是先冻结旧版输出样例,再建立 Electron 的最小渲染闭环,随后逐项迁移模式,最后处理旧数据导入与平台安装包。
每次迁移都可以使用同一组文本、字体、纸张尺寸和 OCR 图片做回归测试。对于字帖工具,像素截图比较还不够,必须打印或导出后核对基线、行距、分页和字体替换。完成这些验证后,从 PyQt6 到 Electron 的变化才真正带来跨平台收益,而不是把原有问题搬进三个平台。