书签与元数据

拿一份加载了 hyperref 的 LaTeX 文档,清空辅助文件,只编译一次。得到的 PDF 里一条书签也没有——大纲要到第二次编译才出现。原因在于书签的生成方式:hyperref 会把标题写进一个叫 jobname.out 的旁路文件,并在下一次运行开始时读回来,然后才写进 PDF。本页顺着这个机制往下讲:把 .out 文件彻底扔掉、一次编译就搞定的 bookmark 宏包,用 \hypersetup 设定的 PDF 元数据,新的 \DocumentMetadata 入口,以及日文书签乱码的问题和解法——每一条都用真实的 pdfinfo 输出佐证。

书签由 hyperref 从标题自动生成

只要写 \usepackage{hyperref}\chapter\section\subsection 就会变成 PDF 大纲。配置通过宏包选项或 \hypersetup{} 完成,常用的有四个:bookmarks(默认开启)、bookmarksnumbered(书签里也带上章节号)、bookmarksopen(初始展开)、bookmarksopenlevel=N(展开到多深)。打开中间文件 jobname.out 会看到一串 LaTeX 宏调用——而且字符串不是明文,而是 UTF-16BE,所以连英文标题都写成 \376\377\000C\000o\000v\000e\000r,每个字符前都插了一个 \000。开头的 \376\377 是 UTF-16 的字节序标记,因为 PDF 的文本字符串就是这么规定的。

latex
\usepackage[bookmarksnumbered,bookmarksopen,bookmarksopenlevel=1]{hyperref}
% or set the same keys later
\hypersetup{bookmarksopenlevel=1}
log
% report.out after three passes — hyperref stores the outline here
\BOOKMARK [0][]{cover.0}{\376\377\000C\000o\000v\000e\000r}{}% 1
\BOOKMARK [0][]{chapter.1}{...1 Foundations...}{}% 2
\BOOKMARK [1][-]{section.1.1}{...1.1 First section...}{chapter.1}% 3
\BOOKMARK [2][-]{subsection.1.1.1}{...1.1.1 A subsection...}{section.1.1}% 4

深度的默认值不是来自书签机制,而是来自目录report 类的 tocdepth 是 2(到 subsection 为止),所以写了 \subsubsection 也不会出现在大纲里。这是有意为之,目的是让目录与书签的粒度一致。想让书签比目录更深时,用 bookmarksdepth——实测加上 bookmarksdepth=4 后,未进入目录的 \subsubsection 只出现在书签里。反过来设 bookmarksdepth=1,大纲就收到节为止。

选项作用默认
bookmarks是否生成大纲true
bookmarksnumbered书签文字里包含章节号false
bookmarksopen打开时展开显示false
bookmarksopenlevel展开到第几层全部
bookmarksdepth进入大纲的最深层级跟随 tocdepth

在没有标题的地方加书签——\pdfbookmark

对于封面、目录、无编号前言这类不经过分节命令的位置,直接写 \pdfbookmark[level]{显示文字}{锚点名}。第一个参数的层级是数字(\chapter 为 0,\section 为 1),第三个参数的锚点名必须在文档内唯一,否则目标位置会冲突。要在当前层级追加,用 \currentpdfbookmark{文字}{锚点};要深一层,用 \belowpdfbookmark{文字}{锚点}。实务中最常见的场景是给目录本身加书签:在 \tableofcontents 前放一行即可——没有它,读者就会拿到一份哪儿都能跳、唯独回不了目录的怪 PDF。

latex
\begin{document}
\pdfbookmark[0]{Cover}{cover}      % level 0, same rank as \chapter
\maketitle
\clearpage
\pdfbookmark[1]{Contents}{toc}     % the classic missing bookmark
\tableofcontents
\chapter{Foundations}

bookmark 宏包:扔掉 .out 文件,一次编译搞定

把 Heiko Oberdiek 的 bookmark 宏包(TeX Live 2024 随附 v1.31,2023-12-10)加载在 hyperref 之后,整套书签机制就被替换掉了。一测便知:只用 hyperref 时,从干净目录出发的第一次编译,生成的 PDF 里根本没有 /Outlines 对象,要到第二次才带上那 7 个条目。加上 bookmark 后,第一次编译就已经有全部 7 个条目。窍门很简单——bookmark 不写 .out 文件(可以验证:目录里不会出现)。它把大纲信息经由 .aux 传递,于是「读回一个已经过时的旁路文件」这一步就消失了。hyperref 自身的书签会自动关闭,因此不会冲突。

第二个好处是外观。\bookmarksetup{} 接受 numbered(包含章节号)、openopenlevel,以及逐条的样式——color=bluebolditalic。翻看生成的 PDF,每个大纲条目上确实写入了 /C [ … ] 颜色项。只想改某一条时,在它前面紧接着放 \bookmarksetupnext{color=red}。在长篇报告里只给附录和索引换个颜色,侧边栏一眼就清楚多了。

latex
\usepackage{hyperref}
\usepackage{bookmark}      % must come after hyperref
\bookmarksetup{numbered, open, openlevel=1, color=blue}

% one entry only
\bookmarksetupnext{color=red, bold}
\chapter{Appendix}

PDF 元数据:用 \hypersetup 写清标题与作者

查看器「文档属性」里显示的内容由 \hypersetup{} 的四个键决定:pdftitlepdfauthorpdfsubjectpdfkeywords。它们不会从 \title\author 自动抄过来,所以两边都得写——hyperref 需要在 \maketitle 之前就知道这些值。设没设成功,敲一次 pdfinfo 就一目了然。pdfcreatorpdfproducer 标识生成软件,通常会自动填好:加载了 hyperref 的 pdfLaTeX 输出会写 Creator: LaTeX with hyperrefProducer: pdfTeX-1.40.26。这两项可以覆盖,但覆盖之后就抹掉了「这个文件是怎么做出来的」这唯一线索,还是别动为好。

latex
\usepackage{hyperref}
\hypersetup{
  pdftitle={Measured Bookmarks},
  pdfauthor={Ada Lovelace},
  pdfsubject={PDF navigation},
  pdfkeywords={LaTeX, hyperref, bookmarks}
}
terminal
$ pdfinfo report.pdf
Title:           Measured Bookmarks
Subject:         PDF navigation
Keywords:        LaTeX, hyperref, bookmarks
Author:          Ada Lovelace
Creator:         LaTeX with hyperref
Producer:        pdfTeX-1.40.26
Pages:           5
Page size:       595.276 x 841.89 pts (A4)
PDF version:     1.5

带重音的字符现在可以直接写。TeX Live 2024 随附的 hyperref 7.01h 内部默认 \Hy@unicodetrue,所以 pdftitle={Théorie des catégories — Übersicht} 在 pdfLaTeX 下也能原样出现在 pdfinfo 里;过去必须显式加的 unicode 选项已经不需要了。真正仍会咬人的是:\hypersetup 的值会作为字符串直接写进 PDF,所以实务铁律是别放宏进去——写 pdftitle={\LaTeX{} 使用指南} 很容易展开失败,老老实实写 pdftitle={LaTeX 使用指南} 才稳。

\DocumentMetadata:元数据与标签化的新入口

\DocumentMetadata{…} 是 LaTeX 内核较新的声明,要放在 \documentclass 之前。它在 TeX Live 2024 上确实可用,接受 lang=en-GB(文档语言)、pdfversion=2.0pdfstandard=A-2B(PDF/A 等级,从 A-1BA-4)、uncompress(关闭全部压缩)等键。哪怕只加一行,效果也看得见:pdfinfo 里的 Metadata Stream 会从 no 变成 yes,因为 PDF 里多了一段 XMP 元数据流。原有的 \hypersetup 各键可以照常并用,实测两边的值都会正确写进 PDF。

再往前一步就是带标签的 PDF。加上 testphase={phase-III} 并跑两遍 pdflatexpdfinfoTagged 就会变成 yes——意思是 LaTeX 已开始把段落与标题的结构写进 PDF 的结构树。正如键名所示,这仍是试验阶段的功能,不该在最终投稿版里无条件打开;但值得知道的是,标准 TeX Live 里就带着一个能用的版本。另外 \DocumentMetadata纸张尺寸有副作用,往既有文档里加时请确认一下 PDF 的尺寸(详见「PDF 的生成与控制」)。

latex
\DocumentMetadata{pdfversion=2.0, lang=en-GB, testphase={phase-III}}
\documentclass{article}
\usepackage{hyperref}
\hypersetup{pdftitle={Tagged Test}, pdfauthor={Ada Lovelace}}
% pdfinfo then reports: Tagged: yes / Metadata Stream: yes / PDF version: 2.0

日文书签乱码——pxjahyperdvipdfmx 选项

要让 upLaTeX + dvipdfmx 正确输出日文书签,需要两处修补。第一处是告诉 hyperref 它在为哪个驱动写东西。只写 \usepackage{hyperref} 时,日志里会出现 Package hyperref Info: Driver (default): hdvips.——明明生成的是 DVI,hyperref 却写出了面向 dvips 的 \special。把这样的 DVI 交给 dvipdfmx,就会刷出一串 dvipdfmx:warning: Unknown token "SDict"Interpreting special command ps: (ps:) failed.,得到一份书签和链接全都没有的 PDF。改写成 \usepackage[dvipdfmx]{hyperref},日志就变成 Driver: hdvipdfm.,警告归零。

第二处是字符编码。改好驱动后书签能出来了,但日文标题会变成 æ鞥æ鲬èꪞã膮èꚋå螺ã膗 这样的乱码。看一眼 .out 文件就明白:日 本应在 UTF-16BE 下变成 \145\345 两个字节,可它的三个 UTF-8 字节却各自被当成一个字符,被填充成了 \000\346\000\227\000\245。这时加上 \usepackage{pxjahyper}(八登崇之 作,TeX Live 2024 随附 v1.3),.out 就变成正确的 UTF-16BE——\376\377\145\345\147\054\212\236…,而 pdfinfoTitle 也读得出 日本語のタイトル 了。关键在于:它同时修好了 pdftitlepdfauthor,不只是书签。

latex
% upLaTeX -> dvipdfmx: both lines are needed
\documentclass{ujarticle}
\usepackage[dvipdfmx]{hyperref}   % without this: dvipdfmx warning, no outline
\usepackage{pxjahyper}            % without this: mojibake in the outline
\hypersetup{pdftitle={...}, pdfauthor={...}}

这套两步走只在 (u)pLaTeX 的 DVI 路线上才需要。LuaLaTeX + LuaTeX-ja 用最朴素的 \usepackage{hyperref} 就能得到正确结果——日志写 Driver (autodetected): hluatex..out 从一开始就是正确的 UTF-16BE。XeLaTeX + xeCJK 同样不需要额外宏包,书签就能读。如果日文文档的书签乱码反复折磨你,换引擎往往是最短的解法。顺序上还有一点:hyperref 应尽量靠后加载,但 cleveref 必须在 hyperref 之后,顺序弄反就会以 ! Package cleveref Error: cleveref must be loaded after hyperref!. 中断。若同时用 varioref,顺序是 hyperrefvariorefcleveref