原文项目钟毓英语衡水体字帖生成器重构前后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.3NSIS / AppImage / deb / rpm / dmg / zip 一网打尽OCRtesseract.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线位 y0/13/26/39单横线行高 30、组距 0QFont 的 pt 字号按 96 DPI 换算px pt × 4/3描红色#ff6464、网格线#c0c0c0、页码#a0a0a0。排版引擎集中在src/renderer/src/engine/copybook.js的buildPages一次排版分页。顺带还修了原版一个潜伏 bug单横线模式下原版按四线三格行高估算页容量跨页时单词会重复出现重构版按实际行高算就好了——算是重构附赠的彩蛋。二、工程文件双向兼容pickle 这个老顽童原版的.zyecb工程文件是Python picklePy3 默认协议 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 里字符串 opcodeV后面跟一行以\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)建一个隐藏的 BrowserWindow加载print.htmlIPC 把排版数据塞过去渲染进程用 Canvas 2D 按 A4 尺寸逐页画画完了 IPC 回报主进程按 sender id 过滤多窗口串消息这种事防一手设个 30s 超时兜底根据调用场景分流pdf:export→webContents.printToPDFprint:direct→webContents.print弹系统打印对话框print:preview→ 复用单例预览窗口显示。打印参数统一为{ printBackground: true, pageSize: A4, margins: { marginType: none } }。划重点新版 Electron 用 margins 对象旧的 marginsType 已经被打入冷宫。3.2 高清渲染2 倍 DPRA4 页面按2 倍 DPR192 DPI绘制794×1123 96dpi 的 2 倍打出来的字边缘那叫一个锐利。打印和 PDF 共用同一份 Canvas 数据效果完全一致用户再也不会说PDF 看着挺好打出来糊了。3.3 打印预览窗口的那些小细节单例复用重复打开就 focus 重渲染别傻乎乎每次新建窗口渲染完再 show()不然用户看到白屏闪烁体验分骤降setMenu(null)非 macOS 下附加窗口默认带应用菜单栏必须手动清掉不然预览窗口顶个文件编辑帮助菜单怪尴尬的CSSzoom缩放别用transform: scalezoom 是参与 Chromium 布局计算的滚动条和页码定位才对得上适应页宽算法zoom (clientWidth - 两侧留白) / 794794 是 A4 宽 96dpi 的像素值页码跟随滚动用getBoundingClientRect找最后一个 top ≤ 视口 35% 的页当当前页。别问我为什么知道——初版用current写了个差一 bug翻第一页显示第 2/2 页当场社死。3.4 Linux 下验证的小坑在 deepin 上做自动化验证时发现一个有意思的现象GTK 打印对话框打开期间父窗口渲染进程的 JS 被模态阻塞了CDPRuntime.evaluate直接超时对话框一关立马恢复。所以测试时系统对话框那块选打印机、点确认得交给 xdotool应用内交互点预览按钮、拖缩放才能用 CDP。另外 xdotool 在 GTK 保存对话框里type路径会被输入法劫持斜杠还会被吃掉解决方案是测试导出时先接受默认文件名事后再改回去。四、截图识别desktopCapturer tesseract.js光有手动输入哪够必须上个截图识别——看到屏幕上的英文直接框一下就进字帖多香。4.1 完整流程走一遍主窗口先藏起来desktopCapturer.getSources({ types: [screen] })截屏thumbnailSize设成显示器物理尺寸 × scaleFactorHiDPI 下才不会糊每个显示器弹一个全屏无边框alwaysOnTop选区窗口sel.html复用主 preload用户拖框选宽高 8px 当误触处理Enter 或双击确认、Esc 取消裁出的 dataURL 经 IPC 扔给主进程的 tesseract.js识别结果用document.execCommand(insertText, false, text)回填输入框——这样能保留原生撤销栈还能自动触发 input 事件联动预览。4.2 双击确认的交互坑写的时候踩了个挺隐蔽的 bug拖出选区后双击居然确认不了。一通 debug 发现双击的第一次mousedown命中了已有选区代码把选区重置成了一个点到dblclick时选区宽高已经是 0 了自然啥也确认不了。修复很简单mousedown时如果点落在已有选区内不重置选区只记个起始点把确认的机会留给dblclick。改完 Enter 确认、双击确认、Esc 取消就各司其职了。4.3 HiDPI 坐标换算screenAPI 返回的是 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 缓存别每次识别都新建输入用 BufferdataURL 先转 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 基础配置{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 emailexample.com,artifactName:${name}-${version}-${arch}.${ext}}}几个要点maintainer必须带 email不然 deb 构建给你报个莫名其妙的错artifactName用${name}保持 ASCII 文件名Windows 除外默认按 productName 中文命名linux.icon 指向多尺寸目录而不是单张 PNG原因见坑 3。5.2 架构支持矩阵平台x64arm64riscv64Windows NSIS✅✅合并包❌Linux AppImage/deb/rpm✅✅交叉❌macOS dmg/zip✅✅❌Linux 交叉打 arm64npx electron-builder --linux AppImage deb rpm --arm64Windows NSIS 不指定--x64时默认打x64arm64 双架构合并安装包安装时让用户自选架构riscv64 没官方 Electron 二进制死心吧。5.3 Linux 上交叉打 Windows NSIS无系统 wine用 flatpak Bottles本机装不了系统级 wine退而求其次用 flatpak 的 Bottlesflatpak install flathub com.usebottles.bottles建 bottlebottles-cli new --bottle-name builder --environment application --arch win64沙箱网络是个大坑bottles 组件从 github 下沙箱默认不走 host socks5 代理Python requests 还缺 SOCKS 支持 →pip download PySocks纯 py wheel解到 bottles 能访问的目录建 bottle 时加--envPYTHONPATH... --envhttps_proxysocks5h://x.x.x.x:port新 winesoda runner 11.xwow64 合并了只有 wine 没有 wine64而且 standalone 是个 bash 脚本不是二进制写个 wine 包装器wine 和 wine64 都软链到它exportLD_LIBRARY_PATHrunner/lib:runner/lib/wine/x86_64-unix:runner/lib/wine/i386-unixexportWINEPREFIXbottle路径exportPATHrunner/bin:$PATHexecrunner/bin/wine$PATH/tmp/eb-wine:$PATH npx electron-builder --win nsis --publish never实测验证rceditexe 版本信息能塞中文产品名、makensis 都正常还能在 bottle 里Setup.exe /S静默安装走一遍流程。5.4 Linux 上出 macOS zipdmg-license 是 macOS 专属可选依赖Linux 上装不上原生库 invalid ELF header。绕法npm i --no-save dmg-license --force然后把它的index.js替换成module.exports {}桩模块zip 目标只 require 不调用。dmg 必须在 macOS 上构建要 hdiutilLinux 上没办法。完事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 跑需要用户级 rpmdbrpm --initdb初始化~/.cache/rpmdb在~/.rpmmacros写%_dbpath /home/user/.cache/rpmdb不然会报/var/lib/rpm/rpmdb.sqlite打不开。六、四个致命的打包坑真实用户机器上栽过的跟头这节是全文最有含金量的部分——这四个问题本地开发压根测不出来都是打包后扔到真实用户环境才现原形的。坑 1Windows 安装后白屏闪退现象安装一切正常双击快捷方式窗口一闪而过连个错误日志都不给你留。根因src/main/ocr.js顶层写了import { createWorker } from tesseract.js构建产物在主进程入口变成require(tesseract.js)某些打包/系统环境下初始化失败直接闪退白屏。修复改成函数内await import(tesseract.js)懒加载首次用到 OCR 时才加载。教训主进程顶层永远别 import 重依赖这条记住能救命。坑 2deepin/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.asarfiles 里加resources/app_icon.png打包后join(__dirname, ../../resources/app_icon.png)nativeImage 支持 asar 路径三平台通吃。这样 hicolor 成了唯一物理文件硬链接条目数直接归零。验证方法debar x pkg.deb tar tvf data.tar.* | grep -c ^hrpmrpm2archive pkg.rpm | tar tv | grep -c ^h坑 3Linux 图标显示未知类型占位图现象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 数据目录后用 GTKGtk.IconTheme.get_default().lookup_icon(name, 256, 0)能解析出来。注意 offscreen 下 PyQt 的QIcon.fromTheme连系统图标都查不到别拿它验证纯属浪费时间。坑 4Windows 12 代 Intel 大小核 CPU 上 Node 初始化崩溃现象Windows 11 真机装完打不开退出码 0但同一个安装包扔 VirtualBox Win10 里跑得欢VS Code 等其他 Electron 应用在这台机器上也正常。根因Electron 31 内置的 Node 20.14 在 Intel 大小核混合 CPU12 代及以后上调GetLogicalProcessorInformationEx枚举处理器组时缓冲区越界直接 fatal。修复升级到 Electron 33.4.11Node 20.18修了这 bug。少数处理器组信息异常的机器还得检查 BIOS 或 Windows 启动参数bcdedit里的groupsize/maxgroup/numproc/usegroup。教训新项目直接 Electron ≥33别给自己找麻烦。七、菜单助记符的跨平台玄学这是个小坑但烦人的很。Electron 的菜单在 Linux GTK 和 Windows 上对(X)的处理还不一样顶层菜单(F)会被整体从显示文本剥离只注册 Alt 助记符——所以顶层得双写文件(F)(F)才能既显示(F)又有 AltF子菜单项只剥符号本身新建(N)显示带下划线的 N原生写法就行→ 字面不注册助记符_在 GTK 不当下划线语法原样显示。封装两个 helper 一劳永逸consttopMenuLabel(text,key)${text}(${key})(${key})constitemLabel(text,key,suffix)${text}(${key})${suffix}八、没有商业 UI 测试工具这套组合拳顶用deepin 上做验证没有付费测试工具全靠开源凑CDP--remote-debugging-port9223临时装个ws包Node 20 没全局 WebSocket写脚本Runtime.evaluate读 DOM/canvas 像素、Input.dispatchKeyEvent驱动应用内按键xdotool负责 GTK 原生对话框菜单导航key --delay 200 altf不加 delay 菜单丢键PillowImageGrab.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/dmgOCR、打印、文件关联这些原生能力也都齐活。代价嘛就是得重新适应浏览器环境下的渲染、IPC、打包模型。几个扎心的经验逐像素对齐原版是桌面应用重构的第一原则别擅自优化用户已经习惯的视觉文件格式双向兼容是迁移的生命线pickle 协议 0 写入的\uXXXX坑一定要绕开主进程顶层不 import 重依赖一律懒加载白屏闪退能防一大半打包坑全在真实环境暴露EXDEV 硬链接、hicolor 0x0 图标、Intel 大小核崩溃——这些本地开发永远测不出来必须上目标系统验打印预览要单例、渲染完再 show、记得清菜单细节决定体验。重构这趟下来感觉 Electron 做桌面应用其实没网上传的那么不堪关键是别把它当网页写得有桌面应用的意识——窗口生命周期、系统对话框、原生菜单、文件关联一个都不能少。希望这篇踩坑记录能帮到正在做类似重构的你。有问题欢迎评论区开麦 相关资源项目仓库GitHubv2.0.2 Release