LaTeX 的表格绘制,大多数使用者都处于并将长期处于知其然不知其所以然的阶段。往往就是简单的表会画,稍微复杂一点,只能去网上搜怎么写,帖子倒是有很多,就是代码抄过来用不了。甚至费很大功夫,把一个个宏包英文文档看完,还是似懂非懂。要不就是用 table generator 之类的网站,机器生成的代码丑就不说了,太复杂的效果还是整不出来。所以啊,了解一点基本原理还是很有必要的。
关于 LaTeX 表格的大致介绍,可以先看
LaTeX下的表格处理 - 知乎
这篇神文。阿玲写的东西向来很好,如果没有阿玲这样高的水平,很难在写得简明易懂的同时不出知识性错误。文章写于2014年,8年间的改变是 tabu 宏包由于 LaTeX 内核的破坏性更新已经不能用了,其替代者 tabularray 可以解决表格绘制的绝大多数问题。这里是耿楠老师翻译的中文文档:
https://gitee.com/nwafu_nan/tabularray-doc-zh-cn
。前提是不能违背基本法,要是“第一行第一列的的单元格是第一行第二列宽度的50%,第一行第二列宽度又是第一行第一列的50%”这种现实中不可能实现的要求,那什么宏包都无法达成您的期望。
有很多离谱的要求,可能比上面提到的例子更隐蔽,其中几条要求分开都合理,合在一起就不可能做到。宏包代码无法判断各种要求组合是否合理(总不能弄出个停机问题),不可避免用户有的需求,排版失败,LaTeX 还往意识不到出错的真正原因,结果就是报错信息人类完全看不懂。所以进阶用户有必要去了解表格绘制的流程。否则像
tabularx如何用在合并单元格中
这样的问题,光是尝试基本上不太可能猜出解决方法。
绝大多数表格实现,如 LaTeX 默认提供的tabular 环境、longtable宏包的longtable环境、tabularx宏包的tabularx环境等等,基本原理都是依赖 TeX 的原始命令halign,这一类表格环境的问题是既有上层抽象的行列,又有底层的对齐处理机制,LaTeX 试图隐藏底层实现却没有做到完全隔离,导致结果常常不可控;与之相对的是tabularray 宏包,它的方案是先检查一遍表格内容,算好尺寸后拿段落盒子手动拼一个表格出来,各种效果都比较完美,代价是编译更慢以及一些兼容问题。
一、
\halign
的基本语法
\halign{-\hfil#-&-\hfil#\hfil-&-\fbox{#}\hfil-\cr
1 & 2 & 3\cr
Alpha & Beta & Gamma \cr}
\halign
的行以
\cr
结束,其中第一行叫做“模版”。
-
排版的时候,TeX 用每一栏的内容去依次替换模版里的
#
,于是上面的代码会变成如下形式
-\hfil 1-&-\hfil 2\hfil-&-\fbox{3}\hfil-\cr
-\hfil Alpha-&-\hfil Beta\hfil-&-\fbox{Gamma}\hfil-\cr
不引起歧义的情况下,本文通常用“栏”来表示
\halign
的各列,而用“列”来表示表格的各列。
于是
1
是表格第一行第一列的内容,而
-\hfil1-
是第一行第一栏的内容。
二、
tabular
环境
\begin{tabular}{|lcr|}
1 & 2 & 3\\
x & y & z\\
\end{tabular}
首先从对齐说明
lcr
生成
\halign
的模版。
-
左对齐
l
是
\hfil#
-
右对齐
r
是
#\hfil
-
居中对齐
c
是
\hfil#\hfil
-
p{2cm}
是
\parbox{2cm}{#}
-
|
是
\vrule width\arrayrulewidth
。
试在表格里用
\show\@preamble
查看模板的内容:
\everycr {}\tabskip \z@skip \halign \@halignto \bgroup
\relax \unhcopy \@arstrutbox
\hskip -.5\arrayrulewidth \vrule width\arrayrulewidth \hskip -.5\arrayrulewidth
\hskip \tabcolsep {\hskip 1sp\ignorespaces \@sharp \unskip \hfil }\hskip \tabcolsep
&\hskip \tabcolsep {\hfil \hskip 1sp\ignorespaces \@sharp \unskip \hfil }\hskip \tabcolsep
&\hskip \tabcolsep {\hfil \hskip 1sp\ignorespaces \@sharp \unskip }
\hskip \tabcolsep \hskip -.5\arrayrulewidth \vrule width\arrayrulewidth \hskip -.5\arrayrulewidth
\tabskip \z@skip \cr
解释一下,
-
\tabskip \z@skip
把
\tabkskip
设为0pt。
-
\@halignto
要么是空白,要么在
tabular*
环境中是
to <width>
,表示
\halign
整体的宽度。
-
\bgroup
就是一个左括号。
-
\@arstrutbox
是一个支撑盒子,用来撑起每行的高度,保证即使某行内容为空表格也有基本的高度。前面的
\relax
不用管,是一个展开技巧的结果,在这里没有什么用。
-
\arrayrulewidth
典型地是0.4pt,跟TeX默认的线宽相等。
-
\hskip -.5\arrayrulewidth \vrule width\arrayrulewidth \hskip -.5\arrayrulewidth
就是画竖线的代码,它使框线不占宽度。现在普遍认为这个做法不妥当,
array
宏包会把它改为
\vrule width\arrayrulewidth
,所以加载
array
宏包后线间距会变大一点。
-
\hskip\tabcolsep
用来生成列与列的粘连,LaTeX把
\tabskip
设为0而用
\tabcolsesp
来控制,否则横线会断开。
-
{\hskip 1sp\ignorespaces \@sharp \unskip \hfil }
里的
\@sharp
就是
#
,所以这就是
{#\hfil}
,表示左对齐。
-
-
\hskip 1sp
的1sp是一个极小极小的尺寸,可以视为0pt。其目的是如果用户在单元格开头写了
\unskip
,避免
\tabcolsep
被吃掉。曾经的实现就是0pt,改成1sp是为了配合LaTeX内部命令
\@bsphack
,
\@esphack
的判断。
-
最后还有一个
\tabskip \z@skip
。有的时候会在表格模板中修改
\tabskip
,而 TeX 会在第一栏左边和最后一栏右边插入制表粘连。这里的作用是保证前后的粘连都是0。
★★ 为什么模板里
{\hskip 1sp\ignorespaces \@sharp \unskip \hfil }
要用括号包起来?这是因为 LaTeX 的部分字体相关命令定义中含有
\aftergroup
,如果这样的命令出现在最后一行的最后一栏,就会将紧随
\aftergroup
的token插入到
\cr
后,导致
\halign
的内容没有以
\cr
结尾。
这个“最后一行的最后一栏”,实际可以是任意一栏,比如
\halign{#&#\cr
a\aftergroup b\cr}
虽然模板有两栏,但这行中第二栏没有出现,第二栏的模板就不会用到,而第一栏就成了这一行的“最后一栏”。
同样地,如果一行的
&
个数不够,就会出现单元格没填的情况
\begin{tabular}{|c|c|c|}\hline
1 & 2 & 3\\\hline
x & y \\\hline
\end{tabular}
三、跨栏的命令
\multicolumn
3.1
\span
和
\omit
TeX 底层有两个命令
\span
和
\omit
。当 TeX 查找栏的内容时,把
&
,
\span
和
\cr
视为一栏结束的分隔符。
\span
跟
&
的地位一样,它的意义是将左右两栏的内容合并成一栏。
\halign{*#&#-\cr
x\span y\cr}
的结果就是
*xy-
。
\omit
如果出现在一栏开头,就表示忽略这一栏的模板
\halign{&*#*\cr
1&\omit 2\cr}
的结果是
*1*2
。
模板开头的
&
表示循环。一般地,模板一栏里如果遇到多余的
&
,就表示后边的部分无限重复,如

就表示
#1...
,
#0&
表示
#0...
。
TeX 为了判断一栏开头是否为
\span
,
\omit
,
\cr
,
&
,会不断展开第一个 token 直到遇到无法展开的记号,如前面的
\relax \unhcopy \@arstrutbox
,其实是
\@arsturt
在构建模板的时候被
\edef
展开的结果,其定义为
\def\@arstrut{\relax\ifmmode\copy\@arstrutbox\else\unhcopy\@arstrutbox\fi}
如果我们在某一栏开头有这样的代码
%\usepackage{array}
\begin{tabular}{>{$}c<{$}}
\ifmmode X\else Y\fi
\end{tabular}
展开的结果就是
Y
,因为
\ifmmode
在替换模板之前就被展开了。它的前面如果加了一个
\relax
,展开就在遇到
\relax
时停止,送到模板里变成
$\relax\ifmmode...$
,最终
\ifmmode
展开的结果为
X
。
3.2
\multicolumn
的实现
\begin{tabular}{|c|c|c|}
\hline
1 & 2 & 3\\ \hline
\multicolumn{2}{|c|}{xy} & z\\ \hline
\end{tabular}
\multicloumn
首先展开成
\omit\span\omit...\span\omit
,跨 n 栏就是 n 个
\omit
夹着 n-1 个
\span
。
第一个
\omit
忽略第一栏的模板,
\span
合并第二栏,第二个
\omit
忽略第二栏的模板……。
然后将multicolumn的对齐说明像前面所说生成表格模板一样去解析,并去跨列的内容替换临时模板里的
#
,比如
|c|
就是
\def\@sharp{xy}
\hskip -.5\arrayrulewidth \vrule width\arrayrulewidth \hskip -.5\arrayrulewidth
\hskip \tabcolsep {\hfil \hskip 1sp\ignorespaces \@sharp \unskip \hfil }\hskip \tabcolsep
\hskip -.5\arrayrulewidth \vrule width\arrayrulewidth \hskip -.5\arrayrulewidth
默认的模板生成算法中,每一根框线属于其左方的一栏,除了第一列左边的,它属于第一栏。
|c|c|c|
就是对齐说明分别为
|c|
,
c|
,
c|
的三列。
\multicloumn
的时候也要遵循同样的原则,上面的例子中
\multicolumn
忽略了第一栏左右、第二栏右边的三根框线,所以合并栏两边的框线都要指定出来。
3.3
\halign
宽度的确定
-
如果没有合并栏的存在,TeX将每一栏的宽度设为所有行中这一栏最大的宽度。
-
如果存在合并的栏,首先筛选出所有合并的终点,然后从左到右,每一个以第 j 栏为终点的合并栏,如果某行第 i 栏到第 j 栏合并为一个盒子,就用它的最终宽度减去第 i, i+1, ...j-1 栏及各自紧随其后的
\tabskip
,当作这一行第 j 栏的宽度,然后取所有第 j 栏的最大值。
于是
\begin{tabular}{|c|c|c|c|}
\hline
\multicloumn{2}{|c|}{1} & 2 & 3\\ \hline
\multicloumn{3}{|c|}{abcdefghijkmnopqrtuvwxy} & z\\ \hline
\end{tabular}
可以看出合并单元格多余的宽度都放在其合并的最后一栏了。
首先两行合并的结果是第一行的栏分为1-2, 3, 4,第二行1-3, 4。所有合并的终点是2, 3, 4。
-
第一栏忽略掉。
-
以第二栏为终点的只有
\multicloumn{2}{|c|}{1}
,所以第二栏宽度就是它的宽度w12。
-
以第三栏为终点的有
2
和
\multicloumn{3}{|c|}{abcdefghijkmnopqrstuvwxy}
,合并单元格的宽度换成其原本的宽度w23减去w2+tabskip,由于w23-w2-tabskip>w13,所以w3=w23-w2-tabskip。
-
以第四栏为终点的有
3
和
z
,w4就是max(w14,w24)。
四、
tabularx
环境
tabularx
提供了一个新的对齐说明符
X
。
用lshort-zh-cn里的例子
% \usepackage{tabularx}
\begin{tabularx}{14em}%
{|*{4}{>{\centering\arraybackslash}X|}}
\hline
A & B & C & D \\ \hline
a & b & c & d \\ \hline
\end{tabularx}
首先设置了总宽度14em,第一趟
tabularx
就把
X
当成
p{14em}
来试排版,于是格式就是
|>{\centering\arraybackslash}p{14em}|>{\centering\arraybackslash}p{14em}|>{\centering\arraybackslash}p{14em}|>{\centering\arraybackslash}p{14em}|
tabularx
宏包自动加载了
array
包,所以框线有宽度,每条0.4pt;每格两个
\col@sep
(相当于原来的
\tabcolsep
)各6pt,试排出来的表格总共宽14em+4*2*6pt+0.4*5=610.00082pt,
从试探的宽度减去超出的长度除去
X
栏的个数:14em-(610.00082pt-14em)/4= 22.50006pt。
第二趟就把
X
当成
p{22.50006pt}
来排。
五、一些例子
想画如下表格
直接想法就是等分成6栏,前两行每格占2栏,第三行每格占3栏。
\newcolumntype{Y}{>{\centering\arraybackslash}X}
\begin{tabularx}{9cm}{|XXXXXX|}
\hline
\multicolumn{2}{|Y|}{\textbf{A}} & \multicolumn{2}{Y|}{\textbf{Table}} & \multicolumn{2}{Y|}{\textbf{B}} \\ \hline
\multicolumn{2}{|Y|}{$DC_A$} & \multicolumn{2}{Y|}{$S$} & \multicolumn{2}{Y|}{$DC_B$} \\ \hline
\multicolumn{3}{|Y|}{\textbf{Common Ground} \textit{cg}} & \multicolumn{3}{Y|}{\textbf{Projected Set} \textit{ps}} \\ \hline
\end{tabularx}
显然结果不对。
环境里总共有14个
X
,
tabularx
没有区分哪些是导言中的哪些在表格正文(其实是没想到用户这么鸡贼
\multicolumn
里也用
X
,它以为导言里就写了14个,而某些列全部被
\multicolumn
合并后忽略了
X
),所以除以均分的栏数的时候从14开始,每次减一来逐个试探。而第一二行
X
栏总长度为
3\TX@col@width
,第三行为
2\TX@col@width
,尝试12次之后,将多余的宽度分成3分刚好可以达到最终宽度,即
\TX@col@width = \TX@target-(第一次试排版总宽度-\TX@target)/3
。
问题来了,
-
\halign
中没有以第一栏为终点的,忽略;
-
第二栏,一个
X
宽度;
-
第三栏,
\multicolumn{3}{|Y|}{\textbf{Common Ground} \textit{cg}}
宽度-w2=0pt,宽度为0,由于右框线属于这一栏,就画在宽度为0的第三栏右边,与第二栏右边界对齐;
-
第四栏宽度为一个
X
宽度;
-
第五栏忽略;
-
第六栏,前两行给出一个
X
宽度,第三栏给出
\TX@col@width
减去第四栏的
\TX@col@width
还是0,最后宽度以前两行为准。
可见在跨栏里用
X
会导致意想不到的结果,如果不用会怎样呢?
\begin{tabularx}{9cm}{|XXXXXX|}
\hline
\multicolumn{2}{|c|}{\textbf{A}} & \multicolumn{2}{c|}{\textbf{Table}} & \multicolumn{2}{c|}{\textbf{B}} \\ \hline
\multicolumn{2}{|c|}{$DC_A$} & \multicolumn{2}{c|}{$S$} & \multicolumn{2}{c|}{$DC_B$} \\ \hline
\multicolumn{3}{|c|}{\textbf{Common Ground} \textit{cg}} & \multicolumn{3}{c|}{\textbf{Projected Set} \textit{ps}} \\ \hline
\end{tabularx}
试排版第一趟宽度比最终总宽度还小,不再尝试直接排出结果,并报警告
Underfull \hbox
。
-
同样,第一栏忽略;
-
第二栏,前两行第一格自然宽度的最大值;
-
第三栏,第三行第一格的自然宽度-w2;
-
第四栏,前两行第二格的最大自然宽度-w3,宽度为负;
-
第五栏忽略;
-
第六栏,第三行第二格的自然宽度-w4。
-
再右边横框线继续补足到目标总宽度。
分析到这里,正确的办法已经呼之欲出了:
\newcolumntype{Y}{>{\centering\arraybackslash}X}
\begin{tabularx}{9cm}{|cccccc|}
\hline
\multicolumn{2}{|Y|}{\textbf{A}} & \multicolumn{2}{Y|}{\textbf{Table}} & \multicolumn{2}{Y|}{\textbf{B}} \\ \hline
\multicolumn{2}{|c|}{$DC_A$} & \multicolumn{2}{c|}{$S$} & \multicolumn{2}{c|}{$DC_B$} \\ \hline
\multicolumn{3}{|>{\hsize=1.5\hsize\linewidth=\hsize}Y|}{\textbf{Common Ground} \textit{cg}} &
\multicolumn{3}{>{\hsize=1.5\hsize\linewidth=\hsize}Y|}{\textbf{Projected Set} \textit{ps}} \\ \hline
\end{tabularx}
>{\hsize=1.5\hsize\linewidth=\hsize}Y
也可以写成
>{\centering\arraybackslash}p{1.5\csname TX@col@width\endcsname}
。
分四栏也是一样的
\newcolumntype{Y}{>{\centering\arraybackslash}X}
\begin{tabularx}{15cm}{|Y|cc|Y|}
\hline
\textbf{A} & \multicolumn{2}{Y|}{\textbf{Table}} & \textbf{B} \\ \hline
$DC_A$ & \multicolumn{2}{c|}{$S$} & $DC_B$ \\ \hline
\multicolumn{2}{|>{\hsize=1.5\hsize\linewidth=\hsize}Y|}{\textbf{Common Ground} \textit{cg}} &
\multicolumn{2}{>{\hsize=1.5\hsize\linewidth=\hsize}Y|}{\textbf{Projected Set} \textit{ps}} \\ \hline
\end{tabularx}
模板不要
X
,前两行随便选一行去用它对齐,只要我们保证设定的 9cm / 3 = 3cm 不小于前两行每一格的自然宽度就行。
仔细观察会发现上图并没有完全等分,前两行比第三行多了一个竖线以及两个
\col@sep
,稍微修补一下
\newcolumntype{Y}{>{\centering\arraybackslash}X}
\edef\colsep{\expandafter\noexpand\csname col@sep\endcsname}
\begin{tabularx}{9cm}{|Y|cc|Y|}
\hline
\textbf{A} & \multicolumn{2}{Y|}{\textbf{Table}} & \textbf{B} \\ \hline
$DC_A$ & \multicolumn{2}{c|}{$S$} & $DC_B$ \\ \hline
\multicolumn{2}{|>{\hsize=\dimexpr1.5\hsize+\colsep+.5\arrayrulewidth\relax\linewidth=\hsize}Y|}{\textbf{Common Ground} \textit{cg}} &
\multicolumn{2}{>{\hsize=\dimexpr1.5\hsize+\colsep+.5\arrayrulewidth\relax\linewidth=\hsize}Y|}{\textbf{Projected Set} \textit{ps}} \\ \hline
\end{tabularx}
回过头想一想,其实我们一开始的代码没什么不对,只是
tabularx
的机制限制画不出来。如果用更强大的
tabularray
,同样的做法就变得可行了。
\begin{tblr}{colspec={|XXXXXX|},
hlines,vlines,width=9cm,rowsep=0pt, % width 可以不用设置,rowsep 设为0是为了跟tabular环境长得更像
cell{1-2}{odd}={c=2}{c},
cell{3}{1,4}={c=3}{c}
\textbf{A} && \textbf{Table} && \textbf{B} & \\
$DC_A$ && $S$ && $DC_B$ &\\
\textbf{Common Ground} \textit{cg} &&& \textbf{Projected Set} \textit{ps} \\
\end{tblr}