标题格式 (titlesec)

LaTeX 标题的外观,是在一个小得惊人的地方决定的。article.cls 第 302 行开始的短短四行——一次向名为 \@startsection 的命令传入六个参数的调用——就定下了每个 \section 的字体、编号和四周空白。titlesec 宏包(Javier Bezos 编写)所做的,是把这六个参数从文档类里 读回来,再以人类可读的 \titleformat\titlespacing 重新奉上。因此仅仅加载它,页面上不会有 1pt 的变化。本页先打开文档类的 \@startsection,弄清标题真正的运作方式,再讲 \titleformat\titlespacing 的写法、星号的真正含义,以及它与 KOMA-Script 和 memoir 冲突的原因——全部有实测。

\@startsection:文档类用来定义标题的六个参数

\section 的定义原样打印出来,出现的是一次带六个参数的调用。标准文档类中除 \part 之外的所有标题都是这个形状,参数依次是 标题名称、层级编号、缩进量、标题上方空白、标题下方空白、格式article.cls 的第 302 至 305 行正是如此。

article.cls
% article.cls, lines 302-305 (TeX Live 2024)
\newcommand\section{\@startsection {section}{1}{\z@}%
                                   {-3.5ex \@plus -1ex \@minus -.2ex}%
                                   {2.3ex \@plus.2ex}%
                                   {\normalfont\Large\bfseries}}

% \paragraph, from the same file - note the NEGATIVE after-skip
\newcommand\paragraph{\@startsection{paragraph}{4}{\z@}%
                                    {3.25ex \@plus1ex \@minus.2ex}%
                                    {-1em}%
                                    {\normalfont\normalsize\bfseries}}

有意思的是,第四和第五个参数的符号是有含义的。第四个(上方空白)为负,表示“该标题后的第一个段落不缩进”;第五个(下方空白)为负,表示“标题后不换行,正文接着排在同一行”——也就是 行内标题(run-in)\section 的上方空白是 -3.5ex,所以节后的段落不缩进;\paragraph 的下方空白是 -1em,所以 \paragraph 是行内标题。用一个长度的正负号来切换行为,是相当典型的 1980 年代 TeX 设计。实现里也有佐证:titlesec 用来读回文档类定义的例程,在第四个为负时选择带星号的 \titlespacing*,在第五个为负时选择 runin 形状,并各自把符号反转。

标题命令在 article 中的层级在 book / report 中的层级
\part0−1
\chapter(不存在)0
\section11
\subsection22
\subsubsection33
\paragraph4(默认为行内标题)4
\subparagraph5(同上)5

也就是说,article六个层级(0 到 5),加入 \chapterbookreport七个(−1 到 5)。编号的深度由 \c@secnumdepth 掌握,实测 article3bookreport2。所以 article 一直编号到 \subsubsection,而 book 只到 \subsection。写上 \setcounter{secnumdepth}{4}\paragraph 也会获得编号。目录收录的深度另有一本账,由 tocdepth 负责。

加载 titlesec 之后到底发生了什么

页面上什么也不会变。变的是 \section 的定义:对 \@startsection 的调用被 titlesec 自己的机制 替换 了。实测显示,加载之前 \section\@startsection {section}{1}{\z@}{-3.5ex ...}{2.3ex ...}{\normalfont \Large \bfseries},加载之后只剩 \ttl@straightclass {section} 一个记号。这一过程中,六个参数被搬进 titlesec 的内部存储,上方空白变成具体长度 15.0694pt plus 4.30554pt minus 0.86108pt,下方空白变成 9.90276pt plus 0.86108pt。因为 10pt 的 Computer Modern 中 1ex = 4.30554pt,这不过是把 3.5ex2.3ex 算成了数值(plusminus 是 LaTeX 为齐平页面下沿而可以使用的伸缩余量)。总之这是一次 等价变换,没有丢失任何含义。

latex
% Measured in a 10pt article. These two lines reproduce exactly what the
% class already does - they are what titlesec extracted on load.
\titleformat{\section}[hang]
  {\normalfont\Large\bfseries}{\thesection}{1em}{}
\titlespacing*{\section}{0pt}
  {15.0694pt plus 4.30554pt minus 0.86108pt}   % = 3.5ex plus 1ex minus .2ex
  {9.90276pt plus 0.86108pt}                   % = 2.3ex plus .2ex

\titleformat 的写法:七个参数与六种形状

重新设计标题外观的核心是 \titleformat。参数虽多,但与 \@startsection 的六个一一对应,就不再可怕。若只想换字体,星号形式 \titleformat* 最短——两个参数,如 \titleformat*{\section}{\large\bfseries\sffamily}——编号和间距仍由文档类决定。更靠前还有一套仅靠宏包选项完成的简易设置:\usepackage[sf,bf]{titlesec} 会让所有标题变成无衬线粗体。选项分三个系列——rm sf tt md bf up it sl scbig medium small tinyraggedleft center raggedright——再加上压缩上下空白的 compact。若只想改变编号的输出方式,就用 \titlelabel,例如 \titlelabel{\thetitle.\quad},其中 \thetitle 表示该标题的编号。

latex
\titleformat{⟨command⟩}[⟨shape⟩]{⟨format⟩}{⟨label⟩}{⟨sep⟩}{⟨before-code⟩}[⟨after-code⟩]
  • ⟨command⟩ — 要重新设计的标题命令本身:\part\chapter\section\subsection\subsubsection\paragraph\subparagraph 之一。
  • [⟨shape⟩](可省略)— 标题的“形状”:hangblockdisplayruninleftmarginrightmarginframedropwrap。默认为 hang
  • {⟨format⟩} — 同时作用于编号和标题的格式。在这里统一指定字体与字号,对齐则用 \filcenter\filright\filleft\fillast,双面排版可用 \filinner\filouter
  • {⟨label⟩} — 编号的输出方式,放入 \thesection,或用 \@seccntformat{section} 保留文档类自身的格式。留空会取消编号,但目录中也会一并消失。
  • {⟨sep⟩} — 标签与标题之间的间隔。必须是长度,不能留空hangblock 中为水平,display 中为竖直,frame 中为标题到边框的距离)。
  • {⟨before-code⟩} — 在标题 之前 执行的代码。装饰放在这里:\titlerule 画横线,\titleline 把任意内容排成独立一行。
  • [⟨after-code⟩](可省略)— 在标题 之后 执行的代码(hangblockdisplay 中为竖直模式,runinleftmargin 中为水平模式)。
shape效果
hang默认值。编号悬挂,标题在其右侧对齐(即标准的 \section
block把编号和标题作为一个块(段落)排版;适合居中标题
display把编号放在标题上方的独立一行(即标准的 \chapter
runin行内标题:不换行,正文接着排在同一行(即标准的 \paragraph
leftmargin把标题放进左边距内;rightmargin 是右边距版本
frame类似 display,但给标题加边框
drop让正文环绕在标题周围;wrap 是让标题盒子贴合内容宽度的变体

\titlespacing 与星号的含义:为什么几乎总该用 \titlespacing*

星号的意思是 抑制标题后第一个段落的缩进。最容易记住的方式,是把它看作上一节 \@startsection 约定的直接翻译:上方空白为负即“不缩进”。标准的 \section 上方空白是 -3.5ex,所以要保持文档类原有的外观,就该用带星号的形式——这正是“几乎总用 \titlespacing*”的原因。它的参数是:额外的左边距、标题上方的空白、标题与正文之间的间隔,以及可选的额外右边距。在 leftmargin 系形状中,第一个参数表示标题宽度;在 runin 中表示标题前的缩进。至于 dropwraprunin 形状,本来就不存在需要抑制的缩进,所以星号在那里没有作用。

latex
\titlespacing*{⟨command⟩}{⟨left⟩}{⟨before-sep⟩}{⟨after-sep⟩}[⟨right⟩]

% All arguments must be LENGTHS. A value containing a command such as
% \stretch, or an empty {}, produces:
%   ! Missing number, treated as zero.
%   ! Illegal unit of measure (pt inserted).
%
% Shorthand: *4 means 4 units of \beforetitleunit (or \aftertitleunit)
\titlespacing*{\section}{0pt}{*4}{*2}

所有参数都必须是 长度(dimension)。若传入包含命令的值,例如 \stretch{1},就会先得到 ! Missing number, treated as zero.,接着是 ! Illegal unit of measure (pt inserted).。传入空的 {} 也会得到同样这两个错误。如果完整写出 skip 值太麻烦,可以用 *4 这种 * 加数字的缩写,其单位保存在 \beforetitleunit\aftertitleunit 中。

这里实测一个容易踩的行为。\titlespacing\chapter\part 不会起任何作用,除非同时用 \titleformat 定义它们的格式。report 类中只写 \titlespacing*{\chapter}{0pt}{0pt}{10pt},再测量章后正文的位置,得到 305.1077pt,与完全不写时相差不到 1sp。在同一文件中加上 \titleformat{\chapter}[display]{...},位置就上移到 229.2701pt。既不报警告也不报错,因此麻烦之处在于毫无察觉。\part 本来就不使用 \@startsection 实现,所以连简易设置也不会改变它。这两个层级都请从 \titleformat 入手。

做一个带横线的标题和一个行内标题

\titleformat\titlespacing* 通常成对书写。下面的导言区把 \section 改成“标题上方横一条细线”的样式,把 \subsection 改成“标题后加句点、正文接着排”的行内标题。画线的惯用做法,是在 [display] 形状的 {⟨before-code⟩} 中放入 \titlerule。加上 [explicit] 选项后,标题文字不再自动出现,必须用 #1 显式 放置——这正是想在标题前后添加装饰时所需的机制。

document.tex
\documentclass{article}
\usepackage[explicit]{titlesec}

% A rule above the title, sans-serif bold below it
\titleformat{\section}[display]
  {\normalfont\Large\bfseries\sffamily}   % applies to label and title
  {\thesection}                           % the number
  {0pt}                                   % label-to-title gap (vertical here)
  {\titlerule\vspace{2pt}#1}              % before-code, then the title itself
\titlespacing*{\section}{0pt}
  {3.5ex plus 1ex minus .2ex}{2.3ex plus .2ex}

% A run-in subsection: "1.1 Beta. Text continues here."
\titleformat{\subsection}[runin]
  {\normalfont\bfseries}{\thesubsection}{.5em}{#1.}
\titlespacing*{\subsection}{0pt}{2ex plus .5ex}{.5em}

\begin{document}
\section{Alpha}
The paragraph after a starred \titlespacing is not indented.
\subsection{Beta}
This text runs on from the heading, on the same line.
\end{document}

让目录和页眉也保持一致:titletoctitleps

标题重新设计之后,通常也希望目录和页眉保持一致。titlesec 在同一发布包中带有两个姊妹宏包(也可单独使用):目录一侧是 titletoc,页面样式一侧是 titlepstitletoc 的核心是完整定义目录项的 \titlecontents,以及一次性生成带点线引导的常用样式的 \dottedcontents。唯一的陷阱是开头的 ⟨section⟩ 参数是 不带反斜杠的名称sectionchapterfigure 等)。titleps\newpagestyle 定义整套页面样式,并用 \sethead\setfoot 填充左、中、右三个槽位。页眉上取用哪个标题名由 \settitlemarks 控制,\bottitlemarks\toptitlemarks\firsttitlemarks 则让你选择使用该页上的哪一个标题。页眉页脚本身的做法(包括 fancyhdr)由另一页负责。

latex
\usepackage{titletoc}
% Dotted leaders for section entries:
%   indent 1.5em, no numbered-entry format, label width 2.3em, leader 1pc
\dottedcontents{section}[1.5em]{}{2.3em}{1pc}

\usepackage{titleps}
\newpagestyle{main}{\sethead{\thesection}{}{\thepage}}
\pagestyle{main}

为什么不该在 KOMA-Script 和 memoir 中使用 titlesec

这两套文档类都用自己的机制来组装标题,所以 titlesec 试图在其之上截获 \@startsection 时并不合拍。不过二者的失败方式完全不同。KOMA-Script 会出声。scrartcl 中加载 titlesec,首先会得到 Class scrartcl Warning: Usage of package titlesec together with a KOMA-Script class is not recommended.,接着每个标题各来一条 Package titlesec Warning: Non standard sectioning command \section detected. Using default spacing and no format.。第二条才是问题的实质:KOMA-Script 的 \section 调用的是 \scr@startsection 而非 \@startsection,因此 titlesec 一点也读不出文档类的设计,转而用自己的默认值重排标题。而且未必只停留在警告。若同时使用文档类一侧的标题选项,例如 \documentclass[headings=big]{scrartcl},实测会得到 ! Package titlesec Error: No format for this command.,随后是 ! Missing number, treated as zero.! Illegal unit of measure (pt inserted).,排版就此崩掉。若使用 KOMA-Script,请改用文档类自身的接口 \setkomafont\RedeclareSectionCommand 来调整标题。

memoir 则相反,什么也不说。 在 TeX Live 2024 上试验,这一组合毫无警告也毫无错误地通过,\titleformat 也如预期生效。原因是 memoir 的 \section 形如 \sechook \@startsection {section}{1}{\secindent}{\beforesecskip}{\aftersecskip}{\normalfont \secheadstyle}titlesec 能把它识别为标准形并读回。但查看读回的内容会发现,\secheadstyle\secindent 以名称形式保留,而 上下空白却被固定成加载那一刻的数值 15.0694pt 与 9.90276pt。也就是说 \setsecheadstyle 仍然有效,\setbeforesecskip\setaftersecskip 却不再起作用——宏包 悄无声息地弄坏了一半接口。memoir 自带一整套完整的标题设计机制,用它更稳妥。

最后是 titlesec 周边值得记住的两件事。\titleclass 用来重新指派某一层级的类别,写法如 \titleclass{\subsection}{straight};有效的类别名只有 toppagestraight 三个,传入其他值会得到 Package titlesec Error: Unknown sectioning class\assignpagestyle{\chapter}{plain} 则为某个标题所在的起始页指定页面样式。另外关于作者:Javier Bezos 同时也是多语言宏包 babel 的现任维护者,以及控制列表版式的 enumitem 的作者——所以在 LaTeX 里花一天调整标题、列表和语言,你会三次遇上同一个人写的代码。