协作与变更跟踪

.docx 其实是一包压缩成 ZIP 的 XML。把它的两份草稿交给 Git,Git 只能回答一句「二进制文件不同」。而 LaTeX 稿件是纯文本,git diff 能直接指出合作者改动的那一句话——版本管理与协作写作之所以与 LaTeX 如此契合,归根到底就在这一点上。不过这种契合并非自动送到手上。Git 比较的是行而不是句,把整个段落写成一长行,它就是一个不可分割的行。更糟的是,即使文件里还留着 <<<<<<< HEAD,LaTeX 也会一声不吭地编译过去,把冲突痕迹原样印进 PDF。本页讲的是:提交什么、在哪里断行才能让 diff 可读、怎样从合并冲突中脱身,以及 latexdifftodonoteschanges 如何把源码树变成合作者真的能审阅的东西。

为什么 git diff.tex 有效、对 .docx 无效

因为 git diff 不过是逐行比对两个文件而已。.tex 里存的就是人敲进去的字符,顺序原样保留,比对结果因此是一份读得懂的报告:这一行变成了那一行。.docx 内部是压缩后的 XML,哪怕只多加一个逗号,压缩后的字节也可能整体改变,Git 除了「不一样」之外说不出任何话。这正是文字处理文档的协作往往变成来回传附件、最后由某个人手工合并的原因。用 LaTeX 就不必来回传,分支与合并 接手了这份工作。反过来说:仓库里只放值得当作文本来读的文件,把对 diff 毫无意义的生成物一开始就排除在外——协作仓库的设计几乎就只有这一条。

Git 还准备了更进一步的体贴。每个差异块的标题行(以 @@ -3,2 +3,2 @@ 开头的那一行)通常只是随手带上附近的某一行;但只要在 .gitattributes 里写上 *.tex diff=tex 这一行,Git 就会启用内置的 TeX 规则,把 包含该差异块的 \section 名称 放到标题行上。在几百页的稿件里,扫一眼 diff 就知道每处改动属于哪一节,效果并不小。配置只有一行,没有副作用。

terminal
# .gitattributes — teach git the structure of a .tex file
*.tex diff=tex

# hunk headers now name the enclosing sectioning command:
#   @@ -3,2 +3,2 @@ \section{First}
# without it, git prints an arbitrary nearby line instead.

该提交什么,什么该写进 .gitignore

要提交的 只有人手写下来的东西.tex 文件、.bib 数据库、图的源文件、latexmkrcMakefile,以及文档依赖的类文件与样式文件。有了它们,任何人都能重建出同一份 PDF。凡是编译时会重新生成的文件,一律写进 .gitignore。只要对一个用了 biblatexbiber 的最小文档跑一次 latexmk,就会生成 .aux.bbl.bcf.blg.fdb_latexmk.fls.log.run.xml.toc。做索引会多出 .idx.ilg.ind;用 hyperref 会多出 .out;开启 SyncTeX 会多出 .synctex.gz。一旦把它们纳入跟踪,即使正文一个字都没动,每次提交也会带来几百行噪声。

terminal
# .gitignore — everything below is regenerated by a build
*.aux
*.log
*.out
*.toc
*.lof
*.lot
*.fls
*.fdb_latexmk
*.synctex.gz
*.bbl
*.blg
*.bcf
*.run.xml
*.idx
*.ilg
*.ind

# generated PDFs: ignore the working build, keep tagged releases by hand
main.pdf
*-diff*.tex

生成的 PDF 是这条方针唯一可能的例外。跟踪每次构建都会改写的 main.pdf,既读不出差异,仓库也只会越来越臃肿。平时忽略它,只把日后可能需要 逐字逐句原样取回 的版本——投稿版、发布版——附加到 tag 或 Release 上,会更好打理。.bbl 也是同样的道理:它属于构建产物,但若投稿方要求源文件包中含 .bbl,就在提交前生成并单独随附,这并不构成把它长期留在仓库里的理由。还有一类值得忽略的是 latexdiff 写出的 *-diff*.tex。它是产物而非稿件,一旦混进主线,下一版就得去编辑一份满是 \DIF 命令的稿子。

一句一行——差异的单位是换行

把稿件写成 一句一行。对 LaTeX 来说,单个换行只相当于一个空格,排版结果一个字符都不会变;变的只是 diff 的可读性。若整段写成一行,哪怕只改一个逗号,Git 也会把整段报告为「删除并重新添加」。每句之后换行,报告的就只是变动的那一句。对审阅改动的合作者而言,仅此一点差别就足以脱胎换骨。既然换行不影响排版,随时把已有稿件改成一句一行都不会改变 PDF——不过这次转换会移动所有行,因此必须单独成一个提交,绝不能与内容改动混在一起。

terminal
# whole paragraph on one line: git rewrites the entire paragraph
-The fox jumps over the dog. The morning was fine. Nobody minded.
+The fox jumps over the dog. The morning was cold. Nobody minded.

# one sentence per line: git points at the sentence that moved
 The fox jumps over the dog.
-The morning was fine.
+The morning was cold.
 Nobody minded.

对中文、日文和韩文来说,这条建议不只是有用,而是迫在眉睫。英文还有一条退路:git diff --word-diff 即使面对长行,也只会以 [-旧-]{+新+} 的形式显示变动的词。可是 --word-diff 认定的词边界是 空白。在不加空格的中文上试一次就知道:把「吾輩は猫である」中的一个字改掉,Git 依然把整行删除再整行添加。想用 --word-diff-regex=. 逐字比较也无济于事:该模式按字节施加,多字节的 UTF-8 字符会被切碎,输出成 吾輩は?[-??-]{+??+}である。 这样的乱码。换句话说,CJK 稿件根本没有退路。一句一行在英文里是个好习惯,在中文、日文和韩文里则 几乎是唯一的办法

解决 .tex 的合并冲突——LaTeX 不会警告你

哪怕忘了删掉冲突标记就去跑 pdflatex也不会出现任何错误<<<<<<<=======>>>>>>> 都是正文模式下合法的字符序列,在 LaTeX 眼里不过是标点。于是编译以退出码 0 成功结束,生成的 PDF 里同时印着两个版本以及夹在中间的冲突标记。在默认的 OT1 编码下,<> 会映射为倒置标点,因此页面上会出现 ¡¡¡¡¡¡¡ HEAD¿¿¿¿¿¿¿ feature 这样陌生的两行——见到它们,请先怀疑冲突没有解决干净。

latex
% what git leaves behind - and what LaTeX happily typesets
\begin{document}
<<<<<<< HEAD
Main branch sentence.
=======
Feature branch sentence.
>>>>>>> feature
\end{document}

% check before every build:
%   git grep -n "^<<<<<<< " -- "*.tex"

解决过程本身与普通的 Git 工作无异:打开 git status 列出的文件,决定 <<<<<<<>>>>>>> 之间保留哪一侧(或把两侧重写为一段),删掉标记,然后 git add。有两点是 LaTeX 特有的。其一,如果冲突恰好落在 \begin{itemize}\end{itemize} 之间,只保留一侧就可能破坏开合配对,那时文档就真的编译不过了——删除标记时务必用眼睛确认每个环境都有开有合。其二,冲突是 可以预防的。只要一句一行,Git 就能按句自动合并,只要两人改的不是同一句,冲突根本不会发生。如果两人计划同时重写同一节,用 \include 拆分文件、按文件分工更为稳妥。

latexdiff — 把两个版本变成「看得见改动的 PDF」

latexdiff 输出的不是 PDF,而是 一个嵌入了变更标记的新 .tex 文件。自己把它编译一遍,就得到一份像文字处理软件修订视图那样的 PDF。默认样式下,新增的词显示为蓝色波浪下划线(ulem\uwave),删除的词显示为红色删除线(\sout);所需的 \RequirePackage 会自动补进生成文件的导言区。它插入的所有命令都以 \DIF 开头——\DIFadd\DIFdel\DIFaddbegin,浮动体内则是 \DIFaddFL——事后极易辨认。作者是 F. J. Tilmann,TeX Live 2024 随附的版本是 1.3.3。

terminal
latexdiff --flatten old.tex new.tex > diff.tex
pdflatex diff.tex        # additions blue and underlined, deletions red and struck out

# what latexdiff actually writes into the body:
#   The quick \DIFdelbegin \DIFdel{brown fox jumps }\DIFdelend
#   \DIFaddbegin \DIFadd{red fox leaps }\DIFaddend over the lazy dog.

这里值得留意的是,latexdiff按词 比较的。上面的例子把「brown fox jumps」与「red fox leaps」并置,而不是报告整行被替换。这与 git diff 的按行粒度形成对照,二者与其说竞争,不如说分工:Git 负责历史与自动合并,latexdiff 负责向合作者展示究竟改了什么。实务上有三个坑。用 \input\include 拆分的文档,若不加 --flatten,就只比较顶层文件。数学公式内部的标记粒度可用 --math-markup=level 调整,公式排乱时把它调粗即可。还有,生成的 diff.tex 并不是稿件:请让它保持独立的文件名,所有修改都写回原来的 .tex

latexdiff-vc 直接与 Git 修订版比较

不必手工导出旧版本。把修订号交给随附的 latexdiff-vc,例如 --git -r HEAD~3,它会临时检出那一版进行比较,并把差异文件写成 main-diffHEAD~3.tex。不指定版本管理系统时它会自行猜测,但用 --git--svn--hg--cvs--rcs 明确指定更稳妥。再加上 --pdf,它会对差异文件运行两遍 pdflatex,连 PDF 一起做出来。于是「从投稿到修改稿之间改了什么」这件事,向审稿人展示只需一条命令。别忘了把生成的 *-diff*.tex 写进 .gitignore

terminal
latexdiff-vc --git -r HEAD~3 main.tex     # writes main-diffHEAD~3.tex
latexdiff-vc --git --pdf -r v1.0 main.tex # ...and builds the PDF as well

# output of the run:
#   Running: latexdiff "main-oldtmp-15378.tex" "main.tex" > "main-diffHEAD~3.tex"
#   Generated difference file main-diffHEAD~3.tex

todonotes — 让页边备注在最终版里消失

\todo{...} 会在页边贴上一张彩色便签,\listoftodos 则把它们汇成一份清单。这是在稿件里留下「以后再改」的最小工具,它胜过 % TODO 注释之处只有一点:它印得出来。正因为看得见,才不会忘记。若希望备注打断正文,就用 \todo[inline]{...};若要为尚未绘制的图预留位置,就用 \missingfigure{...};若想让 TODO 清单本身进入目录,就加上 \todototoc。最终版只需改用 \usepackage[disable]{todonotes},一个 \todo 调用都不必删除,全部标注就从纸面上消失了。改传 obeyFinal,还能让这些备注自动跟随文档类自身的 final 选项。

latex
\usepackage{todonotes}          % [disable] hides every note in the final build
...
\todo{Citation needed here}
\todo[inline]{Rewrite this paragraph before submission}
\missingfigure{Circuit diagram goes here}
\listoftodos

changes — 按作者区分的修改标注与 Undefined changes author 错误

多人共同批改稿件时,changes 宏包正是称手的工具。\added{...}\deleted{...}\replaced{新}{旧}\highlight{...}\comment{...} 用来说明每处修改的意图,\listofchanges 汇成全部改动的索引,draft 选项显示标记,切换到 final 则不留痕迹。不过有一处人人第一次都会绊倒。若写了 \added[id=AB]{...} 却没有定义作者 AB,编译就会停在这里:! Package changes Error: Undefined changes author: AB. 随后还会跟着一串来自 xcolorUndefined color 错误,但根源都是同一个。在导言区加上一行 \definechangesauthor[name={...}, color=blue]{AB} 即可通过。为每位作者分配不同颜色正是这个宏包的要旨,因此每加入一位合作者,就相应添一行。

latex
\usepackage[draft]{changes}     % swap draft for final to hide all markup
\definechangesauthor[name={Ada Byron}, color=blue]{AB}
\definechangesauthor[name={Bob Lane}, color=orange]{BL}
...
\added[id=AB]{A sentence the reviewer asked for.}
\replaced[id=AB]{new wording}{old wording}
\deleted[id=BL]{This clause has to go.}
\listofchanges

在 Overleaf 上的合作者,以及只用 Word 的合作者

并不需要逼所有人都用 Git。Overleaf 可以只在浏览器里协同编辑,并自带历史记录与变更追踪界面,对不熟悉 LaTeX 的合作者来说往往更快。而且 Overleaf 的项目本身也可以当作 Git 仓库使用(可以 git clone)。搭好这座桥之后,分工就成立了:合作者在浏览器里写,你在本地 git pull,再用 latexdiff-vc 做出差异 PDF。要留意的是,上面关于 .gitignore 的道理照样适用;请一开始就确认同步设置,别把 Overleaf 那边生成的 PDF 和日志一并拉进来。

更棘手的是只用 Word 的合作者。明智的做法是 把 LaTeX 一侧定为正本。交稿时用 pandoc.tex 生成 .docx,带着修订记录返回的 .docx 再喂回 pandocpandoc--track-changesaccept(默认)、rejectall 三种取值,决定如何处理 Word 的修订:accept 应用全部插入与删除,reject 忽略它们,all 则连同作者与时间一起保留插入、删除和批注,从而可以只采纳某一位审阅者的改动。该选项仅在读取 .docx 时生效。每往返一次都会损失一些格式,但只要正本在 LaTeX 这边,失去的就只是格式,而不是稿件。

工具显示什么何时使用
git diff源文件的逐行差异需要历史、分支、分工与自动合并时
latexdiff排版成 PDF 的逐词差异需要让合作者或审稿人「在纸面上」看到改动时
todonotes页边便签与 TODO 清单有未完成之处、需要提醒自己时
changes按作者着色的修改与批注多人在同一份稿件上批改时
pandoc.tex.docx 之间的互相转换合作者只用 Word 工作时

把最终 PDF 发出去之前的检查

  • todonotes 切到 disablechanges 切到 final,然后真的打开 PDF,确认没有 TODO 和批改残留。
  • git grep -n "^<<<<<<< " -- "*.tex" 搜查遗留的冲突标记——LaTeX 不会为此报警。
  • 先用 latexmk -C 清除构建产物再做一次干净构建,以证明文档不依赖某个陈旧的中间文件。
  • 最后再看一次 git status,确认 latexdiff 生成的 *-diff*.tex 没有混进稿件目录。
  • 发给合作者时,把「源文件包」「差异 PDF」「最终 PDF」分开,并明说希望审阅哪一份。