在 Astro 博客中集成 TikZ 支持
项目背景
这个博客基于 Astro 7,Markdown 处理链路已经用了:
- 自定义 remark / rehype 插件;
- Expressive Code 做代码高亮;
- KaTeX 渲染数学公式;
- Tailwind CSS 4 负责样式;
<ClientRouter />做页面过渡。
目标是在 Markdown 里写:
```tikz\draw (0,0) -- (2,1);```构建后直接得到矢量 SVG,而不是一张模糊的位图。
这篇文章记录的是它从最早那版一直迭代到现在所踩过的坑,以及最终形成的结构。
第一版:最简单的想法
最早的实现很直白:
- 写一个 remark 插件,遍历 Markdown AST;
- 找到
lang === 'tikz'的代码块; - 把 TikZ 代码写入临时
.tex; - 用
pdflatex编译成 PDF; - 用
pdftocairo转成 SVG; - 输出到
public/tikz-images/; - 在 Markdown 里替换成
<img src="/tikz-images/xxx.svg">。
核心流程类似:
TikZ 代码 → 写 .tex → pdflatex → PDF → pdftocairo -svg → public/tikz-images/xxx.svg → <img>这个方案能跑,结构也简单,但很快暴露出一连串问题。
遇到的第一个大坑:开发环境卡在 Syncing content
博客里有 31 个 TikZ 代码块。第一版在 remark 插件里并行调用编译,pnpm dev 启动时同时去跑几十个 TeX 进程,Tectonic 还会争抢共享缓存,最后卡在:
[content] Syncing content处理方式
做了两件事:
-
串行编译 用一个 Promise 锁保证同一时间只有一个 TeX 进程在跑:
let texLock: Promise<void> = Promise.resolve()async function withTexLock<T>(task: () => Promise<T>): Promise<T> {const previous = texLocklet release!: () => voidtexLock = new Promise<void>((resolve) => {release = resolve})await previoustry {return await task()} finally {release()}} -
固定缓存目录 给 Tectonic 设置项目内的
XDG_CACHE_HOME:env: {...process.env,XDG_CACHE_HOME: TECTONIC_CACHE_DIR,}
这样多个进程不会互相写坏缓存。
但即使串行,首次冷启动仍然要把 31 张图全部编译完,content sync 还是要等很久。
第二版:开发环境惰性渲染
后来把“开发”和“构建”彻底分开:
- 生产构建:仍然在构建时编译所有 TikZ,并直接内联成 SVG;
- 开发环境:content sync 只登记源码,不编译,浏览器请求图片时再按需编译。
开发模式下,remark 插件不再输出 <img>,而是输出一个占位容器:
<div class="tikz-container tikz-container--lazy" data-tikz-src="/_tikz/<hash>.svg"> <span class="tikz-placeholder" aria-hidden="true">正在渲染 TikZ…</span></div>同时新增一个 Vite 中间件:
/_tikz/<hash>.svg浏览器请求这个地址时,中间件读取 .tikz-cache-sources/<hash>.tikz,调用 tikzToSvg() 编译,再把 SVG 返回。
带来的变化:
pnpm dev几秒内就能 ready;- 图片按需编译,滚动到哪编译到哪;
- 编译失败时中间件返回一个错误 SVG,页面上能看到失败原因。
第三版:深色模式与 currentColor
最早开发环境用 <img>,生产环境内联 SVG。后来发现一个问题:
NOTE
<img>是一个独立文档,里面的currentColor不会继承博客页面的color。
结果就是深色模式下,SVG 里的黑色线条仍然是黑的,看不清。
处理方式
- 生产构建继续内联 SVG;
- 开发环境通过客户端脚本把
/_tikz/<hash>.svgfetch 回来,再innerHTML注入成内联 SVG; - 在
makeAdaptive()中把 SVG 里的纯黑替换成currentColor:
function makeAdaptive(svg: string): string { return svg .replace(/^<\?xml[^>]*\?>\s*/i, '') .replace(/rgb\(\s*0%?\s*,\s*0%?\s*,\s*0%?\s*\)/gi, 'currentColor') .replace(/#000000\b/gi, 'currentColor') .replace(/#000\b/gi, 'currentColor') .replace(/fill="black"/gi, 'fill="currentColor"') .replace(/stroke="black"/gi, 'stroke="currentColor"')}这样黑色线条会跟随 --c-ink,在深色模式下自动变成白色。
第四版:彩色填充的明度互补
currentColor 只解决了黑色。流程图里有大量彩色填充,例如 blue!10、orange!15。
深色模式下文字会变白,但浅色填充还是浅色,白字放在上面依然难读。
处理方式
不再用简单的 CSS filter,而是在构建时给每张 SVG 写入自己的 <style>:
- 扫描所有显式
fill/stroke颜色; - 转成 HSL;
- 保持 H、S 不变;
- 只把明度反转:
L' = 100 - L生成的规则类似:
<style data-tikz-dark-colors=""> [data-theme='dark'] #tikz-<hash> [fill="rgb(89.99%, 89.99%, 100%)] { fill: rgb(0, 0, 26); }</style>规则通过根节点 id tikz-<hash> 限定作用范围,所以不会影响其他图。
这样:
- 浅蓝、浅橙、浅红、浅绿会变成同色相的深色;
- 白色文字放在深色填充上重新变得清晰;
- 红、蓝曲线仍保持原来的色相。
第五版:多张 SVG 的 id 冲突
又遇到一个很隐蔽的问题:一篇文章里同时内联 30 多张 SVG 后,会出现:
- A/B/C 变成别的字;
- 流程图文字串图;
- 公式和标签重叠成乱码。
原因是 pdftocairo 生成的每张 SVG 都使用相同 id:
glyph-0-0clip-0...多张 SVG 内联到同一 HTML 后 id 会互相覆盖,后面的图引用 #glyph-0-0 时会命中前面图的字形定义。
处理方式
给每张 SVG 的所有 id 加上内容 hash 前缀:
function namespaceSvgIds(svg: string, hash: string): string { const prefix = `tikz-${hash}-` const ids = Array.from(svg.matchAll(/\bid="([^"]+)"/g), (match) => match[1]) let result = svg
for (const id of ids) { const escaped = escapeRegExp(id) result = result.replace(new RegExp(`\\bid="${escaped}"`, 'g'), `id="${prefix}${id}"`) result = result.replace(new RegExp(`#${escaped}(?![\\w-])`, 'g'), `#${prefix}${id}`) }
return result}这样每张图的 <defs>、clip-path、url(#...) 都不会串。
第六版:中文与 TeX 引擎
中文 TikZ 又引出一组问题。
1. ctex 只在需要时加载
含中文的图需要 ctex,但纯英文图加载它只会拖慢编译。现在会先判断代码里是否真的有需要排版的中文:
function containsTypesetCJK(code: string): boolean { return code .split(/\r?\n/) .some((line) => CJK_RE.test(line.replace(/(^|[^\\])%.*$/, '$1')))}注释里的中文不算,只有真正会出现在图里的中文才触发 ctex。
2. 引擎优先级
pdflatex 处理 ctex 中文时会尝试生成 unisong 之类的位图字体,常见报错:
mktexpk: don't know how to create bitmap font for unisong89所以现在的引擎选择顺序是:
TIKZ_TEX_BIN → 项目本地 .tikz-bin/tectonic(.exe) → PATH 里的 tectonic → PATH 里的 xelatex → 非中文图才允许 pdflatex检测到中文但只有 pdflatex 时,会直接提示:
NOTE请运行
pnpm tikz:setup或bash scripts/get-tectonic.sh,也可以安装 tectonic / xelatex。
3. 不要只看文件存不存在
引擎文件存在不代表能执行。之前固定下载 Linux 版 tectonic,在 macOS / Windows 上会报:
spawn ENOEXEC现在用 canRun() 实际执行一次 --version 验证,不能运行的二进制会跳过;Windows 下会同时查找 .tikz-bin/tectonic.exe。
跨平台安装 tectonic
为了避免每台电脑手动配 MiKTeX / Poppler,项目里加了:
pnpm tikz:setup它实际上调用:
bash scripts/get-tectonic.sh脚本会根据 uname 自动选择:
- Linux x86_64 / aarch64;
- macOS x86_64 / arm64;
- Windows x86_64 / arm64。
Windows 下载 zip,用 unzip / python3 / powershell / pwsh 解压,最终放到:
.tikz-bin/tectonic# 或.tikz-bin/tectonic.exemacOS 上还需要 pdftocairo,可以通过 Homebrew 安装:
brew install poppler当前整体架构
现在完整的渲染链路如下:
Markdown 里的 ```tikz 代码块 │ ▼remarkTikz.ts ├─ 生产构建:立即调用 tikzToSvg(),内联 SVG └─ 开发环境:登记源码,输出 /_tikz/<hash>.svg 占位 │ ▼tikzToSvg.ts ├─ 计算内容 hash,查 .tikz-cache ├─ 选择 TeX 引擎(tectonic / xelatex / pdflatex) ├─ withTexLock 串行编译 ├─ pdftocairo 转 SVG ├─ makeAdaptive:黑色转 currentColor ├─ namespaceSvgIds:按 hash 隔离 id └─ addDarkModeColors:写入深色模式明度互补规则 │ ▼生产构建:<div class="tikz-container">内联 SVG</div>开发环境:/_tikz/<hash>.svg → Vite 中间件按需编译文件职责
| 文件 | 作用 |
|---|---|
src/plugins/remarkTikz.ts | 遍历 Markdown AST,替换 tikz 代码块 |
src/utils/tikzToSvg.ts | 包装 LaTeX、选择引擎、编译、缓存、SVG 后处理 |
src/plugins/tikzDevServer.ts | 开发服务器 /_tikz/<hash>.svg 按需编译中间件 |
scripts/get-tectonic.sh | 跨平台下载 tectonic |
package.json | 提供 pnpm tikz:setup 脚本 |
src/styles/global.css | .tikz-container 和 SVG 自适应样式 |
src/content.config.ts | 内容缓存版本注释,渲染器改动后可清空旧内容缓存 |
关键实现细节
1. 代码包装
wrapTikZ() 会处理两种情况:
- 代码块已经包含
\begin{tikzpicture}:直接使用; - 只有
\draw/\node片段:自动补一层tikzpicture。
模板大致是:
\documentclass[tikz,border=2pt]{standalone}\usepackage{tikz}\usepackage{amsmath}\usepackage{amssymb}% 含中文时才加:\usepackage[UTF8]{ctex}\usetikzlibrary{arrows.meta, positioning, shapes, calc, decorations.pathreplacing}\begin{document}...\end{document}border=2pt 让 standalone 自动裁掉白边。
2. 缓存
缓存 key 不是单纯的代码 MD5,而是:
createHash('md5') .update(`${CACHE_VERSION}\n${tikzCode}`) .digest('hex')CACHE_VERSION 目前是 v6。只要包装方式、后处理规则变化,就递增版本,旧 SVG 自动失效。
缓存目录:
.tikz-cache/ # 编译好的 SVG 和临时文件.tikz-cache-sources/ # 开发环境登记的原始终 TikZ 源码.tikz-cache-home/ # Tectonic 的 XDG_CACHE_HOME.tikz-bin/ # pnpm tikz:setup 下载的本地引擎这些目录都在 .gitignore 里,不会进仓库。
3. 编译失败回退
生产构建里如果某一幅图失败,不会让整站构建崩掉,而是输出:
<div class="tikz-error"> <pre><code>原始 TikZ 代码</code></pre> <p class="error-message">TikZ 渲染失败: ...</p></div>这样其他页面和其他图不受影响,也方便定位问题。
开发环境下,中间件会返回一个错误 SVG,页面上会直接显示失败原因。
4. 样式自适应
小图不要缩得太小,大图不能溢出正文容器:
.markdown .tikz-container svg { width: auto; min-width: min(100%, 12rem); max-width: 100%; height: auto;}故障排查
spawn ENOEXEC
通常是平台不匹配的 tectonic,比如在 macOS / Windows 上跑了 Linux 二进制。
处理:
rm -rf .tikz-binpnpm tikz:setupmktexpk: ... unisong89
说明当前用的是 pdflatex 编译中文图,而 ctex 需要 Unicode 引擎。
处理:
pnpm tikz:setup或者安装 tectonic / xelatex。
开发环境一直显示“正在渲染 TikZ…”
检查:
.tikz-cache-sources/<hash>.tikz是否已生成;/_tikz/<hash>.svg请求是否成功;- 浏览器控制台里 Vite 中间件是否返回错误 SVG;
- 本地 TeX 引擎是否可用。
深色模式下填充和文字对比度不对
当前颜色规则是通过 addDarkModeColors() 在构建时写进 SVG 的。如果新增了特别的颜色写法,可以检查:
- 是否解析成了
fill/stroke; - 是否被
none/currentColor/url(...)条件跳过; data-tikz-dark-colors规则是否生成。
多张图文字串图
确认 namespaceSvgIds() 是否对所有 id 和 #id 引用都加了 tikz-<hash>- 前缀。
当前限制
- 首次冷启动 Tectonic 需要下载资源包,比较慢;
- 每个新图第一次编译都要等 TeX;
- 开发环境按需编译,滚动时会有短暂占位;
pdftocairo是额外依赖,macOS 需要 Homebrew 安装 poppler;- 构建缓存在本地,GitHub Actions 每次都是全新环境时,第一张图仍要重新下载;
- SVG 后处理目前只处理
fill/stroke,更复杂的颜色写法可以按需扩展。
最终使用方式
在 Markdown 里直接写:
```tikz\node (X) at (-2,0) {$x$};\node (P) at (0, 0) {Progress};\node (Y) at (2, 0) {$y$};\path[->] (X) edge (P);\path[->] (P) edge (Y);```渲染效果:
总结
现在的 TikZ 渲染不再是最开始那个“remark 插件 + <img>”的简单方案,而是一套完整的构建期流水线:
- 开发环境:登记源码、按需编译,不阻塞
pnpm dev; - 生产构建:构建时编译、内联 SVG,避免额外请求;
- 主题适配:黑色转
currentColor,彩色按 HSL 明度互补; - 多图安全:按内容 hash 隔离 SVG id;
- 中文可用:按需
ctex,优先 Unicode 引擎; - 跨平台:
pnpm tikz:setup自动下载对应平台的 tectonic。
这套方案对个人博客来说维护成本不高,同时保留了 TikZ 最核心的矢量渲染能力。