Skip to content

嵌入 jsfcstm 可视化 (SVG/PNG):mini-racer + WASM 栅格化 #89

Description

@HansBug

2026-08-01 合并后权威状态

PR-D #415 已于 2026-08-01T03:43:41Z 合入
dev/python-diagram-umbrella,实现 head 3e19c1d2,merge commit 1174eec3b9e1。合并时 47 项检查 success、0 failure、1 skipping。

至此伞 PR #383 下四个 sub PR 全部完成并合入 umbrella,计划中没有 PR-E: PR-A #384、PR-B #386(merge aa745161)、PR-C #389(merge 46660c4c)、PR-D #415(merge 1174eec3)。

本 issue 要的「嵌入 jsfcstm 可视化(SVG/PNG)」能力现已可用:

from pyfcstm.model import load_state_machine_from_text

view = load_state_machine_from_text(
    "state Root { state Idle; state Busy; [*] -> Idle; "
    "Idle -> Busy :: Start; Busy -> Idle :: Done; }"
).diagram()

svg = view.to_svg()             # str,   展开形式,自足不依赖字体
png = view.to_png(scale=2.0)    # bytes, 光栅
pdf = view.to_pdf()             # bytes, 单页矢量

真实运行:

to_svg()   18598 字节  <text>=0 <marker>=0 font-family=0 <script>=0 <path>=24
to_png()   scale=1 -> 367x396 / 11706B   scale=2 -> 734x792 / 27916B   scale=3 -> 1101x1188 / 46392B
to_pdf()   14559 字节  页数=1  位图对象=0  MediaBox=0 0 367. 396.
CLI        json 2290B/745ms  html 29406293B/1330ms  svg 18598B/3090ms  png 11706B/3124ms  pdf 14559B/3167ms

SVG 的 <text>=0 是合同而非巧合:不展开的 SVG 里文字仍是 <text> + font-family,收件人机器缺字体时排版就崩;展开后字形是路径,文件自足。PDF 的 位图对象=0 同理,是真矢量而非把图片塞进 PDF 壳。

超出尺寸时是拒绝而不是崩溃或悄悄裁切,且限额在 WASM 之前检查——让渲染器 OOM 不是"拒绝",用户拿到的会是段错误而不是一句能读懂的话:

产品限额: 单边 <= 16384 px, 像素 <= 16777216
200 状态 scale=1 -> 296x15800 通过;scale=2 -> 拒绝 DiagramRenderLimitError limit_name=edge

新增的 DiagramRenderLimitError 刻意不继承 DiagramRenderError:它的含义是"把 scale 调小就行",而后者意味着"重试没用"。

本 issue 继续保持 open。 伞 PR 尚未合入 main,剩余门禁:同步最新 main、full unit/jsfcstm/VSCode/geometry/visual/package/docs/installed-wheel/PyInstaller gates 全绿、记录同步、issue #418 关闭、VSCode webview 展开宿主能力决策(实测 resvg 2,478,606 字节 + 字体 17,700,092 单语言 / 59,403,012 全部 13 种)、最终独立 review C=0 / I=0、维护者人工批准。

本段取代下方把 PR-D 描述为待实施、以及把同步三格式导出/Notebook/完整 CLI 描述为未交付的旧状态;旧 POC、hash 与调研材料继续保留作历史考据。

2026-07-30 合并后权威状态

PR-C #389 已于 2026-07-30T15:20:37Z 合入
dev/python-diagram-umbrella,实现 head 8a4f465611a832bd1422213a041523df4fb1e79d,merge commit 46660c4c9226c211c661349bf2e81f18fdeea50a。合并前 48 项检查通过、0 跳过。

本次交付的是公共 Python 可视化接口与浏览器侧对比视图:

  • StateMachine.diagram() 返回不可变的 Diagram 快照,to_dict / to_json / to_html /
    save / show / with_options / with_view_state 构成公共面;
  • self-contained、zero-network 的 HTML viewer,含 fit/zoom/pan/reset 与浏览器侧
    SVG/PNG/单页矢量 PDF 下载(实测 PDF images: 0pages: 1);
  • pyfcstm diagram -i ... -o ....json|....html--open
  • content-addressed viewer cache,按每用户 0700 目录隔离,用前以 lstat 复核;
  • 双语 Tutorial / How-to / Reference / Explanation 四类文档。

本 issue 继续保持 open。 本 issue 标题所指的同步 SVG/PNG 栅格化能力仍未交付:
Diagram.to_svg() / to_png() / to_pdf() / save() 这条同步导出 facade、CLI 的
-o ....svg|....png|....pdf、Notebook static SVG、installed-wheel 与 PyInstaller onefile
闭环,全部属于 PR-D。伞 PR #383 也仍不可合入
main:除 PR-D 之外还差同步最新 main、installed-wheel/PyInstaller 门禁、记录同步、
最终独立 review 与维护者批准。

本段取代下方把 PR-C 描述为待实施或待合入的旧状态;旧 POC、hash 与调研材料继续保留作历史考据。
需要特别注意:下方历史 POC 区里的 pyfcstm visualize CLI 与
pyfcstm.visualize.render_svg/render_png 接口已被伞 PR 合同取代,现行接口是
pyfcstm diagramStateMachine.diagram()

2026-07-20 合并后权威状态

PR-B #386 已于 2026-07-20T12:45:18Z 合入 dev/python-diagram-umbrella,实现 head b78ae178b7649aea48b8a7f313642e1caf863f08,merge commit aa745161980799a4ba9e56e3654040339f7faa98。merge 前 60 项检查通过,1 项设计内跳过,template-suite-gate 通过,最终 review 为 APPROVE / READY / CLEAR。

这只关闭 official resvg asset/runtime foundation:公共 StateMachine.diagram()/show()、Diagram facade、HTML viewer/cache、Notebook、完整 CLI、PDF 与最终 headless delivery 仍由 PR-C/PR-D 交付。因此本 issue 继续保持 open;当前不能因 PR-B 合并而关闭。证据:https://gist.github.com/HansBug/b43d202ccc9436cada8bc57fa3763450。

本段取代下方把 PR-B 描述为 pending 或 ready-to-merge 的旧状态;旧 POC、hash 与调研材料继续保留作历史考据。

PR-B pre-merge state (historical) (b78ae178, 2026-07-20)

This section supersedes conflicting current-state text below that refers to old PR-B
heads, request-only/raw-SVG rejection, fixed output limits, production strict validators,
or DiagramRenderLimitError. Older sections remain historical research material.

The current runtime uses official @resvg/resvg-wasm@2.6.2, default timeout=None,
finite-positive scale without production output caps, minimal production SVG/PNG checks,
and strict maintenance/CI visual validators. DiagramData remains the normal input;
canonical SVG compatibility input is allowed but is not a general-purpose sanitizer.

PR-B now also handles real DTD/entity declarations without rejecting legal comment/CDATA,
rejects non-mapping/non-finite JSON requests at the Python boundary, redacts evaluated
source from MiniRacer errors, and contains the Python 3.7 legacy timeout+malformed-JS
native abort as a loud asset error.

The issue remains open. PR-B only completes the asset/runtime foundation;
StateMachine.diagram/show, public export APIs, HTML viewer/cache, Notebook, CLI, PDF,
and final delivery remain PR-C/PR-D work.

PR-B final current state (09ce4b9, 2026-07-20)

本段是 issue 当前权威状态,旧 POC 和历史 hash 保留为考据。

当前状态 (historical, 2026-07-20)

PR-A 已通过并合入伞 PR 分支,见 PR #384。PR-B #386 已将 official @resvg/resvg-wasm@2.6.2、closed SVG、PNG 结构与非空 ink、scale/尺寸限制、CJK、reference parity、token-bound memory、provenance 和 package 门禁实现并推送到 umbrella,当前 head 为 98fd2158,正在等待最终 CI/review。当前 issue 继续保持 open:公共 StateMachine.diagram/show、导出 facade、HTML viewer/cache 与整体 P0 仍由 PR-C/D 交付。

当前已确定的实现边界

  • 资源路径为 pyfcstm/diagram/assets/,由 make build_assets 唯一生成并随 wheel/sdist 发布;旧 pyfcstm/assetspyfcstm/diagram_runtime 路径不再有效。
  • Python 运行时兼容 modern mini-racer 与 Python 3.7 legacy py-mini-racer,CJK 资源覆盖 SC/TC/HK/JP/KR 五地区 Regular/Bold OTF。
  • 资源缺失、非法 JS/WASM/OpenType、初始化失败、非法 PNG 或 expanded SVG 会 loud fail;开发 checkout 提示 make build_assets,已安装包提示向 pyfcstm issues 报告。
  • PR fix(visualization): make nested A -> B arrow terminals normal to target border #377 的箭头目标边框法向接入、orient=autorefX=10 和 18px 终段已由共享 renderer 继承。

PR-A 的历史远端 required checks 为 57 SUCCESS、1 SKIPPED、0 failed;PR-A 的 490565a5 保留为历史资产基线;PR-B 当前 base 为 bcadb92a、head 为 98fd2158,本地 test_assets.py 为 60 passed,完整慢测子集为 46830 passed。当前实现还增加了完整 PNG 结构校验(包括非法 scanline filter 拒绝)、固定 13-face 字体集合门禁、受控资源 ASCII/English 门禁(包括 .gitignore),以及物理缺失 packaged resource 的明确错误分类。完整证据见 CJK/runtime verification Gistasset fail-loud reportindependent visual audit

下方原始 POC 方案保留作历史考据。它包含旧的 pyfcstm/jsruntime 路径、旧 visualize API、直接进 git 的资源方案以及“PDF 留单独 issue”等当时假设;这些内容已被 PR-A/伞 PR 当前合同取代,不应视为现行接口或资源纪律。


背景

pyfcstm 现在能产出 PlantUML / Mermaid 源码,但最终成图全靠下游渲染器,质量受限。VSCode 扩展那边已经证明可以做得好得多——ELK 层次化布局、palette 主题、可点击回跳源码——但这套实现完全在 editors/jsfcstm(TypeScript)和 editors/vscode(Vue webview)里,Python 包用不上。

目标:让 pyfcstm 用户不离开 Python 就能拿到同等质量的图pip install pyfcstm[viz] 之后用一个 CLI 子命令或一行 Python API 把 state_machine.fcstm 渲染成 SVG / PNG。

为什么必须套一个 JS 沙箱

jsfcstm 真正不可替代的部分是 elkjs 对复合状态图的层次化布局——Python 生态没有任何近似实现。把 ELK 在 Python 里重写一遍要面对几万行 Java 算法(GWT 自动转译成的 JS),不现实。务实做法:直接嵌一个 JS 沙箱,把 jsfcstm + elkjs 装进去用。

方案(POC 已跑通)

阶段 实现 备注
DSL → SVG jsfcstm 全链路(parser + diagram + ELK)打成 IIFE 走公开导出:getParser → buildAstFromTree → buildStateMachineModel → buildFcstmDiagramFromStateMachine → buildFcstmElkGraph → elk.layout → renderFcstmDiagramSvg
SVG → PNG @resvg/resvg-wasm + 内嵌 OFL 等宽字体 全程 in-process,跑在 mini-racer 的 V8 + WASM 里,不引入任何 native 依赖
JS 宿主 mini-racer(py≥3.8)/ py-mini-racer(py<3.8) 用一个 JsEngine 适配层做版本分流

JS bundle、WASM 二进制、字体资产直接进 git(与现有 pyfcstm/template/*.zip 的"预生成资产"性质相同)。终端用户不需要装 npm 也不需要装 Node

POC 阶段踩出来的硬事实(写下来防止以后再踩)

  1. mini-racer 0.14 的 V8 自带 WebAssembly 支持。
  2. mini-racer 不带 TextEncoder / TextDecoder,需要自己写一个 ≤ 50 行的 UTF-8 polyfill。
  3. elkjs 的 worker 模块按 typeof self 分流。必须注入 globalThis.window = globalThis.global = globalThis但不能注入 self,否则 elk-worker.min.js 会安装一个 web-worker 监听器而不是导出 Worker 类。
  4. elkjs 的 PromisedWorker 会调 setTimeout(fn, 0),加载 bundle 前要先注入一个同步桩。
  5. esbuild 必须加 --keep-names,否则 ANTLR4 生成的 Constructor.name 字符串匹配(Def_assignmentContext 之类)在 minify 后全部失效,AST 会静默地构造出一个 rootState 缺失的空文档,buildStateMachineModel 直接返回 null
  6. esbuild --platform=neutral 默认不读 package.jsonmain 字段,要显式 --main-fields=main,module
  7. jsfcstmworkspace/editor/ 子模块为了跨文件 import 引用了 fs/path/node:url。单文件渲染路径用不到,bundle 时把这些 alias 成空模块即可。
  8. renderFcstmDiagramSvg 返回的是 string 不是 {svg,width,height}(webview 自己有一份重新实现的 renderer 才返回那个对象,那个不是 jsfcstm 的公开 API)。
  9. resvg-wasminitWasm 接受 WebAssembly.Module 直接初始化,不需要 fetch。
  10. resvg 在沙箱里看不到任何系统字体,必须自带一份 fallback 等宽字体——选 JetBrainsMono-Regular(OFL,~210 KB)正合适,因为 SVG 默认 font-family 第一项就是 JetBrains Mono

这个 PR 为什么做 PDF

VSCode 扩展现有的"PDF 导出"实际上是 4× 栅格 PNG 用 jsPDF 包了一页不是矢量 PDF——见 Stage.vue 第 410 行附近的注释:作者主动放弃了 SVG → 矢量 PDF 路径,理由是任何 in-process 转换器都没法忠实处理浏览器级别的 SVG(CSS 级联 / webfont / 滤镜 / foreignObject)。要做真矢量 PDF 得引入 svg2pdf.js + @xmldom/xmldom,是一套独立的工程量、有自己的坑。为了让首个 PR 可控可发布,本 PR 只做 SVG + PNG,PDF 留单独 issue

对外接口

CLI:

pyfcstm visualize -i input.fcstm -o output.svg
pyfcstm visualize -i input.fcstm -o output.png --scale 2
pyfcstm visualize -i input.fcstm -o output.png --direction DOWN --palette nord --mode dark

Python:

from pyfcstm.visualize import render_svg, render_png, VisualizeOptions

svg: str   = render_svg(dsl_text, VisualizeOptions(direction="DOWN"))
png: bytes = render_png(dsl_text, scale=2.0)

目录结构

pyfcstm/
├── jsruntime/
│   ├── __init__.py            # 公开 API
│   ├── engine.py              # JsEngine + MiniRacerEngine + PyMiniRacerEngine
│   ├── shim.js                # window/global/setTimeout/atob/btoa/TextEncoder/TextDecoder
│   ├── bundle.js              # jsfcstm + elkjs + resvg glue 的单文件 IIFE(预编译,进 git)
│   ├── resvg.wasm             # @resvg/resvg-wasm 的 WASM 二进制(预编译,进 git)
│   └── fonts/
│       └── JetBrainsMono-Regular.ttf   # OFL,~210 KB
├── visualize/
│   ├── __init__.py
│   ├── render.py              # render_svg / render_png
│   └── options.py             # VisualizeOptions dataclass
└── entry/
    └── visualize.py           # Click 子命令(在 dispatch 里注册)

构建工具:tools/package_jsbundle.py(esbuild 调度器,重新生成 pyfcstm/jsruntime/bundle.js);Makefilemake jsbundle / make jsbundle_clean

依赖

extras_require = {
    "viz": [
        "mini-racer>=0.12; python_version >= '3.8'",
        "py-mini-racer>=0.6.0,<0.7; python_version < '3.8'",
    ],
}

不引入任何新的 Python 端栅格化依赖(不用 cairosvg、不用 resvg-py、不用 Pillow、不用 img2pdf)。所有跟图像相关的事情都在 V8 + WASM 里完成。

兼容矩阵

  • Python 3.7 – 3.14(适配层负责 3.7 的分支)
  • Linux / macOS / Windows(mini-racer wheel 覆盖主流矩阵;WASM 跨平台字节级一致)
  • Win 7 + py 3.7 通过 py-mini-racer 0.6.x 尽力兼容,不进 CI,但适配层不阻挡

本 PR 的验收标准

  • pyfcstm visualize -i ... -o out.svg 输出一份合法 SVG,结构与 VSCode 预览一致
  • pyfcstm visualize -i ... -o out.png --scale 2 输出一份 2× PNG,文字字形正确(即字体喂进去了)
  • 新增代码 100% 行覆盖率
  • 现有测试套件全绿
  • 基础安装路径不引入新的 Python 运行时依赖;viz extras 显式 opt-in

不在本 PR 范围(后续 issue)

  • 矢量 PDF 导出(需要 svg2pdf.js + @xmldom/xmldom
  • 交互式预览窗口(需要 pywebview + webview 抽离成独立 build)
  • 用户自定义字体 buffer
  • DSL 编辑时的 watch / 热渲染

Metadata

Metadata

Assignees

Labels

area: packagingPackages, bootstrap/self-check, frozen artifacts, resources, and publishing.area: visualizationPlantUML, ELK, diagrams, and rendered images.kind: featureNew user-facing or developer-facing capability.status: completedWork was delivered and the issue is closed as completed.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions