本文已加入專欄文章目錄,歸入「進階使用」文章系列。
本文可以看作對這個發生于 2019 年 7 月中旬的 TeX-SX 上自問自答的展開說明。那個回答中避免了 python 的使用,而是利用 zref
宏包把位置信息以文本形式在 pdf 中呈現,好處是不用引入 python,壞處是如果寫成文章,需要額外介紹 zref
的使用。
問題的引入
fancyvrb
宏包提供了高度可配置的抄錄環境,功能大致上和 listings
相當。
有些配置項提供了「跳出抄錄環境,回到一般 latex」的功能,例如 commandchars
。它接受一串三個符號組成的值,分別代表命令開始、左側分組、右側分組(實際可以歸結到 catcode,此處略過)。
直接看 fancyvrb
文檔 Sec. 4.1.12 的例子

文檔截圖中的第二個例子展示了一種使用方式,利用 commandchars
為抄錄環境的某一行增加標簽(label
),然后在正文中引用(ref
)它來獲得行號。特別地,hyperref
包還會自動為行號添加超鏈接,點擊行號就能跳轉到抄錄環境中的對應行。
上一段的最后一個分句,只是描述了我們期望的行為,實際的編譯和測試結果不是這樣的,點擊引用(ref
)得到的行號后,無法跳轉到對應行。
工具和示例準備
除了靠手去點超鏈接,然后根據閱讀器跳轉的位置來判斷和分析,還可以借助工具直接讀取 PDF 文件里的超鏈接跳轉位置。例如,使用 Python 的 PyPDF2 庫,
from PyPDF2 import PdfFileReaderfname = 'xxx.pdf'
pdf = PdfFileReader(fname)
named_dests = pdf.namedDestinations.items()print('Coordinates of named destinations')
for k, v in named_dests:print(k, [v['/Left'], v['/Top']])top = None
print('nVertical distances between labels of line numbers')
for k, v in named_dests:if 'FancyVerbLine' in k or 'lstnumber' in k:curr = v['/Top']if top is not None:print(k, float(top - curr) / 72 * 72.27, 'pt')top = curr
有關 PDF 格式的補充說明:
- 「超鏈接跳轉位置」在 PDF 格式中稱為 named destination
- 每個 named destination 擁有一個全文檔唯一的名稱
- 它的內容,在本文中我們關心的是橫縱坐標信息,有時也關心它的目標頁面
- 它的使用,是成為某個 annotation(例如
hyperref
自動添加的)的跳轉目標
有關上述 python 腳本的說明:
- 第一組
print
,輸出文檔內所有 named destinations 的名稱和坐標 - 第二組
print
,僅輸出與fancyvrb
(和listings
,用于對照) 有關的相鄰 named destinations 的縱坐標差值
同時,使用以下 latex 示例文檔
(注意,示例中的 newpagenull
是特意添加的,為的是保證 pdf 閱讀器有跳轉,也就是把第一頁往上翻,的空間)
documentclass{article}
usepackage{fancyvrb}
usepackage{hyperref}% <possible config appears here>begin{document}
begin{Verbatim}[numbers=left, commandchars={}]
firstlabel{vrb:1}
secondlabel{vrb:2}
thirdlabel{vrb:3}
forthlabel{vrb:4}
fifthlabel{vrb:5}
sixthlabel{vrb:6}
aend{Verbatim}ref{vrb:1}, ref{vrb:2}, ref{vrb:3}, ref{vrb:4}, ref{vrb:5}, and ref{vrb:6}
newpagenull
end{document}
最后,需要留意示例文檔的編譯方式
如果使用 xelatex
,因為默認情況下 xdvipdfmx
會去掉未使用的 named destinations,并簡化所有 named destinations 的名稱,所以需要通過選項讓 xdvipdfmx
不對 named destinations 自動優化。
xelatex -no-pdf xxx
xelatex -no-pdf xxx
xdvipdfmx -C 0x0010 xxx
如果使用 pdflatex
或 lualatex
,直接使用即可。
不同引擎生成的 pdf 中,named destination 的信息有微小差異。本文默認使用 xelatex
。
初步嘗試
編譯 latex 示例文檔生成 pdf,點擊那六個超鏈接,可以發現它們都跳轉到同一位置。

執行 python 腳本讀取這個 pdf 里的信息,會獲得如下輸出
Coordinates of named destinations
Doc-Start [133.77, 667.2]
page.1 [132.77, 705.06]
page.2 [132.77, 705.06]Vertical distances between labels of line numbers
似乎六個 label
根本沒有生成六個不同的跳轉目標,連一個也沒有生成。如果直接使用 xelatex xxx.tex
,生成的 pdf 里就只有一條記錄
Coordinates of named destinations
0 [133.77, 667.2]
如果繼續使用 PyPDF2 的功能去看第一頁的所有 annotations 的跳轉目標(此處略去代碼),就可以完全確定:六個 label
完全沒有生成新跳轉目標,六個 ref
都跳轉去了當前頁的開始處(具體位置是 page.1
跳轉目標標記的、頁面版心的左上角)。
以上是從 pdf 一側進行的分析和探索。如果從 latex 一側進行,從相關宏包的源碼入手,則能了解到以下事實:
- 在
fancyvrb
內部負責遞增行號的宏FV@refstepcounter
的定義中,重寫了一遍 latex2e 中refstepcounter
的原始定義,刻意避免了直接使用refstepcounter
hyperref
重定義后的refstepcounter
會在展開時插入新的跳轉目的地, 并把該目的地儲存在@currentHref
中以供label
在內部引用(這則「事實」的展開介紹,可能需要額外的一篇或多篇文章,此處略過)
這樣,因為fancyvrb
在遞增行號時沒有使用 refstepcounter
,所以對應于新行號的跳轉位置無法生成,@currentHref
得不到更新,label
關聯的就變成了上一次更新過的 @currentHref
信息,也即 hyperref
在每一頁開頭默認插入的跳轉目標。
第一步嘗試很簡單,讓 FV@refstepcounter
成為 refstepcounter
letFV@refstepcounterrefstepcounter
繼續嘗試
修改保存、編譯 tex 文件、執行 python,會發現問題沒有完全解決。
Coordinates of named destinations
Doc-Start [133.77, 667.2]
FancyVerbLine.1 [133.77, 667.2]
FancyVerbLine.2 [133.77, 657.18]
FancyVerbLine.3 [133.77, 657.18]
FancyVerbLine.4 [133.77, 645.22]
FancyVerbLine.5 [133.77, 633.22]
FancyVerbLine.6 [133.77, 621.31]
page.1 [132.77, 705.06]
page.2 [132.77, 705.06]Vertical distances between labels of line numbers
FancyVerbLine.2 10.057574999999998 pt
FancyVerbLine.3 0.0 pt
FancyVerbLine.4 12.004850000000001 pt
FancyVerbLine.5 12.044999999999998 pt
FancyVerbLine.6 11.954662499999998 pt
從 python 腳本的輸出可以看出,雖然現在每個 label
都對應了不同的跳轉目標,但是目標之間的縱坐標差異并不一致。
- 預期輸出是,每兩個相鄰目標,在縱坐標上都相差 12pt(對應 latex 中
baselineskip
儲存的值,也即行距) - 實際得到的是,
- line 2 和 line 1 只差了 10pt(與字號有關,與行距無關,例如用
fontsize{10}{50}selectfont
修改行距后仍然是 10pt), - line 3 和 line 2 差 0pt,
- 后面的正常。
- line 2 和 line 1 只差了 10pt(與字號有關,與行距無關,例如用
推斷,FV@refstepcounter
展開的位置有問題。
根據對類似示例代碼的手動展開(見項目 muzimuzhi/latex-expansion 中以 fancyvrb 打頭的文件),判斷縱坐標差異應該源于 fancyvrb
對抄錄環境前三行的特殊處理(可能是為了控制在環境中間換頁的條件)具體涉及命令 FV@ListProcessLine@(i|ii|iii|iv)
。這幾個宏的具體作用,限于時間和水平筆者還沒能了解清楚。
筆者采取了一個討巧(但可能帶來其他未知問題)的解決方案:把 FV@refstepcounter
(具體是調用它的 FV@StepLineNo
宏 )的展開位置延遲到抄錄行文本剛要輸出之前,以保證通過 refstepcounter
遞增行號并插入新跳轉目標時,所處高度和抄錄文本行一致。
這樣,要做的修改就很簡單:把 FV@StepLineNo
從原來的位置刪掉,再在一個新的位置插入。
usepackage{etoolbox}% move FV@StepLineNo into FV@ListProcessLine
patchcmdFV@@PreProcessLine{FV@StepLineNo}{}{}{fail}patchcmdFV@ListProcessLine{kernleftmargin}{FV@StepLineNokernleftmargin}{}{fail}
從 pdf 閱讀器里的點擊跳轉效果,和 python 腳本的輸出看,問題似乎修好了。
其他
- 包含修改代碼的 tex 文檔,見項目 muzimuzhi/latex-examples 中的文件 fancyvrb-improvements.tex。文件中還包含修改行號引用風格的代碼,會在后續文章里介紹。
- 最困難的部分可能是定位問題和知道可以把
FV@StepLineNo
挪到哪,筆者主要是通過手動展開來探索的。 fancyvrb
被其他一些宏包依賴,依賴關系比較深的是tcolorbox -> minted -> fvextra -> fancyvrb
,文中介紹的嘗試,并未經過充分測試。