在 Astro 博客中集成 TikZ 支持

· 更新于 2026年9月20日· 约 21 分钟· 4187 字

项目背景

这个博客基于 Astro 7,Markdown 处理链路已经用了:

  • 自定义 remark / rehype 插件;
  • Expressive Code 做代码高亮;
  • KaTeX 渲染数学公式;
  • Tailwind CSS 4 负责样式;
  • <ClientRouter /> 做页面过渡。

目标是在 Markdown 里写:

```tikz
\draw (0,0) -- (2,1);
```

构建后直接得到矢量 SVG,而不是一张模糊的位图。

这篇文章记录的是它从最早那版一直迭代到现在所踩过的坑,以及最终形成的结构。

第一版:最简单的想法

最早的实现很直白:

  1. 写一个 remark 插件,遍历 Markdown AST;
  2. 找到 lang === 'tikz' 的代码块;
  3. 把 TikZ 代码写入临时 .tex
  4. pdflatex 编译成 PDF;
  5. pdftocairo 转成 SVG;
  6. 输出到 public/tikz-images/
  7. 在 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

处理方式

做了两件事:

  1. 串行编译 用一个 Promise 锁保证同一时间只有一个 TeX 进程在跑:

    let texLock: Promise<void> = Promise.resolve()
    async function withTexLock<T>(task: () => Promise<T>): Promise<T> {
    const previous = texLock
    let release!: () => void
    texLock = new Promise<void>((resolve) => {
    release = resolve
    })
    await previous
    try {
    return await task()
    } finally {
    release()
    }
    }
  2. 固定缓存目录 给 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>.svg fetch 回来,再 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!10orange!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-0
clip-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-pathurl(#...) 都不会串。

第六版:中文与 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:setupbash scripts/get-tectonic.sh,也可以安装 tectonic / xelatex。

3. 不要只看文件存不存在

引擎文件存在不代表能执行。之前固定下载 Linux 版 tectonic,在 macOS / Windows 上会报:

spawn ENOEXEC

现在用 canRun() 实际执行一次 --version 验证,不能运行的二进制会跳过;Windows 下会同时查找 .tikz-bin/tectonic.exe

跨平台安装 tectonic

为了避免每台电脑手动配 MiKTeX / Poppler,项目里加了:

Terminal window
pnpm tikz:setup

它实际上调用:

Terminal window
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.exe

macOS 上还需要 pdftocairo,可以通过 Homebrew 安装:

Terminal window
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 二进制。

处理:

Terminal window
rm -rf .tikz-bin
pnpm tikz:setup

mktexpk: ... unisong89

说明当前用的是 pdflatex 编译中文图,而 ctex 需要 Unicode 引擎。

处理:

Terminal window
pnpm tikz:setup

或者安装 tectonic / xelatex

开发环境一直显示“正在渲染 TikZ…”

检查:

  1. .tikz-cache-sources/<hash>.tikz 是否已生成;
  2. /_tikz/<hash>.svg 请求是否成功;
  3. 浏览器控制台里 Vite 中间件是否返回错误 SVG;
  4. 本地 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 最核心的矢量渲染能力。