0. 先给结论
Visioxure 是一个「原生壳 + Web 内核」的 Windows 桌面应用:C++17/Win32 负责窗口、文件和 .vsdx 编解码,WebView2 里的原生 JS + Canvas 2D 负责编辑器和原型运行时。

它没有 Electron 的 100 MB Chromium,也没有自己从头写一套 2D 图元库。整条链路里我认为最值得抄的三件事是:
- 一份事实源:文档模型只存在于前端 JSON,后端完全无状态。
- 一条渲染管线 :编辑器、PNG、SVG、PDF、HTML 导出、原型预览共用同一份
drawScene。 - 用像素断言兜住"看着对":导出与画布的一致性靠离屏采样断言,而不是靠肉眼截图比对。
下面分别是它们怎么落地,以及踩过的坑。
1. 形态:为什么是 WebView2 + Canvas 2D
摆在面前的三条路:
| 方案 | 体积 | 代价 |
|---|---|---|
| Electron | 100 MB+ / 150 MB 内存 | 为了一个画图工具背一个 Chromium |
| 纯原生 Direct2D 自绘图元库 | 极小 | 文本排版、输入法、光标、滚动、DPI 全得自己写------这才是真正的无底洞 |
| 原生壳 + WebView2(本项目) | 1.46 MB | 依赖系统 WebView2;只有 Windows |
选第三条的核心理由不是"Web 前端好写",而是图形编辑器真正难的从来不是画矩形,而是文本折行、字体回退、输入法、命中测试、缩放下的描边宽度、DPI 对齐。这些 WebView2 已经替我解决了一大半,剩下的是我能控制的量。
至于为什么是 Canvas 2D 而不是 DOM 元素画形状:一次拖拽要重算几千个图元的命中与变换,DOM 的布局与合成开销不可控;而 Canvas 2D 的「形状 → 像素」只需一个 Path2D 级别的思维模型,缩放就是把 zoom 乘进坐标里(sx = x * zoom + ox),没有第二套坐标系。
单文件分发是这样做到的:
text
build.ps1
└─ pack_resources.py 扫描 frontend/ → 生成 resources.rc(30 个资源,数字 ID)
└─ MSVC 把 .rc 编进 exe
运行时
└─ VirtualFS 从 exe 资源段读出文件内容(内存里)
└─ WebResourceRequested 拦截 https://tauricpp.app/* → 按 MIME 返回 VirtualFS 里的字节
结果是:磁盘上没有 .js 文件,临时目录里也没有------前端代码没有"被拷走"的落地形态。开发时用 build.ps1 -DevMode 关掉嵌入,直接从磁盘读 frontend/,改完刷新即可,不必重新编译。
2. 职责边界:前端是唯一事实源,后端无状态
text
┌──────────────── WebView2(编译期嵌入)───────────────┬────── C++17 ──────┐
│ model.js 文档 JSON(唯一事实源) │ main.cpp 命令表 │
│ renderer.js Canvas 2D 渲染 / drawScene │ vsdx/package OPC │
│ tools.js 选择 / 变换 / 连接线 / 就地文本 │ vsdx/writer JSON→XML │
│ interact.js 交互运行时(预览与导出共用) │ vsdx/reader XML→JSON │
└──────────────────────┬──────────────────────────────┴───────────────────┘
│ invoke(cmd) ⇄ emit(event)(11 条 Bridge 命令)
后端只做四类事:OPC zip 编解码、原生对话框、最近文件、写盘。11 条命令里没有一条碰文档结构:
cpp
bridge.test app.set_title app.get_info app.open_url
file.open_dialog file.save_dialog app.recent_files log_error
vsdx.save vsdx.load export.write
这个边界带来的好处很直接:
- 撤销、导出、交互、测试全在一个语言栈里(JS),改一个字段不需要同时改 C++ 的结构体;
- C++ 侧无状态,不存在"两边各持一份模型、谁覆盖谁"的并发与生命周期问题;
- 保存就是把
Model.serialize()的字符串丢给vsdx.save,读取就是把vsdx.load返回的 JSON 丢给Model.load。
代价是文档要过一次 Bridge。但 800×1100 的原型文档通常不到 1 MB,invoke 的序列化开销在这个量级可以忽略;真到了几十 MB 的图纸,我们再谈增量同步------现在不值得为它引入复杂度。
3. 撤销:快照式,而不是命令模式
这是我最不"教科书"的一个选择:
js
run(label, fn) {
const before = VX.Model.serialize(); // 全量 JSON
const v0 = view(); // 当前页 + 选中
fn();
const after = VX.Model.serialize();
if (after !== before) push(label, before, v0); // 没变化就不入栈
}
- 全量快照,上限 200 步;
- 每个快照同时记录视图上下文(当前页 + 选中 id),回放时一起恢复;
- 拖拽类操作用
begin() / commit(label):开始时记一次,结束时比一次,没变化就丢弃; - 回放走
Model.load(json),再把仍然存在的形状重新选中。
为什么不用命令模式(每个操作实现 undo())?因为这个编辑器到 1.0 已经有几十种编辑操作:拖拽、缩放、旋转、布尔、组合、解组、连接线重粘附、手动折线、文字编辑、图层、对齐分布、面板状态切换、变量改名......每一种都要配一个逆操作,漏一个就是"撤销后文件坏了"。而全量快照只用了 20 行代码,就让所有新操作天然可撤销------新增功能时我不需要再想撤销这件事。
代价是内存和序列化时间,对策有三条:before !== after 去重、上限 200 步、拖拽过程只在开始/结束各序列化一次(不是每帧)。
4. 渲染管线里的三个细节
4.1 最小描边宽度只属于编辑态
缩放 20% 时,1.5 px 的线细到看不见。所以编辑态有一个下限:
js
function strokeWidth(w, zoom, exported) {
if (exported) return Math.max(w || 0, 0.01); // 导出:真实世界线宽
return Math.max(w || 0, 1 / (zoom || 1)); // 编辑:至少 1 屏幕像素
}
这个下限如果照搬进导出,就会出事:2x / 3x 的 PNG 是靠 zoom = 2 / 3 放大实现的,同一条世界线宽会被不同的下限夹住,导致倍率越高线越细 。所以 opts.exported 一开,下限直接失效。
4.2 字体回退链:中文不能排在前面
js
return `${bold}${size}px '${family}', Arial, Helvetica, 'Microsoft YaHei', sans-serif`;
把 Microsoft YaHei 提到前面,拉丁字母的字距会明显变宽,文本行宽和折行位置就跟 Visio 差一截。所以顺序是"原字体 → Arial → Helvetica → 雅黑",让拉丁字优先落到西文字体,缺字才落到 CJK。
4.3 别让一个不存在的方法带走整帧
ctx.roundRect 在老版本 WebView2 里没有,而直接调用不存在的绘制方法是抛异常 ------异常发生在 drawScene 中间,意味着"这一帧后面的形状全部不画"。表现出来就是"画着画着有些形状消失了",非常难查。
现在所有这种地方都是特性探测 + 手写兜底路径(arcTo 拼圆角)。同类教训还有一例:拖动连接线端点后整条线消失------根因是布线返回了空值,moveTo(undefined, NaN) 抛异常中断了整帧。
5. VSDX:以 Visio 兼容为壳,装自己的语义
.vsdx 本质是 OPC(一个 zip)+ VisioML。要"Visio 能打开我存的、我能打开 Visio 存的",绕不开四件事:
5.1 坐标系要翻两次
Visio 原点在左下、Y 向上 ,PinX/PinY 是形状中心点而不是左上角,单位是英寸。内部模型是左上原点、Y 向下、单位 px(96 dpi)。于是写入时:
text
PinX = (x + w/2) / 96 PinY = (pageH - y - h/2) / 96
读取时反过来,还要处理 LocPin 带来的局部坐标换算。这是最容易写错、也最容易"看起来对但旋转 90° 后全错"的一段,我给它配了专门的 round-trip 断言。
5.2 真实 Visio 文件的三道坎
- 样式表继承 :大量形状的
FillForegnd值是F="Inh",真实颜色在StyleSheets里,还要按FillStyle/LineStyle/TextStyle三组分别继承。 - 母版几何合并 :
Masters/Master里的几何是局部坐标,实例只给PinX/PinY/Width/Height;不合并就只剩空框。 - 1-D 连接线 :连接线不是矩形,是
BeginX/BeginY + EndX/EndY加上页面级Connects/Connect。
还有圆弧:ArcTo 的 A 是弓高 ,EllipticalArcTo 的 A/B 是弧上一点。我的解法是"起点 + 弧上一点 + 终点 → 三点定圆 → 按 15° 采样成折线"。圆角矩形和流程图的胶囊形(Visio 的"开始/结束"框)全靠它才不变直角。
5.3 自己的语义放哪:User 段
Visio 的 Shape/PageSheet 允许挂自定义单元格(User.xxx)。于是"Visioxure 私有语义"全部住在这里:
text
User.VXId 形状 id(跨软件维持引用关系)
User.VXGroup 组合链(因为写出去的是展开后的独立形状)
User.VXGlue 连接线粘附的端点
User.VXRoute 手写折线
User.VXInteractions / VXPanel 交互与动态面板状态
User.VXVars / VXGlobalVars 页面变量 / 全局变量
User.VXNotes / VXLinks / VXScripts 注释 / 链接 / 脚本
User.VXGrid 页面网格样式(小 / 大 / 网点 / 无)
关键细节:除了值 V 还要写公式 F 。Visio 只认公式,纯 V 的自定义单元格在 Visio 里另存一次就会被清成 0。所以文本/JSON 类一律写带引号的字符串公式(内部引号双写),数字类写裸数字。
这条边界的承诺我写进了文档:不承诺母版与主题的写回保真(那是无底洞),只承诺互开不炸、私有数据往返不丢。
5.4 两个把我坑惨的几何语义
坑一:子路径开合不能从"有没有填充"推断。
渲染器曾经用"有填充就闭合"来决定路径,于是形状库里那批无填充的多边形 (三角、梯形、菱形、箭头)全缺一条边。根因是把两个正交的语义绑在一起了。现在的模型里显式声明 path.closed[],缺省闭合;.vsdx 读写同步保留该语义,并用像素探针双向守住。
坑二:局部坐标的图形必须锚在"设计尺寸"上。
菱形/三角/梯形/布尔结果的几何存在 path.subpaths(局部坐标)。拖动下边手柄只改包围盒 h,图形照原样画------表现成"选框变低了但图形没变"。修法是把局部坐标归到 path.design = {w, h},缩放系数只由 Geometry.pathScale 一处提供,渲染、命中、多边形化、writer 四个消费端都调它,谁也不许自己算。
6. 导出:四种产物,一套渲染
导出是这套架构收益最明显的地方。四种产物的生成都在前端,因为只有前端有画布和渲染器:
| 产物 | 做法 |
|---|---|
| PNG | 离屏 canvas 按 scale 放大 → 调同一个 drawScene → toDataURL |
| SVG | 同一套几何与样式字段,输出 <path> / <text><tspan>,圆角/虚线/箭头逐项对齐画布 |
极简 PDF writer:每页渲成 JPEG → DCTDecode 嵌入,1 px = 0.75 pt |
|
| HTML | 文档 JSON + renderer.js + interact.js + 一个小外壳,全部内联成单文件 |
落盘链路刻意做得"笨":
js
前端产物(base64 或文本)
→ file.save_dialog 只问路径
→ export.write 解一次 base64 写盘,不关心格式
→ 没有 Bridge(浏览器直开 / E2E)时退化成 <a download>
<a download> 那条退化路径不是为了用户,是为了测试:E2E 在静态服务里跑,能直接拿到产物内容做像素断言。
一个真实 bug:导出的页面为什么不是白的
症状很典型:编辑器里页面是白的,导出的 HTML 整页发灰。
根因在渲染器:
js
// 旧代码:exported 一开,页面底色和「编辑器装饰」一起跳过
if (!(opts && opts.exported)) {
shadow(); fillStyle = page.background; fillRect(...); // 底色 + 阴影 + 描边
}
而 HTML 运行时给 drawScene 传的正是 exported: true(因为要去掉阴影和边框),于是页面底色也没画 ,露出了运行时预铺的舞台灰 #e9ebee。
修法不是再加一层 if,而是把三件事拆成正交开关:
exported:关掉阴影、页面描边、最小线宽下限;paintBg:是否铺页面底色(默认跟随exported,HTML 运行时显式传true);grid:是否画网格(导出永远false)。
并在 E2E 里补了一条断言:导出 HTML 的空白区域,每个通道都要 ≥ 250。这样下次谁再"顺手"跳过底色,测试会先发现,而不是等用户说"颜色不对"。
7. 测试:三层,越靠下越便宜
| 层 | 工具 | 跑一次 | 守什么 |
|---|---|---|---|
| C++ 单元 | vsdx_test + ctest |
秒级 | JSON → VSDX → JSON 逐字段 round-trip(特殊字符、透明填充、图层、变量、网格、错误输入) |
| 纯逻辑 | headless Node + vm |
秒级 | 模型 / 渲染调用序列 / 布线,用假 CanvasRenderingContext2D 录调用 |
| 端到端 | Playwright(复用本机 Edge,不下载内核) | 十几秒 | 88 项主干断言 + 65 项导出断言,含像素探针 |
几个实践:
- 中间那层最被低估 。这一轮做"页面网格四档",我先写了一个 14 条的 Node harness:直接
vm.runInThisContext加载model.js和renderer.js,用一个记录型 2D context 断言"大网格描了两种颜色的线、网点只 fill 不 stroke、grid:false时一条线都不画"。不用起浏览器,改一行跑一次。 - 像素断言比截图比对便宜 。"这个位置到底画没画出来"用
getImageData采样就够了,不需要视觉回归基线;check_stencils.js/check_path_resize.js都是这么守住几何语义的。 - 截图只用来给人看。CI 里截图比对会因为字体版本抖动,而像素探针只问一个确定的问题:这个坐标是不是墨色。
8. 还没做的(技术债清单)
- 大文档性能:现在是单 canvas + dirty 全量重绘,没有静态层离屏缓存、没有空间索引,500+ 形状没验过;
- 正交布线还是"固定引导段 + 曼哈顿折线",A* 避障未做;
- 图片资产(
Foreign+media)读写未做,模型里连assets[]都还没启用; - 中继器、组件库(母版/实例)未做;
- 参考线只存在内存里,保存后丢(
User.VXGuides排在 A5); - 只有一份亮色主题;关窗未保存确认(
app.close_confirm)还没接。
这些在 CODEPLAN.md 里按 S/A/B/C 排了优先级,每条都带验收标准------写下来比记在脑子里更不容易自欺。
9. 小结
如果只留三句话:
- 混合形态是划算的:原生壳给你文件、窗口、系统集成;Web 内核给你文本、输入法、图形栈。1.46 MB 换掉一个 Chromium,值。
- 事实源只能有一个:前端 JSON 是模型,后端只做编解码与 IO,两边就不会漂。
- 一致性是可以测的:同一份渲染器 + 正交开关 + 像素断言,把"导出看起来一样"变成"导出的每个像素都可验证"。
仓库:github.com/Emsoro/Visioxure / gitee.com/masonwu21/Visioxure。下一篇想写的是"怎么把 Visio 母版的局部几何合并进自己的模型",如果正好你也在这条路上,欢迎来对答案。