TikZ 基础

TikZ 是住在 LaTeX 里的绘图语言,而它的名字本身就是一句警告。这是德语 TikZ ist kein Zeichenprogramm(“TikZ 不是绘图程序”)的递归缩写,一开始就说明白:你不会用鼠标去拖动图形。取而代之的是在与正文相同的文件里写下 \draw (0,0) -- (2,1);,图形便会以与周围段落相同的字体、相同的质量排版出来。这是一笔交易:舍弃顺手,换来精确与可复现。本页就顺着这笔交易走一遍,从画出第一个图形,到坐标与节点的思路,再到编译为什么变慢、又该怎么办。

为什么 TikZ 坚持自己“不是绘图程序”——TikZ 与 PGF

这个名字是 GNU 传统里的一句玩笑,而手册自己也这么说:TikZ 是承接“gnu’s Not Unix”那一脉的递归缩写,放在那里就是为了提醒读者该期待什么。用鼠标换来的,是把 TeX 的做法搬进图形之后的好处——精确的定位、借助宏的复用、与正文一致的字形处理。交出去的则是所见即所得。手册对其余代价也毫不掩饰,开头几页就列出了陡峭的学习曲线,以及每改一点就要重新编译的现实。

真正把线画出来的是底下的 PGF,取自 “portable graphics format”(可移植图形格式),这个名字来历很清楚。作者当初想写一套图形宏,让它在 pdfLaTeX 和传统的基于 PostScript 的 LaTeX 上同样可用;这就需要一层来吸收不同输出路径的差异——所以叫“可移植”。由于直接书写原始 PGF 相当费力,便在其上覆盖了一层人能读写的语法,那就是 TikZ。人们习惯把“PGF/TikZ”当成一个词,但实际敲进去的几乎总是 TikZ 这一侧,而 \usepackage{tikz} 会连同 PGF 一起加载。

这两者都出自 Till Tantau 之手。据他自己讲述,他在本科时开始摆弄 TeX,由此做出了演示文稿用的 beamer 文档类;后来为了博士论文里的插图,又写了 TikZ。LaTeX 做幻灯片的标准做法和画图的标准做法,看似毫不相干,却出自同一个人。还有一个数字能说明它早已不是当年那个论文小工具:3.1.10 版的手册 pgfmanual.pdf 长达 1321 页。而封面本身就是一幅 TikZ 图——暮色风景中的分形树与科赫雪花——并且把生成它的源代码就印在同一页上。

配置几乎不用操心。导言区写上一行 \usepackage{tikz},在 pdfLaTeX、LuaLaTeX 和 XeLaTeX 下就能直接工作。唯一需要额外照顾的是经过 DVI 的路线:使用 pLaTeX 或 upLaTeX 时,还要把驱动(通常是 dvipdfmx)写进文档类选项。下面是一个能编译通过的最小文档。

document.tex
\documentclass{article}   % with pLaTeX: \documentclass[dvipdfmx]{jsarticle}
\usepackage{tikz}
\begin{document}
A line and a circle:
\begin{tikzpicture}
  \draw (0,0) -- (2,1);
  \draw (2.8,0.5) circle [radius=0.5cm];
\end{tikzpicture}
\end{document}

画出第一个图形:tikzpicture 环境与那个非写不可的分号

所有图形都画在 \begin{tikzpicture}\end{tikzpicture} 之内,而且每一条绘图命令都要以分号 ; 结束。这个环境就是承载一幅图的画纸,环境名后方括号里的选项对整张画纸生效——[scale=2] 放大一倍,[thick] 让所有线条变粗。若图小到一行就够,也可以不开环境,直接用行内形式 \tikz{...},或只有单条命令时的 \tikz \draw ...;

latex
% inline: one command, no environment
\tikz \draw (0,0) -- (1.5,0);

% environment: one canvas, several commands, shared options
\begin{tikzpicture}[scale=1.2, thick]
  \draw (-1.5,0) -- (1.5,0);
  \draw (0,-1.5) -- (0,1.5);
\end{tikzpicture}

漏掉一个分号,就会看到 ! Package tikz Error: Giving up on this path. Did you forget a semicolon?。麻烦之处在于,报出的行号不是你写错的那一行,而是它的下一行。因为 TikZ 会一直吞入还可能属于该路径的记号,直到撞上根本不可能属于路径的东西——通常就是下一条 \draw——才终于放弃。所以当错误指向某条 \draw 时,该检查的是上一行的行尾

坐标与路径:\draw\fill--circle 怎么选

TikZ 的绘图围绕路径展开——路径就是“怎样从一个点走到另一个点”的说明——而如何显示这条路径,由命令名决定。\draw 描出线条,\fill 填充闭合路径的内部,\filldraw 先填充再描边,\path 则只定义路径而什么也不画。最后这个看似无用,其实正是用来悄悄放下之后要引用的坐标和节点。写同一串点、只换命令名,线框就变成实心块——这种正交性正是 TikZ 设计的骨架。

写一个点有三种方式。直角坐标 (x,y) 默认以厘米为单位,(1,2) 就是向右 1 cm、向上 2 cm,也可以像 (1cm,2pt) 那样写明单位。极坐标 (30:1cm) 读作“沿 30° 方向走 1 cm”。而命名坐标只需用 \coordinate (P) at (1,1); 放置一次,此后写 (P) 即可。相对坐标里 +++ 的区别最好趁早弄清:++(1,0) 向右走 1 cm 并把当前点一同带走,而 +(1,0) 到达同一位置却让当前点原地不动。连着写几个 ++ 得到一条折线,连着写几个 + 则得到从同一锚点发散出去的射线。

路线怎么走由路径操作决定。-- 是从上一点到下一点的直线;rectangle 取两个对角点;circle 取一个圆心;此外还有 ellipsearcgrid。曲线用 .. controls ..(Bézier)或 to[bend left] 画出;要闭合图形,就在末尾加 -- cycle 回到起点。形状参数如今写成方括号里的键值对:circle [radius=10pt]ellipse [x radius=20pt, y radius=10pt]arc [start angle=0, end angle=30, radius=3mm]grid [step=.5cm]。旧的圆括号写法 circle (10pt) 仍然有效,但新写的代码用方括号更好读,而且再加一个键也毫不费事。

latex
\begin{tikzpicture}
  \draw[step=.5cm, gray, very thin] (-1.4,-1.4) grid (1.4,1.4);
  \draw (0,0) circle [radius=1cm];
  \draw (0,0) -- (30:1cm);                       % polar: 1 cm at 30 degrees
  \fill[blue!20] (0,0) rectangle (0.5,0.5);
  \draw[red] (-1.2,-1.2) -- ++(0.6,0) -- ++(0,0.6);   % ++ carries the current point
\end{tikzpicture}

得到的是这样一幅图:底层是 0.5 cm 间距的浅灰网格;原点处有一个半径 1 cm 的圆;一条短线段从原点沿 30° 方向伸出;原点旁边是一个 0.5 cm 见方、填充浅蓝的正方形;左下角还有一段红色折线,先向右 0.6 cm,再向上 0.6 cm。颜色 blue!20 表示“20% 的蓝”,其余 80% 是白,所以偏淡。写成 red!50!black 这样指定两种颜色即可混色,这套记法对填充和描边同样适用。

节点:放下文字与方框,再按名字连起来

节点(node)把文字或方框放在指定坐标上,写法是 \node[options] (name) at (coordinate) {contents};。方括号里放选项:draw 画边框,circlerectangle 指定形状,fill=blue!20 指定填充,等等。整套写法的要点在于:圆括号里的名字成了这个节点的把手。此后写 (name) 就能引用它的位置,再加上锚点——(name.north)(name.east)——便能指名边界上的某一个点。

由此便有了最常用的套路:先放好带名字的节点,再把名字连起来。用节点名而不是原始坐标来连接,意味着日后把某个节点挪动一厘米,连线会自己跟上。而且线条会停在节点边界,不会插进圆或方框里。节点也可以落在路径途中:写 \draw (a) -- (b) node[midway, above] {$f$}; 就能把标签放在线段中点的正上方——这是给箭头命名的标准写法。

latex
\begin{tikzpicture}
  \node (a) at (0,0)   [draw, circle, fill=blue!20] {A};
  \node (b) at (2.5,0) [draw, circle, fill=blue!20] {B};
  \draw[->] (a) -- (b) node[midway, above] {$f$};
  \draw[dashed] (a.south) -- (b.south);   % .south is an anchor on the border
\end{tikzpicture}

这幅图会摆出两个填充浅蓝的圆形节点 “A” 和 “B”,相距 2.5 cm,从 A 到 B 画一条箭头并在其中点上方放上 $f$,再用虚线把两个圆的底端连起来。这里有一颗值得绕开的地雷:节点必须先定义、后引用。 若写了尚未放置的名字,或把已有的名字拼错,就会得到 ! Package pgf Error: No shape named ‘b’ is known.。名字很容易敲错,所以看到这条错误时,请检查拼写,并确认那条 \node 确实写在前面。

线条选项,以及用 \tikzset 复用样式

线条的样子由方括号里的选项决定:线宽 thin / thick / very thick,颜色如 redblue!50dasheddottedrounded corners,以及箭头 ->(末端)、<-(起端)、<->(两端)。箭头背后有一个务实的捷径:TikZ 会把任何含有 - 的未知选项都当作箭头规格。因此一个敲错的键有两种下场,取决于它的拼写。不含 - 时,TikZ 会告诉你:! Package pgfkeys Error: I do not know the key ‘/tikz/thikc’ and I am going to ignore it. Perhaps you misspelled it.;一旦其中夹了 -,这个笔误就会被读成箭头规格,可能悄无声息地变成奇怪的结果。

选项含义
thick / very thick加粗线条(一直到 ultra thick
red, fill=blue!50描边或填充色;!n 定浓度,red!50!black 混合两色
dashed / dotted虚线或点线
rounded corners把折线的拐角变圆
->, <-, <->末端、起端或两端的箭头
scale=2把该图(或路径)放大 2 倍
node distance=1cm使用 positioning 相对放置时的默认间距

同样的装饰写到第三遍,就该把它做成样式了。用 \tikzset{name/.style={...}} 在导言区定义好,此后只要传一个名字;想改外观时,改定义里的一行就能改到全部。若只在某一幅图里有意义,也可以直接写在 tikzpicture 的选项里。还有一类样式作用于“所有某类元素”,例如 every node/.style={...};凡是像流程图那样需要所有节点保持一致的图,都少不了它。

latex
\tikzset{help lines/.style={color=blue!50, very thin}}

\begin{tikzpicture}
  \draw[help lines] (0,0) grid (3,2);
  \draw[thick, red, ->, rounded corners] (0,0) -- (1,2) -- (3,2);
\end{tikzpicture}

这会从 (0,0) 到 (3,2) 画出浅蓝网格,再画一条从原点折向 (1,2)、再折向 (3,2) 的红色粗线,拐角为圆角、末端带箭头。由于网格走的是样式,把 very thin 改成 thin,改变的就不只是这一张网格,而是所有用了 help lines 的网格。

到底该加载哪些 \usetikzlibrary

TikZ 的核心刻意保持精简,专门的部分被拆分成,在导言区用 \usetikzlibrary{...} 按需加载(多个用逗号分隔)。不加载,那套方便的写法就根本不存在:没有 positioning 却写 right=of a,就会直接撞上上一节那条“不认识这个键”的错误。真正在头一年里派得上用场的,大致就是下面这些。

  • arrows.meta — 丰富且可调的箭头集合(-{Stealth}-{Latex} 等)。手册已把旧的 arrowsarrows.spaced 标为弃用,新代码应当用这个。
  • positioning — 节点的相对放置:right=of abelow=1cm of b,默认间距由 node distance 决定。
  • calc — 坐标运算:($(a)+(1,0)$) 是“a 右侧 1 cm”,($(a)!0.5!(b)$) 是“a 与 b 的中点”。
  • shapes — 圆和矩形之外的节点形状(菱形、星形、标注框等),并细分为 shapes.geometric 等部分。
  • decorations — 把路径装饰成波浪、锯齿、花括号等(decorations.pathmorphing 及同类)。
  • patterns — 用剖面线、线条或点等图案填充。
  • fit — 自动生成刚好包住若干节点的盒子,便于把图的局部编组。
  • backgrounds — 在图形后方的图层上绘制边框或底色,永远不会和前景打架。
  • matrix — 把节点排成网格(矩阵)。graphdrawing 则做自动布局,但它需要 LuaTeX。

只要 arrows.meta 加上 positioning,就足以做出一张能用的流程图。诀窍在于只给第一个节点坐标,其余全部按关系摆放——“在那个的右边”“在那个的下面”。日后要插入一个节点,也不必回头重排坐标。

document.tex
\usepackage{tikz}
\usetikzlibrary{arrows.meta, positioning}

\begin{tikzpicture}[node distance=1cm, every node/.style={draw, rounded corners}]
  \node (start)                  {Start};
  \node (proc)  [right=of start] {Process};
  \node (end)   [right=of proc]  {End};
  \draw[-{Stealth}] (start) -- (proc);
  \draw[-{Stealth}] (proc)  -- (end);
\end{tikzpicture}

结果是一张从左到右的流程图:三个圆角方框写着 “Start”、“Process”、“End”,间隔 1 cm,用尖锐的 Stealth 箭头依次相连。every node/.style 一次性定下三者的外观,node distance=1cm 则定下 right=of 所用的间距。把这两项写进图的选项,正是让图日后容易修改的写法。

TikZ 编译为什么慢,以及何时该用 externalstandalone

慢的原因在于运算是 TeX 自己用宏做的。坐标相加、由角度换算出点、把 Bézier 曲线细分——统统跑在排版过程内部,而不是交给外部引擎。而且结果不会被缓存,所以每一条 \draw 都会在每次编译时从头重算。图只有几幅时你察觉不到;一旦堆上密集的 grid、点数很多的图表、含有几十个节点的图形,等待就明显起来了。手册自己在开头也承认了这一点,把“小改动伴随长时间重新编译”列为 TeX 式做法的代价之一。

标准对策是 external。加载 \usetikzlibrary{external} 并声明 \tikzexternalize,每个 tikzpicture 就会被单独写出一次成为 PDF 并缓存起来。文件名自动分配:主文件是 main.tex 时,得到 main-figure0main-figure1,依此类推。此后再次运行,只要某幅图的源码没变,就完全跳过计算,直接插入已生成的 PDF;一旦改动,它会自动重建。默认的 mode=convert with system call 会让 LaTeX 调用自己来生成图像,因此必须带上 -shell-escape 运行pdflatex -shell-escape main)。忘了这个选项就不会生成任何图,出问题时请先查这里。

main.tex
\usepackage{tikz}
\usetikzlibrary{external}
\tikzexternalize          % caches each picture as main-figure0.pdf, main-figure1.pdf, ...

% compile with:  pdflatex -shell-escape main

另一条路是 standalone 文档类。把图挪进独立的 .tex,用 \documentclass[tikz]{standalone} 单独编译,tikz 选项会加载 tikz 宏包,并生成一页按图形自然尺寸裁切的 PDF(若有多个 tikzpicture,就得到多页)。正文里只需写 \includegraphics{figure}。取舍的经验法则是:图和正文一起成长时用 external——代码留在正文旁边,你只额外得到缓存;图要与其他文档或幻灯片共用,或者需要反复调试几十次时用 standalone——一个几秒就能编译完的小文档,才是做试验更快的地方。

figure.tex
% compiles on its own to a one-page PDF, cropped to the picture
\documentclass[tikz]{standalone}
\usetikzlibrary{arrows.meta}
\begin{document}
\begin{tikzpicture}
  \draw[-{Stealth}, thick] (0,0) -- (2,1);
\end{tikzpicture}
\end{document}

% then in the main document:  \includegraphics{figure}