SyncTeX (正向/反向搜索)

排一篇十八页的文章,PDF 是 76,974 字节,落在它旁边的 .synctex.gz 却有 159,347 字节——比它所描述的东西还大一倍多。这份臃肿的地图就是 SyncTeX,它只做一件事:记住 LaTeX 源文件的哪一行变成了哪一页上的哪个矩形。本页会把这个文件解压开看个究竟,用 synctex viewsynctex edit 亲手跑通正反两个方向,解释为什么点击落在整行而不是你瞄准的那个词上,最后给出正向搜索毫无反应时的排查顺序。

-synctex=1 到底生成了什么

加上 -synctex=1,引擎就会在 PDF 旁边多写一个同名文件 main.synctex.gz。不加则什么都不写——这正是 SyncTeX 配置中最常见的遗漏。这个值不是布尔量而是一组位,man synctex 里写得明明白白:0 或不指定表示不生成,正值表示 gzip 压缩,负值表示不压缩的纯文本,置上 2 这一位则仍然压缩但文件名不带 .gz4 打开 pdfTeX 的 form 支持,8 采用更强的压缩。全部打开就是 -synctex=15。只有 LuaTeX 坚持用双横线形式 --synctex=1。这套机制在 TeX Live 和 MiKTeX 中同样内置,pdfLaTeX、XeLaTeX、LuaLaTeX 产出的地图形式完全相同。

terminal
pdflatex -synctex=1  main.tex     # writes main.synctex.gz
xelatex  -synctex=1  main.tex
lualatex --synctex=1 main.tex     # LuaTeX wants two dashes

pdflatex -synctex=-1 main.tex     # writes main.synctex, plain text
pdflatex -synctex=2  main.tex     # writes main.synctex -- still gzip inside!

2 这一位藏着一个小陷阱。-synctex=2 生成的文件名叫 main.synctex,但用 file 一看,里面仍是 gzip 数据。若相信扩展名而用 less 打开,只会看到一堆二进制,误以为 SyncTeX 写坏了文件。只是想读内容的话,老老实实用 -synctex=-1。如果碰不到命令行(例如一键排版的图形界面),也可以在源文件开头用 TeX 原语 \synctex=1 打开。但这条路 只会给你压缩版本:在本机的 TeX Live 2024 上,即使写成 \synctex=-1,出来的仍是 main.synctex.gz。想要纯文本,只能走命令行。

取值生成的文件内容
(none)什么都不写;两个方向都不工作
-synctex=0与不指定相同;显式关闭时使用
-synctex=1main.synctex.gzgzip 压缩;日常就用这个
-synctex=-1main.synctex纯文本;排查问题时用
-synctex=2main.synctex名字像未压缩,内容却是 gzip;容易误会
-synctex=15main.synctex位 1+2+4+8:含 form 支持与更强压缩

解压 .synctex.gz,读一读里面写了什么

内容是面向行的纯文本,用 gunzip -c main.synctex.gz 一过就能直接读。结构分四段:前言(preamble)、正文(content)、后记(postamble)、附言(post scriptum)。前言里有版本号和 Input:,TeX 打开过的每个文件都会从 1 开始编号(tag)。不只是你的 main.texarticle.clssize10.clo、每个 .sty、还有 main.aux 都会占一行——地图之所以这么臃肿,一半原因就在这里。接下来的 MagnificationUnitX OffsetY Offset 定义坐标系:Unit:1 表示下面所有数字的单位是 sp(scaled point,1pt 的 65536 分之一),而 X Offset:4736287 恰好是一英寸,正是 TeX 历来从纸张左上角留出的那段边距。

terminal
$ gunzip -c main.synctex.gz        # abridged; ... marks omitted lines
SyncTeX Version:1
Input:1:/home/you/doc/./main.tex
Input:2:/usr/local/texlive/2024/texmf-dist/tex/latex/base/article.cls
Input:3:/usr/local/texlive/2024/texmf-dist/tex/latex/base/size10.clo
...
Input:10:/home/you/doc/./chap.tex
Output:pdf
Magnification:1000
Unit:1
X Offset:4736287
Y Offset:4736287
Content:
!962
{1
[1,17:4736286,46220574:26673152,41484288,0
(1,4:8799518,8865054:22609920,655359,0
x1,4:9330359,8865054
k1,4:31409438,8865054:11586343
...
)
]
}1

正文是一串嵌套的盒子记录。{1}1 是一「张」,也就是一页;方括号 [] 是竖直盒子,圆括号 () 是水平盒子。每个开括号的形状是 tag,行:x,y:宽,高,深,于是 (1,4:8799518,8865054:22609920,655359,0 的意思是「由 tag 1(即 main.tex)第 4 行生成的水平盒子」。上例中 main.tex 的第 4 行正是 \section{Forward and inverse}。行首的那个字符表示记录类型:x 是当前位置,k 是 kern,g 是 glue,$ 是数学,f 是 pdfTeX 的 form 引用,vh 是空的竖直盒与水平盒,! 则是字节偏移,供从文件中间开始读取。

以这样的粒度记录每一页,文件自然会胖。开头那篇十八页的文章,压缩后的地图是 159,347 字节,解压后是 638,962 字节——超过 PDF 本身的八倍,共 24,717 行。所以 .synctex.gz 不是交付物,而是可以重新生成的工作文件:把它写进 .gitignore,再加进 latexmk 的 @generated_exts 让清理命令一并扫掉。顺带一提,synctex(5) 的手册页说得很直白:这个结构不应被视为公开规范,除了 synctex 命令和 synctex_parser 库之外,谁也不需要去解析它。读一读以理解问题当然可以,但不要写一个长期依赖它的工具。

perl
# .latexmkrc -- keep -synctex=1 on every build, and clean the map up afterwards
$pdf_mode  = 1;
$pdflatex  = 'pdflatex -synctex=1 -interaction=nonstopmode -file-line-error %O %S';
@generated_exts = (@generated_exts, 'synctex.gz');

在命令行上亲手跑一遍正向搜索和反向搜索

正向搜索(forward search,源 → PDF)synctex view反向搜索(inverse search,PDF → 源)synctex edit。编辑器和查看器在按钮背后调用的就是这两个命令或其等价物,所以当 LaTeX 的反向搜索出问题时,直接敲这两条就能一次分清病因:是地图坏了,还是编辑器与查看器之间的握手坏了。正向搜索接受 -i 行:列:文件-o pdf,返回页码和一个矩形。

terminal
$ synctex view -i 5:1:main.tex -o main.pdf
This is SyncTeX command line utility, version 1.5
SyncTeX result begin
Output:main.pdf
Page:1
x:153.195526
y:156.585541
h:133.768356
v:158.522720
W:343.711060
H:8.855677
before:
offset:-1
middle:
after:
SyncTeX result end

xy 是「请显示这里」的那个点,hvWH 则是应当高亮的矩形的左边、基线、宽和高。单位是 PDF 的点(bp),所以 v:158.52 意为距页面顶端 158.52pt。查看器拿到这些数字后滚动页面,并把 W × H 的那一条闪一下。反方向不过是把坐标扔回去而已。

terminal
$ synctex edit -o 1:153.19:156.58:main.pdf
This is SyncTeX command line utility, version 1.5
SyncTeX result begin
Output:main.pdf
Input:/home/you/doc/./main.tex
Line:5
Column:-1
Offset:0
Context:
SyncTeX result end

参数形如 -o 页:x:y:pdf,返回的是 文件的绝对路径和行号。查看器把这里的 Input:Line: 填进启动编辑器的命令里去调用。值得注意的是 Column:-1。这个格式本身能表示列号,但引擎并不写出列号,所以反向搜索实际上永远只精确到行。编辑器把光标放在行首正是因为这个,而不是配置有误。

为什么会跳到「整行」而不是你点的那个词

因为对应关系的最小单位是 排版出来的盒子。TeX 先把一个段落拉成一条长长的水平队列,直到最后才一次性切成若干行。SyncTeX 记住的只是切出来的盒子以及生成该盒子的那一行源代码,不记单词,也不记字符。实测一下,这种不对称就很明显:把十二个短词写在连续十二行上、中间不留空行,它们会塌缩成 仅仅两个行盒。依次对源文件第 5 到第 12 行做正向搜索,返回的坐标全都一样。

terminal
# twelve short source lines 5..16, no blank line between them
$ for l in 5 6 7 8 9 10 11 12 13 14 15 16; do
>   printf "src %-3s " $l; synctex view -i $l:1:res.tex -o res.pdf | grep "^v:"
> done
src 5   v:230.405960
src 6   v:230.405960
src 7   v:230.405960
src 8   v:230.405960
src 9   v:230.405960
src 10  v:230.405960
src 11  v:230.405960
src 12  v:230.405960
src 13  v:242.361130
src 14  v:242.361130
src 15  v:242.361130
src 16  v:242.361130

有趣的是,反方向要聪明一些。沿着同一个行盒从左往右依次调用 synctex edit,返回的源代码行会随水平位置而变化;而且同一个点常常返回多个候选,查看器通常取第一个。也就是说,正向搜索粗,反向搜索细。反过来看:在一段由一整行长源代码折成八个排版行的段落里,点击其中任何一行返回的都是第 3 行——因为可记的源代码行本来就只有一条。在 TikZ 图内、复杂宏的展开结果里、表格内部偏出一两个词,说的也都是同一件事:盒子的粒度,而不是缺陷。

terminal
# same typeset line (baseline v=230.4), scanning left to right
$ for x in 135 185 235 310 360 435 460; do
>   printf "x=%-4s " $x; synctex edit -o 1:$x:229:res.pdf | grep "^Line:" | tr "\n" " "; echo
> done
x=135  Line:5
x=185  Line:5
x=235  Line:6 Line:7
x=310  Line:7 Line:8
x=360  Line:9 Line:10
x=435  Line:10 Line:11
x=460  Line:11 Line:12

由此可以得到一条实用结论:把源代码写成一整行的话,SyncTeX 对那一整段的分辨率就退化成了一个点。 反过来,一句一行、或者至少在从句边界断行,反向搜索就会明显对准。让版本控制差异易读的写法,和让 SyncTeX 精确的写法,恰好是同一种写法。

为什么用了 \input 子文件之后行号会对不上

先说结论:\input 本身并不会造成偏移。 每条记录除了行号还带一个 tag,tag 指向 Input: 表。子文件有自己的 tag,行号也是该子文件内部的行号。实测中,点击由 \input{chap} 引入的那一章内部,Input: 返回的是 chap.texLine: 是它内部的行号。串起二十章来,编号也不会累加。

真正的原因有两个。其一是 地图过期.synctex.gz 是某一次编译的快照,所以若在 chap.tex 开头加了三行却没有重新编译就做反向搜索,地图仍然回答 Line:3,尽管正文已经挪到第 6 行。若偏移量恰好等于你插入的行数,基本可以断定是它。其二是 绝对路径。写进 Input: 的是编译当时的完整路径,因此挪动项目、经由符号链接打开、或者在容器里编译而在容器外查看,都会让查看器把编辑器指向一个已不存在的路径。如果打开的是完全不同的文件(或者什么都没打开)而不是错行,就该怀疑这一条。

各查看器的反向搜索命令,以及占位符的差异

反向搜索的设置写在 查看器一侧。你交给查看器一个模板:一旦有人点击,就把这个行号和这个文件名填进去,然后执行这条命令。麻烦在于 占位符的写法各家不同:zathura 用带花括号的 %{line}%{input},Skim 用 %line%file,SumatraPDF 和 Okular 用 %l%f。从别处抄来的配置跑不通,多半就是这个原因——命令本身没错,只是占位符对不上。

查看器主要平台行与文件的占位符
zathuraLinux / BSD%{line}%{input},写在 set synctex-editor-command
SkimmacOS%line%file,在 Preferences ▸ Sync ▸ Preset: Custom
SumatraPDFWindows%l%f,填在 Settings ▸ Options 的 inverse search 栏
OkularLinux / Windows%l%f,在设置 ▸ 编辑器中指定(Kile 用 kile --line %l
Adobe Acrobat / Reader全部不支持 SyncTeX;反向搜索根本无从谈起
ini
# zathura (~/.config/zathura/zathurarc)
set synctex true
set synctex-editor-command "nvim --headless -c 'VimtexInverseSearch %{line} %{input}'"

# Skim  -- Preferences > Sync > Preset: Custom
Command:   nvim
Arguments: --headless -c "VimtexInverseSearch %line '%file'"

# SumatraPDF -- Settings > Options > inverse search command-line
cmd /c start /min "" nvim --headless -c "VimtexInverseSearch %l '%f'"

# Okular -- Settings > Configure Okular > Editor
kile --line %l

从编辑器一侧触发 正向搜索 很直接:TeXShop 配 Skim 是在 PDF 上 Cmd + 点击,反方向是 Shift + Cmd + 点击。TeXstudio 用 Ctrl + 点击,或菜单里的「转到 PDF」「跳到源」。VS Code 的 LaTeX Workshop 是 Ctrl/Cmd+Alt+J。这里要点名一个 macOS 专属的坑:macOS 自带的 /usr/bin/vim 是以 -clientserver 编译的,外部根本没有回调编辑器的通道,于是常见的那段反向搜索配置写了也悄无声息。解法是改用 MacVim、Homebrew 的 Vim,或者 Neovim。

走 DVI 路线(pLaTeX / upLaTeX → dvipdfmx)时会怎样

先给结论:在默认设置下你什么都不用做,坐标与直接产出 PDF 的路线是一致的。 -synctex=1 要传给 引擎platexuplatex),而不是转换器。引擎在写出 DVI 的同时也写出 .synctex.gz,其前言里的 Output:dvi 而不是 pdf。之后再跑 dvipdfmx,它根本不会碰这张地图——本机用 cmp 比对 dvipdfmx 前后的文件,逐字节完全相同;何况 dvipdfmx 压根就没有 -synctex 选项。顺带一提,TeX Live 2024 里的 dvipdfmx指向 xdvipdfmx 的符号链接,与 XeTeX 用的转换器是同一个二进制。

terminal
# the same source, both routes, same forward-search question
$ pdflatex -synctex=1 cmp.tex && synctex view -i 3:1:cmp.tex -o cmp.pdf
h:133.768356   v:137.554138

$ latex -synctex=1 cmp.tex && dvipdfmx cmp.dvi
$ synctex view -i 3:1:cmp.tex -o cmp.pdf
h:133.768372   v:137.554153

# the two agree to about 2e-5 pt -- nothing needs reconciling

那么 synctex update 是干什么的?正如手册所写:在 dvi/xdv → pdf 的过滤器跑过之后更新 SyncTeX 文件——而它只在 转换时指定了放大率或偏移 的情况下才需要。-m / -x / -y 应当填入与传给过滤器相同的值。有趣的是它的实现:synctex update 并不改写地图内容。实测加上 -x 20mm 跑一遍再逐字节比对,发现它只是在文件末尾 Post scriptum: 之后 追加 了一段 gzip 数据。解开来只有一行:X Offset:20mm。也就是说,格式的第四部分「附言」正是留给下游转换器像贴便签一样贴上坐标系修正的位置。日常工作中 ptex2pdf 或 latexmk 会替你跑完这一串,几乎不会碰到这个话题。

SyncTeX 毫无反应时,按顺序该看哪里

先看 PDF 所在的同一个文件夹里有没有 .synctex.gz。没有的话,说明构建命令里缺了 -synctex=1。这里最容易漏掉的是 编辑器自带的默认构建设置。例如 Kile 随附的 PDFLaTeX 工具,其默认选项里就没有 -synctex=1,这正是「我明明配置过了却什么都不同步」的头号原因。请记住:在编辑器界面上勾选一个 SyncTeX 选项,未必改变了真正执行的那条命令。

  • 地图存在吗?ls 确认有没有 .synctex.gz。没有就往构建命令里加 -synctex=1——并且对编辑器的默认设置抱持怀疑。
  • PDF 和地图被分开了吗?-output-directory 没有问题,因为两者会一起落到输出目录,但 只把 PDF 单独复制出去,地图不会跟着走,于是什么都不会发生。 实测:从 build/ 里只复制 main.pdf 出来之后,synctex view 一声不响就结束了。
  • 地图过期了吗? 保存之后重新构建了吗?如果偏移量正好等于你刚插入的行数,那就确定了。 用 latexmk 的 -pvc 让每次保存都重新编译,这个失败基本上就不会发生了。
  • 你排的真是这份文档吗? 单独编译一个章节文件,得到的地图描述的是那一章的 PDF,而不是整本书的。检查编辑器的「主文件」或「根文档」设置指向的地方是否如你所想。
  • 查看器支持 SyncTeX 吗? Adobe Acrobat / Reader 根本无法做反向搜索。换用 Skim(macOS)、SumatraPDF(Windows),或 Okular、zathura(Linux)。
  • 占位符对了吗?%{line}%line%l 弄混,恰恰因为命令的其余部分都对而格外难以察觉。
  • 在命令行上分割问题。 直接跑 synctex viewsynctex edit。如果它们回答正确,说明地图健康,毛病出在编辑器与查看器的握手上。另外 synctex 即便什么也没找到也返回退出码 0,所以脚本必须检查输出内容而不是状态码。

最后,写下把 SyncTeX 从「一个设置项」变成 校对习惯 的那个循环:读 PDF,点一个碍眼的词,落到源代码里,改掉,保存,重新构建,再用正向搜索回到刚刚改过的位置。这个循环一旦转得顺,你在长文里翻找该改哪儿的时间就归零了。Jérôme Laurens 为自己的作品取的名字 Synchronize TeXnology 听起来颇为宏大,但它实际带来的就只有这一点:不必再找。