从PyQt6到Electron:一款英语字帖生成器的跨平台重构实战与踩坑全记录

原文项目:钟毓英语衡水体字帖生成器

重构前后:PyQt6 v1.1.1 → Electron v2.0.x(跨平台 Windows / macOS / Linux)

关键词:electron-vite、Canvas 2D、tesseract.js OCR、electron-builder、pickle 兼容、Bottles 交叉打包

前言

笔者手上有一款用 PyQt6 开发的英语衡水体字帖生成器,功能挺全乎:四种生成模式(描红/抄写/描红+抄写/字帖)、两种线格、多标签页、PDF 导出,还有个自定义的 .zyecb 工程文件格式。原版在 Windows 上跑得好好的,可架不住用户问"Linux 有吗"、"mac 能装吗"------Python 桌面应用的分发短板一下就暴露了:PyInstaller 体积大、跨平台得各开一台机器、系统库依赖分分钟给你整出兼容玄学。

得,那就用 Electron 彻底重构吧。本文不整"为什么选 Electron"这种正确的废话,直接上干货:构建思路怎么定的、关键决策怎么做的、以及踩过的那些真实坑和最终怎么爬出来的。正在折腾桌面应用重构的朋友,希望能帮你少走点弯路。


一、整体架构与技术选型

1.1 技术栈

层面 选型 说明
构建工具 electron-vite 2 + Vite 5 主/预加载/渲染进程统一构建,HMR 香得很
运行时 Electron 33.4.11 直接上 33,别问,问就是被 Node 20.14 坑过(见第六节坑 4)
渲染 原生 JavaScript + Canvas 2D 无框架,直接复刻 QPainter 自绘逻辑
打包 electron-builder 24.13.3 NSIS / AppImage / deb / rpm / dmg / zip 一网打尽
OCR tesseract.js v7 截图识别,语言数据内置,离线可用

1.2 目录与多入口设计

原版就是个单窗口 QMainWindow 套 QTabWidget。到了 Electron 这边,我把"全屏自绘"的场景拆成了独立 HTML 入口,各司其职:

复制代码
src/
├── main/          主进程:IPC、窗口、菜单、打印、OCR
├── preload/       contextBridge 桥
└── renderer/
    ├── index.html     主界面(标签页 + 控件 + 预览 Canvas)
    ├── print.html     隐藏窗口:渲染 A4 页面供 PDF 导出 / 打印
    ├── sel.html       截图选区窗口(每显示器一个,全屏无边框)
    └── preview.html   打印预览窗口

四个入口在 electron.vite.config.mjs 的 rollupOptions.input 里注册。这种"按全屏场景拆入口"的设计,比在单个 BrowserWindow 里切视图清爽多了------打印和预览窗口本来就不需要主界面那一堆控件。

1.3 逐像素对齐原版:别瞎优化

重构桌面应用,最忌讳的就是"顺手优化一下",结果用户一打开感觉"这味儿不对啊"。我的策略很简单:页面坐标系、字号、颜色、行高全部一比一复刻。

  • 页面尺寸 800×1131px,边距 (20, 20, 760, 1091),头部高 100;
  • 四线三格行高 40、组距 40(线位 y+0/13/26/39),单横线行高 30、组距 0;
  • QFont 的 pt 字号按 96 DPI 换算:px = pt × 4/3;
  • 描红色 #ff6464、网格线 #c0c0c0、页码 #a0a0a0。

排版引擎集中在 src/renderer/src/engine/copybook.js 的 buildPages,一次排版分页。顺带还修了原版一个潜伏 bug:单横线模式下原版按四线三格行高估算页容量,跨页时单词会重复出现,重构版按实际行高算就好了------算是重构附赠的彩蛋。


二、工程文件双向兼容:pickle 这个老顽童

原版的 .zyecb 工程文件是 Python pickle(Py3 默认协议 4)。老用户迁移是刚需,新版必须能读;要让用户在新旧版本间自由切换,新版存的文件原版最好也能打开。

2.1 读取:手写协议 0~4 子集解析器

pickle 本质是个基于栈的虚拟机字节码。我吭哧吭哧手写了一个解析器,把常用 opcode 都覆盖了:PROTO、STOP、MARK、EMPTY_LIST/DICT/TUPLE、APPEND、SETITEM、BINUNICODE、SHORT_BINUNICODE、GLOBAL、REDUCE 等等,协议 4 的 FRAME、MEMOIZE 也没落下。这活儿不复杂但碎,建议边对照 pickletools.dis() 的输出边写,事半功倍。

2.2 写入:用协议 0,但小心 \uXXXX

输出我选了 协议 0(纯 ASCII),任何 Python 版本都能读,出问题了肉眼也能 debug。

这里有个能把人逼疯的坑:协议 0 里字符串 opcode V 后面跟一行以 \n 结尾的 raw-unicode-escape 字符串。Python 的 raw-unicode-escape 只认 \uXXXX 形式的转义,不认 \n、\\ 这种简写 。所以字符串里的换行、反斜杠、控制符必须全部编码成 \u000a、\u005c 等形式,否则原版 pickle.load 轻则 UnicodeDecodeError,重则读到一堆乱码。

2.3 回归测试

双向跑一遍:

  • 原版保存一批典型工程(特殊字符、空内容、长文本都来点)→ 新版打开;
  • 新版保存 → 原版打开;
  • 断言渲染结果一字不差。

最终实现了真正的双向兼容,用户双击 .zyecb 就能用新版打开(通过 electron-builder 的 fileAssociations 注册文件关联)。


三、打印与打印预览:一条管线走天下

原版"导出 PDF"和"打印"走的是 QPrinter。到了 Electron,我把这两条路合并成一条渲染管线,再额外加了个"打印预览"窗口------毕竟都 2026 年了,没预览的打印是不完整的。

3.1 三路复用的渲染窗口

主进程 ipc.js 抽出 createRenderWindow(data):

  1. 建一个隐藏的 BrowserWindow,加载 print.html;
  2. IPC 把排版数据塞过去,渲染进程用 Canvas 2D 按 A4 尺寸逐页画;
  3. 画完了 IPC 回报,主进程按 sender id 过滤(多窗口串消息这种事,防一手),设个 30s 超时兜底;
  4. 根据调用场景分流:
    • pdf:export → webContents.printToPDF;
    • print:direct → webContents.print 弹系统打印对话框;
    • print:preview → 复用单例预览窗口显示。

打印参数统一为 { printBackground: true, pageSize: 'A4', margins: { marginType: 'none' } }。划重点:新版 Electron 用 margins 对象,旧的 marginsType 已经被打入冷宫。

3.2 高清渲染:2 倍 DPR

A4 页面按 2 倍 DPR(192 DPI) 绘制(794×1123 @96dpi 的 2 倍),打出来的字边缘那叫一个锐利。打印和 PDF 共用同一份 Canvas 数据,效果完全一致,用户再也不会说"PDF 看着挺好,打出来糊了"。

3.3 打印预览窗口的那些小细节

  • 单例复用:重复打开就 focus + 重渲染,别傻乎乎每次新建窗口;
  • 渲染完再 show():不然用户看到白屏闪烁,体验分骤降;
  • setMenu(null):非 macOS 下附加窗口默认带应用菜单栏,必须手动清掉,不然预览窗口顶个文件编辑帮助菜单怪尴尬的;
  • CSS zoom 缩放 :别用 transform: scale,zoom 是参与 Chromium 布局计算的,滚动条和页码定位才对得上;
  • "适应页宽"算法 :zoom = (clientWidth - 两侧留白) / 794,794 是 A4 宽 @96dpi 的像素值;
  • 页码跟随滚动 :用 getBoundingClientRect 找"最后一个 top ≤ 视口 35% 的页"当当前页。别问我为什么知道------初版用 current++ 写了个差一 bug,翻第一页显示"第 2/2 页",当场社死。

3.4 Linux 下验证的小坑

在 deepin 上做自动化验证时发现一个有意思的现象:GTK 打印对话框打开期间,父窗口渲染进程的 JS 被模态阻塞了 ,CDP Runtime.evaluate 直接超时,对话框一关立马恢复。所以测试时系统对话框那块(选打印机、点确认)得交给 xdotool,应用内交互(点预览按钮、拖缩放)才能用 CDP。

另外 xdotool 在 GTK 保存对话框里 type 路径会被输入法劫持,斜杠还会被吃掉,解决方案是测试导出时先接受默认文件名,事后再改回去。


四、截图识别:desktopCapturer + tesseract.js

光有手动输入哪够,必须上个截图识别------看到屏幕上的英文直接框一下就进字帖,多香。

4.1 完整流程走一遍

  1. 主窗口先藏起来;
  2. desktopCapturer.getSources({ types: ['screen'] }) 截屏,thumbnailSize 设成显示器物理尺寸 × scaleFactor,HiDPI 下才不会糊;
  3. 每个显示器弹一个 全屏无边框 alwaysOnTop 选区窗口(sel.html),复用主 preload;
  4. 用户拖框选(宽高 < 8px 当误触处理),Enter 或双击确认、Esc 取消;
  5. 裁出的 dataURL 经 IPC 扔给主进程的 tesseract.js;
  6. 识别结果用 document.execCommand('insertText', false, text) 回填输入框------这样能保留原生撤销栈,还能自动触发 input 事件联动预览。

4.2 双击确认的交互坑

写的时候踩了个挺隐蔽的 bug:拖出选区后双击居然确认不了。一通 debug 发现,双击的第一次 mousedown 命中了已有选区,代码把选区重置成了一个点,到 dblclick 时选区宽高已经是 0 了,自然啥也确认不了。

修复很简单:mousedown 时如果点落在已有选区内,不重置选区,只记个起始点,把确认的机会留给 dblclick。改完 Enter 确认、双击确认、Esc 取消就各司其职了。

4.3 HiDPI 坐标换算

screen API 返回的是 DIP 尺寸(125% 缩放下 1536×864),但截图是物理像素(1920×1080)。选区坐标到图像坐标的换算就一句:sx = image.naturalWidth / window.innerWidth(也就是 scaleFactor),裁剪时 x * sx、y * sy 就行。

4.4 tesseract.js 在主进程跑

  • createWorker(lang, 1, { langPath, cachePath, logger: () => {} }),worker 按语言 Map 缓存,别每次识别都新建;
  • 输入用 Buffer(dataURL 先转 Buffer);
  • 语言数据用 tessdata_fast 的 .gz,丢 resources/ocr-data 里,通过 extraResources 内置,打包后路径是 process.resourcesPath/ocr-data;
  • 必须在 package.json 的 dependencies 里(externalizeDepsPlugin 会把主进程依赖外部化,运行时从 node_modules require);
  • 重依赖懒加载 :别在主进程入口顶层 import,首次调用时再 await import('tesseract.js')------这是第六节"四个致命坑"之一,白屏闪退的元凶。

官方 tessdata.projectnaptha.com 直连会重置连接,换 cdn.jsdelivr.net/gh/tesseract-ocr/tessdata_fast 下 plain 文件再本地 gzip 即可。


五、多平台打包:electron-builder 全攻略

5.1 基础配置

json 复制代码
{
  "win": { "target": "nsis", "icon": "resources/app_icon.ico" },
  "nsis": {
    "oneClick": false,
    "allowToChangeInstallationDirectory": true,
    "createDesktopShortcut": true
  },
  "mac": {
    "target": ["dmg", "zip"],
    "icon": "resources/app_icon.icns",
    "artifactName": "${name}-${version}-${arch}.${ext}"
  },
  "linux": {
    "target": ["AppImage", "deb", "rpm"],
    "icon": "resources/icons",
    "maintainer": "Your Name <email@example.com>",
    "artifactName": "${name}-${version}-${arch}.${ext}"
  }
}

几个要点:

  • maintainer 必须带 email,不然 deb 构建给你报个莫名其妙的错;
  • artifactName 用 ${name} 保持 ASCII 文件名(Windows 除外,默认按 productName 中文命名);
  • linux.icon 指向多尺寸目录而不是单张 PNG(原因见坑 3)。

5.2 架构支持矩阵

平台 x64 arm64 riscv64
Windows NSIS ✅ ✅(合并包) ❌
Linux AppImage/deb/rpm ✅ ✅(交叉) ❌
macOS dmg/zip ✅ ✅ ❌
  • Linux 交叉打 arm64:npx electron-builder --linux AppImage deb rpm --arm64;
  • Windows NSIS 不指定 --x64 时默认打 x64+arm64 双架构合并安装包(安装时让用户自选架构);
  • riscv64 没官方 Electron 二进制,死心吧。

5.3 Linux 上交叉打 Windows NSIS(无系统 wine,用 flatpak Bottles)

本机装不了系统级 wine,退而求其次用 flatpak 的 Bottles:

  1. flatpak install flathub com.usebottles.bottles

  2. 建 bottle:bottles-cli new --bottle-name builder --environment application --arch win64

  3. 沙箱网络是个大坑:bottles 组件从 github 下,沙箱默认不走 host socks5 代理;Python requests 还缺 SOCKS 支持 → pip download PySocks(纯 py wheel)解到 bottles 能访问的目录,建 bottle 时加 --env=PYTHONPATH=... --env=https_proxy=socks5h://x.x.x.x:port

  4. 新 wine(soda runner 11.x)wow64 合并了,只有 wine 没有 wine64,而且 standalone 是个 bash 脚本不是二进制

  5. 写个 wine 包装器(wine 和 wine64 都软链到它):

    bash 复制代码
    export LD_LIBRARY_PATH=<runner>/lib:<runner>/lib/wine/x86_64-unix:<runner>/lib/wine/i386-unix
    export WINEPREFIX=<bottle路径>
    export PATH=<runner>/bin:$PATH
    exec <runner>/bin/wine "$@"
  6. PATH=/tmp/eb-wine:$PATH npx electron-builder --win nsis --publish never

实测验证:rcedit(exe 版本信息能塞中文产品名)、makensis 都正常,还能在 bottle 里 Setup.exe /S 静默安装走一遍流程。

5.4 Linux 上出 macOS zip

dmg-license 是 macOS 专属可选依赖,Linux 上装不上(原生库 invalid ELF header)。绕法:npm i --no-save dmg-license --force 然后把它的 index.js 替换成 module.exports = {} 桩模块(zip 目标只 require 不调用)。dmg 必须在 macOS 上构建 (要 hdiutil),Linux 上没办法。完事 npm prune 清掉。

5.5 rpm 4.20 环境的特殊处理

本机 rpm 4.20 和 electron-builder 内置的 fpm 1.9.3 八字不合:fpm 传的 --define buildroot X 被 rpm 4.20 当空气,结果就是 "File not found"。解法:写个 rpmbuild 包装器,把 --define buildroot X 翻译成 4.20 还认的 --buildroot X,构建时 PATH 前置。

另外非 root 跑需要用户级 rpmdb:rpm --initdb 初始化 ~/.cache/rpmdb,在 ~/.rpmmacros 写 %_dbpath /home/<user>/.cache/rpmdb,不然会报 /var/lib/rpm/rpmdb.sqlite 打不开。


六、四个致命的打包坑(真实用户机器上栽过的跟头)

这节是全文最有含金量的部分------这四个问题本地开发压根测不出来,都是打包后扔到真实用户环境才现原形的。

坑 1:Windows 安装后白屏闪退

现象:安装一切正常,双击快捷方式,窗口一闪而过,连个错误日志都不给你留。

根因 :src/main/ocr.js 顶层写了 import { createWorker } from 'tesseract.js',构建产物在主进程入口变成 require("tesseract.js"),某些打包/系统环境下初始化失败,直接闪退白屏。

修复 :改成函数内 await import('tesseract.js') 懒加载,首次用到 OCR 时才加载。

教训 :主进程顶层永远别 import 重依赖,这条记住能救命。

坑 2:deepin/UOS 装 deb 报 EXDEV 硬链接错误

现象 :dpkg: 错误:新建硬链接 ... 无效的跨设备链接 (EXDEV)。

根因 :app_icon.png 既当 extraResources(装到 /opt/.../resources/)又当 linux.icon(装到 /usr/share/icons/hicolor/),electron-builder staging 阶段用 hardlink 复制,fpm 1.9.3 把硬链接写进 tar(条目类型 h)。deepin/UOS 这类不可变系统 /opt 和 /usr 是不同挂载点,dpkg 解包时 link() 跨设备直接失败。

冷知识 :fpm 1.9.3 没有 --deb-no-hardlinks 选项(1.10+ 才有),配 deb.fpm 会直接构建失败,别在这上面浪费时间。

根治 :图标别放 extraResources,打进 app.asar (files 里加 "resources/app_icon.png"),打包后 join(__dirname, '../../resources/app_icon.png'),nativeImage 支持 asar 路径,三平台通吃。这样 hicolor 成了唯一物理文件,硬链接条目数直接归零。

验证方法:

  • deb:ar x pkg.deb && tar tvf data.tar.* | grep -c '^h'
  • rpm:rpm2archive pkg.rpm | tar tv | grep -c '^h'

坑 3:Linux 图标显示"未知类型"占位图

现象:deb 装上了,启动器里应用图标是个灰色的"未知类型"占位图,丑得离谱。

根因 :linux.icon 给单张 PNG 时,electron-builder 只把它扔到 /usr/share/icons/hicolor/0x0/apps/。0x0 不符合 hicolor-icon-theme 规范,deepin/UOS 的启动器直接不索引。

修复 :弄个多尺寸图标目录 resources/icons/,塞进去 16/24/32/48/64/128/256/512 的 NxN.png(用 PIL LANCZOS 从 256px 源图生成,512 是放大的),linux.icon 指向这个目录,构建后就装到各 hicolor/<size>/apps/ 了。

验证 :模拟 XDG 数据目录后用 GTK Gtk.IconTheme.get_default().lookup_icon(name, 256, 0) 能解析出来。注意 offscreen 下 PyQt 的 QIcon.fromTheme 连系统图标都查不到,别拿它验证,纯属浪费时间。

坑 4:Windows 12 代+ Intel 大小核 CPU 上 Node 初始化崩溃

现象:Windows 11 真机装完打不开(退出码 0),但同一个安装包扔 VirtualBox Win10 里跑得欢,VS Code 等其他 Electron 应用在这台机器上也正常。

根因 :Electron 31 内置的 Node 20.14 在 Intel 大小核混合 CPU(12 代及以后)上调 GetLogicalProcessorInformationEx 枚举处理器组时缓冲区越界,直接 fatal。

修复 :升级到 Electron 33.4.11(Node 20.18,修了这 bug) 。少数处理器组信息异常的机器,还得检查 BIOS 或 Windows 启动参数(bcdedit 里的 groupsize/maxgroup/numproc/usegroup)。

教训:新项目直接 Electron ≥33,别给自己找麻烦。


七、菜单助记符的跨平台玄学

这是个小坑但烦人的很。Electron 的菜单在 Linux GTK 和 Windows 上,对 (&X) 的处理还不一样:

  • 顶层菜单 :(&F) 会被整体从显示文本剥离 ,只注册 Alt 助记符------所以顶层得双写 文件(F)(&F),才能既显示 (F) 又有 Alt+F;
  • 子菜单项 :只剥 & 符号本身,新建(&N) 显示带下划线的 N,原生写法就行;
  • && → 字面 &(不注册助记符);_ 在 GTK 不当下划线语法,原样显示。

封装两个 helper 一劳永逸:

js 复制代码
const topMenuLabel = (text, key) => `${text}(${key})(&${key})`
const itemLabel = (text, key, suffix = '') => `${text}(&${key})${suffix}`

八、没有商业 UI 测试工具?这套组合拳顶用

deepin 上做验证,没有付费测试工具,全靠开源凑:

  • CDP :--remote-debugging-port=9223,临时装个 ws 包(Node 20 没全局 WebSocket)写脚本,Runtime.evaluate 读 DOM/canvas 像素、Input.dispatchKeyEvent 驱动应用内按键;
  • xdotool :负责 GTK 原生对话框(菜单导航 key --delay 200 alt+f,不加 delay 菜单丢键);
  • Pillow :ImageGrab.grab(xdisplay=':0') 截图(物理分辨率,坐标按像素算);
  • tkinter :造 OCR 测试素材------overrideredirect + topmost 置顶窗显示已知文本,setsid nohup 启动防 shell 退出连带杀进程,文字四周留足 padding,不然贴边字母识别错;
  • pkill 技巧 :pkill -f 的模式如果匹配到自身命令行会自杀,用 [x] 括号技巧,比如 pkill -f '[e]lectron .'。

九、总结

从 PyQt6 到 Electron 的重构,最大的收获是跨平台分发能力的质变:一套代码产出 Windows NSIS、Linux AppImage/deb/rpm、macOS zip/dmg,OCR、打印、文件关联这些原生能力也都齐活。代价嘛,就是得重新适应浏览器环境下的渲染、IPC、打包模型。

几个扎心的经验:

  1. 逐像素对齐原版是桌面应用重构的第一原则,别擅自"优化"用户已经习惯的视觉;
  2. 文件格式双向兼容 是迁移的生命线,pickle 协议 0 写入的 \uXXXX 坑一定要绕开;
  3. 主进程顶层不 import 重依赖,一律懒加载,白屏闪退能防一大半;
  4. 打包坑全在真实环境暴露:EXDEV 硬链接、hicolor 0x0 图标、Intel 大小核崩溃------这些本地开发永远测不出来,必须上目标系统验;
  5. 打印预览要单例、渲染完再 show、记得清菜单,细节决定体验。

重构这趟下来,感觉 Electron 做桌面应用其实没网上传的那么不堪,关键是别把它当网页写,得有"桌面应用"的意识------窗口生命周期、系统对话框、原生菜单、文件关联,一个都不能少。

希望这篇踩坑记录能帮到正在做类似重构的你。有问题欢迎评论区开麦 🎤


相关资源:

相关推荐
星栈12 小时前
pnpm 12 升级实测
前端·javascript
Ai-_Man13 小时前
您您这可以把Microsofat Copilot的多个会话比如说。左侧的多个会话一次性导出吗?不是单条会话里面的多次会对话。AI导出鸭
javascript·人工智能·ai·小程序·电脑·copilot
兰亭妙微UI设计公司16 小时前
兰亭妙微UI设计公司 蚂蚁阿福APP拆解:对话式交互如何重构就医全流程?
重构·交互
Bruce_Liuxiaowei16 小时前
网页版 DeepSeek “harness desktop“ WebGL 上下文创建失败排查实录
开发语言·javascript·webgl
小凯在掘金17 小时前
为什么箭头函数不能作为构造函数?
前端·javascript
可乐鸡翅yeah_17 小时前
网页端 M3U8 播放器性能监控,怎么看播放卡顿指标
javascript·ffmpeg·音视频·safari·m3u8
可乐鸡翅yeah_17 小时前
M3U8 防盗链 URL‑Token 签名,新手开发实操避坑
开发语言·javascript·ios·音视频·safari
2603_9658966217 小时前
JS原型与原型链|对象继承与实例底层原理
开发语言·javascript·原型模式
JustHappy18 小时前
「vConsole MCP🛠️」我让 AI 直接看见任何 H5 的日志和请求帮你 debug
前端·javascript·程序员