从 TeX 到 PDF 的过程¶
背景¶
今天跑 xdvipdfmx
的时候出现了报错,忽然想研究一下,DVI 格式是什么,TeX 是如何一步步变成 PDF 的。
流程¶
实际上从 TeX 到 PDF 有不同的工具,其中可能经历了不同的转化过程。
我们今天来看一种比较原始的转换方式:从 TeX 到 DVI,从 DVI 到 PS,再 PS 到 PDF,主要目的是看看这些格式内部都是什么样子的。
从 TeX 到 DVI¶
举一个很小的例子,例如 test.tex
有如下的内容:
在命令行中运行 tex test.tex
,可以看到它生成了 test.dvi
文件:
$ tex test.tex
(test.tex [1] )
Output written on test.dvi (1 page, 228 bytes).
Transcript written on test.log.
那么 DVI 就是 TeX 引擎输出的默认格式了。我们可以用 dviinfox 和 dviasm 工具来看它的一些信息:
$ dviinfox test.dvi
test.dvi: DVI format 2; 1 page
Magnification: 1000/1000
Size unit: 1000x25400000/(1000x473628672)dum = 0.054dum = 1.000sp
Page size: 469ptx667pt = 16.510cmx23.449cm
Stack size: 2
Comment: " TeX output 2022.08.05:2055"
Font 0: cmr10 at 10.000 (design size 10.000, checksum=1274110073)
$ dviasm test.dvi
[preamble]
id: 2
numerator: 25400000
denominator: 473628672
magnification: 1000
comment: ' TeX output 2022.08.05:2058'
[postamble]
maxv: 667.202545pt
maxh: 469.754990pt
maxs: 2
pages: 1
[font definitions]
fntdef: cmr10 at 10pt
[page 1 0 0 0 0 0 0 0 0 0]
push:
down: -14pt
pop:
down: 643.202545pt
push:
down: -633.202545pt
push:
right: 20pt
fnt: cmr10 at 10pt
set: 'Hello,'
right: 3.333328pt
set: 'w'
right: -0.277786pt
set: 'orld!'
pop:
pop:
down: 24pt
push:
right: 232.377487pt
set: '1'
pop:
可以看到,它定义了文档的一些尺寸和字体信息,然后主体部分就是每个页面上需要绘制的内容:
push:
down: -14pt
pop:
down: 643.202545pt
push:
down: -633.202545pt
push:
right: 20pt
fnt: cmr10 at 10pt
set: 'Hello,'
right: 3.333328pt
set: 'w'
right: -0.277786pt
set: 'orld!'
pop:
pop:
down: 24pt
push:
right: 232.377487pt
set: '1'
pop:
可以看到,它保存了一些命令,就像是在移动光标,然后输出文字:
- 向下移动 643.20 pt
- 向上移动 633.20 pt
- 向右移动 20.00 pt
- 设置字体为 cmr10
- 输出 "Hello,"
- 向右移动 3.33 pt
- 输出 "w"
- 向左移动 0.28 pt
- 输出 "orld!"
实际上,它的编码也比较简单,就是一个字节的命令加上若干字节的参数。DVI 二进制格式详细的文档可见 https://www.mn.uio.no/ifi/tjenester/it/hjelp/latex/dvi.pdf。
从 DVI 到 PS¶
有了 DVI 文件以后,下一步是用 dvips
工具来生成 PS 文件:
$ dvips test.dvi
This is dvips(k) 2020.1 Copyright 2020 Radical Eye Software (www.radicaleye.com)
' TeX output 2022.08.05:2058' -> test.ps
</usr/share/texlive/texmf-dist/dvips/base/tex.pro>
</usr/share/texlive/texmf-dist/dvips/base/texps.pro>.
</usr/share/texlive/texmf-dist/fonts/type1/public/amsfonts/cm/cmr10.pfb>[1]
DVI 是二进制格式,而 PS 是纯文本格式,我们可以用编辑器打开,看到里面大概有几部分内容:
- 开头的元数据
- tex.pro 文件的内容
- texps.pro 文件的内容
- 定义 CMR10 字体
- 描述文档内容
让我们直接来看最后一部分:
TeXDict begin 39158280 55380996 1000 600 600 (test.dvi)
@start /Fa 136[60 4[33 2[42 2[23 6[37 46 27[62 22[42
4[23 10[23 33[{}10 83.022 /CMR10 rf end
%%EndProlog
%%BeginSetup
%%Feature: *Resolution 600dpi
TeXDict begin
%%BeginPaperSize: a4
/setpagedevice where
{ pop << /PageSize [595 842] >> setpagedevice }
{ /a4 where { pop a4 } if }
ifelse
%%EndPaperSize
end
%%EndSetup
%%Page: 1 1
TeXDict begin 1 0 bop 166 83 a Fa(Hello,)28 b(w)n(orld!)1929
5539 y(1)p eop end
%%Trailer
userdict /end-hook known{end-hook}if
%%EOF
看到这个,肯定觉得很疑惑,这都是啥?除了隐约可以看到的 Hello,
w
orld!
字样以外,有很多不明含义的字母和代码。
为了读懂这些代码在做什么,首先来学习一下 PostScript。PostScript 是一个基于栈的语言,类似 Forth,所以很多运算都和我们平时看到的不一样。例如:
实际上做的事情是,判断 userdict 中是否存在 /end-hook
,如果存在,则展开它。它的计算过程是:
userdict
userdict /end-hook
userdict /end-hook known
true
true {end-hook}
true {end-hook} if
end-hook
那么回过头来看 Hello, world!
相关的代码:
这里出现的 TeXDict
bop
a
等等应该也是在前面定义的了。往回翻,发现正是 tex.pro
文件定义了这些对象。让我们一点点来看:
表示 TexDict
会展开为 300 dict
,即创建一个最大容量为 300 的 dict。接下来的 begin 和 end 就是在这个 dict 的作用域中进行运算。
接下来是 1 0 bop
,那么我们要看 bop
的定义,根据 DVI 中同名的命令,我们知道它的意思是 begin of page
:
/bop % %t %d bop - -- begin a brand new page, %t=pageno %d=seqno
{
userdict /bop-hook known { bop-hook } if
/SI save N
@rigin
%
% Now we check the resolution. If it's correct, we use RV as V,
% otherwise we use QV.
%
0 0 moveto
/V matrix currentmatrix
A 1 get A mul exch 0 get A mul add .99 lt
{/QV} {/RV} ifelse load def
pop pop
} N
这里有一些代码的含义我还不清楚,先跳过。
接下来的 166 83 a
,根据定义就可以判断出来它是在移动位置:
紧随其后的 Fa
指的是字体。
接下来是 (Hello,)
,即把 Hello,
这些文字压入栈。
接下来看到 28 b
,它输出了栈顶的文本,进行了一个相对的水平移动,并且更新了 delta:
/delta 0 N % we need a variable to hold space moves
%
% The next ten macros allow us to make horizontal motions that
% are within 4 of the previous horizontal motion with a single
% character. These are typically used for spaces.
%
/tail { A /delta X 0 rmoveto } B
/M { S p delta add tail } B
/b { S p tail } B % show and tail!
后面的也都是类似的操作,让我们简单来总结一下 TeXDict begin 1 0 bop 166 83 a Fa(Hello,)28 b(w)n(orld!)1929 5539 y(1)p eop end
都做了什么:
1 0 bop
: 创建了新页面166 83 a
: 移动坐标到(166, 83)
Fa
: 设置字体(Hello,)
: 压栈 "Hello,"28 b
: 输出栈顶,移动坐标,对应dviasm
输出中的right: 3.333328pt
(w)
: 压栈 "w"n
: 输出栈顶,移动坐标,对应dviasm
输出中的right: -0.277786pt
(orld!)
: 压栈 "orld!"1929 5539 y
: 输出栈顶,移动坐标到页码的位置,对应dviasm
输出中的down: 24pt
和right: 232.377487.pt
(1)
: 压栈 "1"p
: 输出栈顶eop
: 结束页面
由此我们基本明白了从 DVI 到 PS 是怎么一个流程:
- 首先在
tex.pro
中定义了一些函数,来实现 DVI 中的命令 - 把 DVI 中的命令翻译成 PS 代码
- 把
tex.pro
、字体等还有翻译出来的 PS 拼接起来作为最终的输出
这算是一种元编程,在 PS 中定义了一个 DSL,可以很方便地执行 DVI 指令。
在 这里 可以看到原始的带注释的 tex.lpro
实现,上面涉及 tex.pro
的代码内容也是从这里复制来的。
从 PS 到 PDF¶
最后一步,我们可以用 ps2pdf
把 PS 转换为 PDF。转换以后,就可以用常见的 PDF 浏览器来阅读了。让我们解压缩其中被压缩的部分,这样就方便阅读它的内容了:
在里面就可以找到我们的 Hello, world!
了:
5 0 obj
<<
/Length 225
>>
stream
q 0.1 0 0 0.1 0 0 cm
0 g
q
10 0 0 10 0 0 cm BT
/R7 9.96264 Tf
1 0 0 1 91.9199 710.04 Tm
[(H)3.21024(e)-1.66516(llo)-5.88993(,)-337.276(w)23.3747(o)-5.88993(r)-6.48419(ld)0.929988(!)]TJ
211.56 -654.72 Td
[(1)-5.8887]TJ
ET
Q
Q
endstream
endobj
阅读 PDF 标准,可以发现它的输出文本部分是这样的:
BT
/R7 9.96264 Tf
1 0 0 1 91.9199 710.04 Tm
[(H)3.21024(e)-1.66516(llo)-5.88993(,)-337.276(w)23.3747(o)-5.88993(r)-6.48419(ld)0.929988(!)]TJ
211.56 -654.72 Td
[(1)-5.8887]TJ
ET
其中:
/R7 9.96264 Tf
设置了字体/R7
,大小是9.96264
1 0 0 1 91.9199 710.04 Tm
设置 Text matrix[(H)3.21024(e)-1.66516(llo)-5.88993(,)-337.276(w)23.3747(o)-5.88993(r)-6.48419(ld)0.929988(!)]TJ
输出一系列的Hello, world!
,中间的数字表示的是文字之间移动的坐标211.56 -654.72 Td
移动坐标到页码的位置[(1)-5.8887]TJ
输出页码
可以看到,从 PS 到 PDF 这一步就不是简单的映射了,例如在 DVI 和 PS 中都是 Hello,
w
orld!
这样断开,而在 PDF 里面则是 H
e
llo
,
w
o
r
ld
!
。