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
结束,其中第一行叫做“模版”。
#
,于是上面的代码会变成如下形式-\hfil 1-&-\hfil 2\hfil-&-\fbox{3}\hfil-\cr
-\hfil Alpha-&-\hfil Beta\hfil-&-\fbox{Gamma}\hfil-\cr
然后 TeX 计算栏宽,每一栏的宽度是所有行中该栏宽度的最大值。
\hbox{-\hfil1-}
,假设其宽度是w1。第二行第一栏装成\hbox{-\hfil Alpha-}
,宽度是w2。因为w2>w1,第一栏的宽度就是w1。\hbox to w2 {-\hfil1-}
里的\hfil
粘连就伸长到w2-w1。(hbox 是为了说明方便假设的,不代表这个阶段真的存在这么一个盒子)\tabskip
的粘连。不引起歧义的情况下,本文通常用“栏”来表示\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
\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
。
\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
忽略了第一栏左右、第二栏右边的三根框线,所以合并栏两边的框线都要指定出来。
\halign
宽度的确定\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
。
分析到这里,正确的办法已经呼之欲出了:
\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}