Skip to content

feat: render project fonts in editor - #3367

Open
nighca wants to merge 15 commits into
goplus:devfrom
nighca:issue-3366
Open

nighca wants to merge 15 commits into
goplus:devfrom
nighca:issue-3366

Conversation

@nighca

@nighca nighca commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Closes #3366.

Summary

  • provide project fonts and the fontPreferences array to the editor SVG rendering context;
  • apply project font semantics to SVG font-family references;
  • rasterize SVG image previews from project font buffers through Resvg/WASM;
  • fall back to the original SVG, with a diagnostic warning, when Resvg cannot render an SVG (for example, one without a valid intrinsic size);
  • update image-preview consumers to use the font-aware rendering path;
  • pin Resvg to nighca/resvg@840374d, our fork of current upstream main with the cluster-aware fallback patch from linebender/resvg#1087.

Fallback coverage

The fork retains the upstream compound/ZWJ emoji regression and adds a Builder-aligned regression: after a variation-selector/ZWJ emoji cluster, fallback continues to a later configured font. This protects the A❤️B … 👩‍💻 and ABC ❤️ 中文 class of project-font content.

Cost and local measurements

The renderer initializes lazily: it is first needed only when a font-aware SVG image is rendered, not during normal application startup. The figures below are from the local production build and a Node 24.17.0 benchmark on the development MacBook Air. They are implementation costs, not a claim about end-to-end browser or mobile-device latency.

Cost Measurement / behavior
Additional WASM request resvg_bg.wasm is 3.78 MiB uncompressed; 1.20 MiB gzip in the local production build (0.82 MiB when locally Brotli-compressed at quality 11). CDN compression, cache state, and device network determine actual transfer time.
WASM compile/instantiate 5.3–6.2 ms with the WASM binary already in the OS file cache. This excludes the network request and will vary by browser/device.
Loading project font buffers The ProjectFonts fixture contains 9 project faces totaling 22.81 MiB. The current renderer registers the whole project collection on first use, rather than only the faces referenced by a particular SVG; with lazy Files this can also cause all of those font resources to be fetched. The built-in default face adds another 58 KiB.
Font registration 5.5–10.9 ms for that 22.81 MiB collection in the local benchmark, after buffers have been read.
SVG parse/layout/rasterize For the 480×360 ProjectFonts backdrop: 25.5–26.5 ms on the first render, then 6.0–6.6 ms median across subsequent renders with the same renderer. Rendering is synchronous WASM work on the UI thread, so many uncached SVGs can affect editor responsiveness.
Raster output and memory That 2.5 KiB source SVG becomes a 14.3 KiB PNG blob. Its decoded 480×360 RGBA surface is about 675 KiB; larger SVG dimensions scale this memory quadratically. The per-context WeakMap avoids repeated rasterization of the same File, but each distinct image has this initial cost.

Other implications:

  • SVG previews now use a fixed-resolution PNG rather than a browser-rendered vector image, so large zoom/scaling can lose sharpness.
  • A renderer and its loaded font buffers are retained for the lifetime of a font context; changing project fonts/preferences creates a new context and later re-renders images.
  • These costs are deliberate trade-offs for avoiding the much worse per-SVG base64 font embedding cost. Before broad rollout, measure first-render latency and memory on representative lower-end devices and projects with many/large font families.

Verification

  • Resvg fork: complete usvg parser suite (including both fallback regressions).
  • Renderer: WASM target check, unit test, and release build.
  • Builder: lint, type check, 786 unit tests, and production build.
  • Visual comparison: compare the ProjectFonts project in the current deployment with the same project opened from this PR's preview deployment to observe the editor's project-font SVG rendering.
  • Browser: nighca/ProjectFonts renders the project backdrop with Chinese text and the final emoji/variation-selector cluster intact.
  • Image rendering: an invalid-size renderer error falls back once to the original SVG for all consumers of the same source file.

The PR remains a draft while we obtain final product review. The fork is pinned by immutable commit; when upstream #1087 merges, we should replace it with the corresponding upstream release or revision.

@gemini-code-assist

This comment was marked as off-topic.

fennoai[bot]

This comment was marked as resolved.

@nighca

This comment was marked as outdated.

- name: Build WASM
run: ./build-wasm.sh

- name: Run Vue TSC

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里调整顺序是因为 build (resvg) wasm 的过程会顺便生成用于调用 wasm 能力的 js & d.ts 文件,它们(尤其是 d.ts 文件)是前端代码静态检查的依赖

Comment thread tools/resvg-wasm/README.md Outdated

## Resvg dependency

The tool currently pins `nighca/resvg` at a specific commit. The fork carries the cluster-aware font-fallback patch from [linebender/resvg#1087](https://github.com/linebender/resvg/pull/1087), which is required for project-font sequences containing variation selectors or ZWJ emoji before a later fallback font. Once that patch ships in upstream Resvg, replace the fork with the corresponding upstream release or revision.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里是晨会提到过的,目前使用的 resvg 是我 fork 的版本,等上游解决 cluster-aware font-fallback 的问题后换成非 fork 版本

Comment thread spx-gui/README.md
- **Node.js**: ^24.11.1
- **pnpm**: ^11.9.0
- **Go**: >= 1.25.0
- **Rust**: >= 1.88 with the `wasm32-unknown-unknown` target

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

会要求 spx-gui 开发者本地有 rust 工具链

const injectedSvgText = await injectFontsToSvgText(svgText, [], new Map())
return new Blob([injectedSvgText ?? ab], { type: 'image/svg+xml' })
const svgTextWithFontPreferences = applyFontPreferences(svgText, this.config.fontPreferences)
const png = (await this.getRenderer()).render(svgTextWithFontPreferences, { maxSize: maxSvgRenderSize })

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里缩小 PNG 后,背景的逻辑尺寸也跟着变了。比如 2048×1024 的 SVG 转成 1024×512 后,StageViewer 和 MapViewer 的原尺寸、平铺模式都会把背景缩小一半。这里是符合预期的吗?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这不是预期的,清晰度下降可以接受,但应该保持图片尺寸不变的,我来处理下

Comment thread tools/resvg-wasm/src/lib.rs Outdated
}
}
}
default_select_fallback(character, used_fonts, fontdb)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

按字体文档的约定,指定列表都缺字后应该显示缺字符号;已经匹配到项目字体的情况下,这里应该直接返回 None?

当某个元素指定了自己的字体列表后,该列表覆盖继承到的全局 Font Preferences。解析规则如下:
1. 按列表顺序查找可用 Font Family
2. 只保留可用 Font Family
3. 使用保留下来的 Font Family 列表进行 fallback
4. 如果列表中没有任何可用 Font Family,则该元素没有可用字体,不再回退到 Project 全局 Font Preferences
因此,如果 SVG 写的是:
```text
font-family="A, B, sans-serif"
```
`A``B``sans-serif` 都不可用,那么这段文本不会继续使用 Project 全局 Font Preferences。
如果希望某段文本在指定字体不可用时仍回到 spx Default Font,需要显式写入 `default`
```text
font-family="A, B, default"
```
当所有可用字体都无法渲染某个 grapheme cluster 时,运行时显示缺字框。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

嗯是的这里逻辑有问题

还有一个回退记录覆盖的问题:同一个 <text> 中,两个 <tspan> 分别指定 default,Bdefault,C,因为主字体 ID 相同,第二次 insert 会覆盖第一次的记录,导致第一段缺字时也使用 C。

这也是个问题,是 resvg 的接口有缺陷;需要调整上游接口来支持

Comment thread tools/resvg-wasm/src/lib.rs Outdated
})
.collect::<Vec<_>>();
if let Some((id, fallback_ids)) = candidate_ids.split_first() {
fallbacks.lock().unwrap().insert(*id, fallback_ids.to_vec());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

还有一个回退记录覆盖的问题:同一个 <text> 中,两个 <tspan> 分别指定 default,Bdefault,C,因为主字体 ID 相同,第二次 insert 会覆盖第一次的记录,导致第一段缺字时也使用 C。

Comment on lines +17 to +18
if (initialization == null) initialization = init()
await initialization

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里初始化失败会一直保留rejected Promise,或许可以考虑用已有的 memoizeAsync 来缓存 init()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以考虑用已有的 memoizeAsync 来缓存 init()

嗯这样会好点

let tree = usvg::Tree::from_str(svg, &options).map_err(error)?;
let size = tree.size().to_int_size();
let (width, height, scale) =
render_size(size.width(), size.height(), render_options.max_size);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里的 maxSize 只限制最终 PNG 的尺寸,但 SVG 里的 <pattern> 还会单独生成一张图片,这张图片没有受到限制。

复现:一个 64×64 的 SVG,里面用了 4096×4096 的 pattern。最终 PNG 虽然只有 64×64,渲染过程中仍然会为这个 pattern 分配 4096 × 4096 × 4 = 64 MiB 的内存。

This branch was successfully deployed

1 active deployment
Preview – builder 02098f3e Deployed Sep 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render project fonts in editor

2 participants