多文件项目 (\input / \include / subfiles)

把一个多文件 LaTeX 项目拆成十几个 .tex,编译器眉头都不会皱一下:pdflatex main.tex 会把它们全读进来,输出一份 PDF。跟丢的是编辑器。开着第三章按下构建键,回来的是 ! LaTeX Error: Missing \begin{document}.——编辑器老老实实地排了你正看着的那个文件。修好它只要在每章开头加一行 % !TEX root = ../main.tex,而有趣之处在于:这一行 LaTeX 从头到尾都不会读。它只是一条注释,收信人是编辑器,不是编译器。本页讲的就是拆分项目的第二层——哪些编辑器读这条魔法注释、不读的又用什么代替,SyncTeX 如何找回正确的章文件,构建产物究竟落在哪里,以及当眼前打开的并非主文件时会坏掉什么。负责拆分本身的 \input\include\includeonly 是另一个话题,页尾有链接。

% !TEX root:开着章文件,编译的仍是主文件

在每个不是主文件的文件开头写上 % !TEX root = ../main.tex,无论哪个文件在前台,构建键都会做对事情。TeXShop 自己的文档里有两个细节值得记住,因为两者都常把人绊倒。其一,这一行必须出现在 文件的前二十行之内——埋在一大段许可证头下面,它根本不会被看见。其二,路径是 相对于写着这一行的那个文件 解析的,而不是相对于项目根目录:放在 chapters/ 里的章文件需要的是 ../main.tex,不是 main.tex。绝对路径也能用,代价是项目从此挪不了窝。主文件本身不需要这一行,它已经就是 root。

text
thesis/
  main.tex                 <- the root; needs no magic comment
  chapters/
    03-results.tex         <- carries the line below
latex
% chapters/03-results.tex -- the first lines of the file
% !TEX root = ../main.tex     % relative to THIS file, not to thesis/
% !TEX TS-program = xelatex   % pin the engine for the whole project
% !TEX encoding = UTF-8 Unicode

\chapter{Results}

这一行有前身,而后来者为什么胜出很能说明问题。TeXShop 从前有一条名叫 “Set Project Root…” 的菜单命令,它把答案记在章文件旁边的附属文件里:two.tex 会多出一个 two.texshop。把那个看不见的文件删掉,TeXShop 立刻又去排章文件了。TeXShop 的文档如今写道,这条命令已从菜单中移除,因为 % !TEX root 的做法更稳固——理由就在“稳固”二字里。写在文件内部的一行会跟着文件走。复制、给上层文件夹改名、用 Git 克隆、交给一位从没打开过你的编辑器的合著者,它都活得下来。搁在文件旁边的配置,迟早会跟文件走散。

LaTeX 本身为什么从不读 % !TEX root

因为 % 开启注释,而注释在 TeX 的词法扫描阶段就被丢掉了,早于其他任何事情。pdflatexxelatexlualatex 在那一行上什么也没看见。它是一个程序(编辑器)发给另一个程序(编辑器的构建命令)的口信,只不过恰好取道源文件。由此有两条实际后果。其一,这一行写错了,没有人会警告你。 让它指向一个不存在的文件,编辑器就悄悄退回自己的猜测——通常是你打开的那个文件——于是你又看见 ! LaTeX Error: Missing \begin{document}.。其二,从终端或 CI 跑的构建(latexmk main.tex、Makefile、GitHub Actions 的某个步骤)会在命令行上直接点名主文件,因此完全无视魔法注释。这一行是交互式编辑的便利,不是项目定义的一部分。

TeX 确实会读的注释恰好只有一条,值得知道,免得把两者混为一谈。如果主输入文件的第一行以 %& 开头,引擎自己会解析它来选择格式——%&pdflatex%&latex——tex 的 man 手册说明这一行为由 -parse-first-line 选项和 parse_first_line 配置变量控制。它出自 TeX 自身的格式加载机制,住在引擎里。而所有写成 % !TEX ... 的东西都住在编辑器里。两者外形相似只是巧合:它们都想把指令藏在 LaTeX 不会绊倒的地方。

latex
% main.tex -- first line only: the ENGINE parses this one
%&pdflatex

% chapters/03-results.tex -- the engine never sees this one
% !TEX root = ../main.tex

哪些编辑器读 % !TEX root,其余的用什么代替

TeXShop、TeXworks、TeXstudio,以及装了 LaTeX Workshop 扩展的 VS Code 都读这一行;Emacs(AUCTeX)用的是自己的文件局部变量,而 Overleaf 从项目设置而非源文件里取答案。其中最值得研究的是 LaTeX Workshop,因为它把整套判定流程都写进了文档:先看当前编辑器里的魔法注释,再看当前文件本身是否含有 \documentclass\begin{document},然后扫描工作区根目录下的 .tex,找出把当前文件引入进来的那一个,接着识别 subfiles 的写法 \documentclass[main.tex]{subfiles},最后退回到上次编译留下的 .fls 文件清单。魔法注释之所以胜出,是因为它排在第一个被查。若哪天不想让它生效,设置项叫 latex-workshop.latex.build.enableMagicComments

编辑器读什么备注
TeXShop% !TEX root该指令的出处;同族还有 % !TEX TS-programencodingspellcheck
TeXworks% !TEX root采用同样的魔法注释方案
TeXstudio% !TeX root先自动检测根文档;有这一行则以它为准
LaTeX Workshop% !TEX rootVS Code 用;五级回退中的第一级,可用 latex-workshop.latex.build.enableMagicComments 关闭
AUCTeXTeX-masterEmacs 用;文件局部变量,惯例上放在文件末尾
Overleaf项目设置在项目菜单里指定 “Main document”;源文件中不留痕迹

有意思的例外是 Emacs。AUCTeX 提出的是同一个问题,但把答案存成文件局部变量,而且按惯例放在文件 末尾 的一个块里。既然各家编辑器只读自己那套约定,两样都写也毫无代价:AUCTeX 的块对其他编辑器只是普通注释,% !TEX root 对 Emacs 也只是普通注释。共享仓库里常见两者兼备的章文件,这是对的,成本不过两行。Overleaf 则完全站在这场争论之外:主文档是项目的属性,从项目菜单里设定,因此源文件中没有任何东西会与之脱节——反过来说,当你把项目下载到本地打开时,也没有任何东西会跟着文件走。

latex
% chapters/03-results.tex -- both conventions in one file
% !TEX root = ../main.tex

\chapter{Results}

%%% Local Variables:
%%% mode: latex
%%% TeX-master: "../main"
%%% End:

跨文件的 SyncTeX:点 PDF 为什么会打开正确的那一章

因为 SyncTeX 为页面上的每一个盒子都记下了 它来自哪个输入文件的第几行。在 PDF 里双击第三章的某个段落,打开的是 chapters/03-results.tex,而不是 main.tex。用 -synctex=1 打开这项功能,你会在项目根目录得到唯一一个 main.synctex.gz,名字取自根文件。并不存在按章分开的 synctex 文件:单一索引覆盖整个项目,而这正是它能指向其中任意一个文件的原因。

terminal
$ pdflatex -synctex=1 main.tex
$ synctex edit -o "2:100:200:main.pdf"
SyncTeX result begin
Output:main.pdf
Input:./chapters/ch2.tex
Line:2
SyncTeX result end

把命令行客户端试一次,机制就变得具体了。synctex edit 接受 PDF 的页码和坐标,返回文件名与行号;synctex view 反向而行,从源文件的一行给出纸面上的位置。它背后的 Synchronize TeXnology,用其 man 手册自己的话说,主要归功于 Jérôme Laurens,如今作为 TeX Live 的一部分维护。而 TeXShop 的文档把它与上一节明确接上了:正是那行 % !TEX root,才让反向搜索的一次点击打开并激活 正确的章窗口,而不是把你丢进主文件。这两项功能通常一起配置,原因正在于此。

陷阱出现在单独编译某一章的时候。引擎的产物落在 运行构建时的工作目录里,而不是输入文件旁边。在项目根目录敲 pdflatex -synctex=1 chapters/03-results.tex03-results.synctex.gz 就会出现在根目录,紧挨着 main.synctex.gz。于是两份索引描述着同样的源行,其中一份指向一个从第 1 页开始的单章 PDF。阅读器碰巧读了哪一份,就决定你这一下点到哪里,页码从此对不上。回到构建整本书时,请把章级构建留下的 PDF 和 synctex 文件删掉。

构建产物落在哪里——.gitignore 与清理

\include 会给每一章写一份 .aux,而且写在 章文件旁边。构建一个含有 chapters/01-intro.tex 的项目,你会发现 chapters/01-intro.aux 就躺在它边上。其余则统统留在与主文件同级的根目录:main.auxmain.logmain.tocmain.outmain.synctex.gz,若用 latexmk,还有 main.flsmain.fdb_latexmk。也就是说产物并不集中在一处,而是薄薄地撒满整棵源码树。

text
thesis/
  main.tex  main.pdf
  main.aux  main.log  main.toc  main.out
  main.synctex.gz  main.fls  main.fdb_latexmk
  chapters/
    01-intro.tex   01-intro.aux    <- one .aux per \include, here
    02-method.tex  02-method.aux

对 Git 而言这没有看上去那么麻烦,因为 .gitignore 中不含斜杠的模式 在任意层级都能匹配:一行朴素的 *.aux 已经覆盖了 chapters/01-intro.aux。覆盖不到的是锚定在根目录的 /*.aux,同样够不着的还有在项目根目录敲 rm *.aux 的清理习惯。更出人意料的是,latexmk 也够不着:在 TeX Live 2024 上实测,latexmk -c 乃至 latexmk -C 都只清掉根目录下的中间文件,把 chapters/*.aux 留在原地。所以当你怀疑是陈旧的 .aux 作祟——就是那种“报错指向一个你根本没碰过的章”的情形——请显式清理,例如 find . -name "*.aux" -delete

有一处地方,拆分确实会把工具弄坏:-output-directory。在使用 \include 的项目上执行 pdflatex -output-directory=build main.tex 想把输出挪到别处,这次运行就会死掉。TeX 试图打开 build/chapters/01-intro.aux,而那个子目录并不存在,于是你得到 ! I can't write on file,随后是致命错误,PDF 一页也没有。TeX 不会创建目录。 补救有两条:自己事先把同样的子目录结构挖好,或者把活交给 latexmk -outdir=build,它会替你创建子目录。这正是为什么要构建到源码树之外的多文件项目,几乎总是由 latexmk 而不是引擎直接驱动。

terminal
# fails: TeX will not create build/chapters/ by itself
pdflatex -output-directory=build main.tex
# ! I can't write on file ... chapters/01-intro.aux

# works: mirror the subdirectories first
mkdir -p build/chapters && pdflatex -output-directory=build main.tex

# works: latexmk creates them for you
latexmk -pdf -outdir=build main.tex

而这份 .fls 也正是拆分项目里“保存即重建”得以成立的原因。带 -recorder 运行(latexmk 会替你加上),引擎就会记录它打开过的每一个文件,于是 main.fls 里为每一章都留着一行 INPUT chapters/01-intro.tex。latexmk 把由此得到的依赖清单存进 main.fdb_latexmk 并全部纳入监视,所以保存第三章就会重建整本书——你从没告诉过它第三章属于这本书。拆分的结构无需声明两遍:那一串 \include 本身就是依赖声明。

当你打开的不是主文件时,会坏掉什么

症状有三种,彼此看上去毫不相像。其一,普通章文件单独编译会立刻停住:第一个 \chapter 处报 ! Undefined control sequence.,接着是 ! LaTeX Error: Missing \begin{document}.,然后 ! Emergency stop.,没有 PDF——文件里没有 \documentclass,这是必然的。其二,subfiles 的章单独编译更麻烦,因为 它会成功:你得到一份从第 1 页开始、看起来煞有介事的单章 PDF,而指向其他章的交叉引用印成 ??。其三,从错误的工作目录启动的编译则栽在图片上,因为项目里所有相对路径都是从 构建运行的位置 解析的,而不是从文件所在之处。

  • 构建键排的是另一个文件 → 给每个非主文件加 % !TEX root,写在前二十行内,路径相对于该文件本身。
  • ! LaTeX Error: Missing \begin{document}. → 你在直接编译某个章文件;它没有导言区,也不该有。
  • 图片消失,或编译停在找不到文件 → 构建不是在项目根目录运行的;相对路径以工作目录为准。
  • 在 PDF 里点击却打开了主文件而非章文件 → 那次构建没加 -synctex=1,或阅读器读的是过期的 .synctex.gz
  • 一次失败的尝试之后根目录多出零散的 .log.pdf → 引擎把产物写进工作目录,而不是输入文件旁边。

归根结底,让这一层保持安静只需两个习惯。其一,文件名和文件夹名里不要有空格。本页提到的每一样工具,最终都会把路径交给 shell,或交给以 % 起头的魔法注释,而空格正是引号相关 bug 的栖身之处。其二,构建一律从项目根目录开始——无论手敲、走 Makefile,还是交给编辑器。工作目录是 \includegraphics\include-output-directory 共同参照的唯一基准点,它一偏,三者同时偏。把这两件事做对,多文件这一层就隐形了——而它只有在隐形的时候,才算尽了本分。