通过修改bst文件手动设置LaTeX参考文献格式

最近因为约稿要投一个很偏的期刊,参考文献格式奇葩,现成的\(\LaTeX\)参考文献格式不能满足要求,需要自己手动配置。一些博客中已经介绍了使用makebst工具交互式生成相应bst文件的做法。好处是相对学习成本比较小,坏处就是被人牵着走还不一定能完成需求。

本文介绍的,是掌握BibTeX语法后,直接修改bst文件来设置\(\LaTeX\)参考文献格式的方法。

BibTeX语言一窥

学习BibTeX语法后从头手搓一个bst当然是一种方案,不过稍微取巧一些的是从一些现成的bst出发进行修改,最后满足需求即可。从宏观来看,bst文件只是某种格式或者配置文件,语法应该是比较直截了当的。然而即使是\(\LaTeX\)内置的简单格式如unsrt,其bst文件unsrt.bst也有18 kB,约一千行代码。所有内置参考文献的bst文件都可以从CTAN下载

unsrt.bst文件中的代码没有太多注释,粗看起来并不好懂。比如以下代码,大体上应该可以判断出来这段代码定义了渲染引用article时参考文献格式的函数,但具体运行逻辑不明朗。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FUNCTION {article}
{ output.bibitem
format.authors "author" output.check
new.block
format.title "title" output.check
new.block
crossref missing$
{ journal emphasize "journal" output.check
format.vol.num.pages output
format.date "year" output.check
}
{ format.article.crossref output.nonnull
format.pages output
}
if$
new.block
note output
fin.entry
}

一个有一定编程基础的人,看完这段代码自然会有如下几个问题:

  • 函数(FUNCTION)的形参和实参是什么?返回值如何定义?
  • 频繁出现在两个单词之间的.代表什么?
  • 出现在单词之后的$有什么含义?
  • {}括起来的是代码块吗?何时需要何时不需要?
  • "引号括起来的部分,是否表示字符串?如果是,为什么最终渲染出来的引文中并没有"author""title"等字符串?

只要将这些问题一一解决,BibTeX的语法也就基本清楚了,这时修改bst文件手动设置\(\LaTeX\)参考文献格式也就有如探囊取物。

BibTeX语言基本模型:逆波兰表示法

逆波兰表示法在计算机科学界可说是鼎鼎大名了,其优点是很容易解析(parse)成堆栈操作并实现为计算机代码。例如表达式5 1 3 + 2 \ *

  1. 首先5,1,3依次入栈
  2. 对栈顶的1和3做加法操作,结果4入栈
  3. 加号后的2入栈
  4. 对栈顶的4和2做除法操作,结果2入栈
  5. 再对栈顶的5和2做乘法操作,结果10入栈
  6. 运算结束

BibTeX是一种脚本语言,基本模型就是通过逆波兰表示法维护一个堆栈,不同的指令对应于不同的堆栈操作,其目标是将参考文献的\(\LaTeX\)代码打印出来。函数与指令没有本质区别。在定义函数时,不需要指定形参和返回值,只需要在函数中对堆栈进行操作就行了。换句话说,假如函数有参数,在调用函数前需要手动将参数压栈。这就回答了上面提到的函数如何定义的问题。从这种函数定义方式上讲,可以说BibTeX是一种比较落后的语言。

BibTeX语言的基本数据类型和内置函数

BibTeX语言中,基本数据类型只有两类:整数和字符串,对应的变量使用前需要在bst文件头部INTEGERSTRING部分声明。BibTeX语言还有一类数据类型是bib文件中的条目(可以理解为某种对象),需要在头部ENTRY部分声明。定义整数时,在数字前加井号,如#1定义字符串时,用双括号括起来,如"hello"。上文示例代码中的"author"等字符串最后并没出现在参考文献的渲染结果的原因是此时"author"等字符串只是入栈,而并没有加入到输出中。实际上,后续的函数output.check就会将其出栈。之所以这么做的原因是如果出错output.check在打印错误原因时需要"author"这一字符串(类似于“Error when processing author”)。换句话说"author"只是output.check的一个参数。

BibTeX的内置函数包括+<等常见操作。另外=代表比较而:=代表赋值。当包含字母时,内置函数由$结尾,如pop$。上文示例BibTeX代码中的missing$就是这样一种内置函数,其作用是判断当前栈顶的“条目”是否为空,返回整数作为布尔值。if$这样的条件分支语句也被当做是函数,它会出栈当前栈顶的三个元素。第一个(三个元素中靠近栈底的)如果严格大于0即布尔真,那么就会执行第二个元素中的代码块,否则会执行第三个元素中的代码块。
至此,我们可以理解上文实例代码中的控制流了:

1
2
3
4
5
6
7
8
9
crossref missing$
{ journal emphasize "journal" output.check
format.vol.num.pages output
format.date "year" output.check
}
{ format.article.crossref output.nonnull
format.pages output
}
if$

如果当前处理的参考文献没有crossref条目,那么在渲染该参考文献时就采用第一个{}中括起来的代码,反之则用第二个。可见{}的作用是暂缓解释器对括起来的代码的执行,而将其作为一个整体入栈,主要目的是和if$配合。当代码块较简单如只有一个单词时,可以在该单词前加上'达到相同的目的,效果与用{}括起来相同。

其它的内置BibTeX函数还有很多,可参考官方文档半官方文档。这些函数中有一个非常特别的format.name$函数,顾名思义专门对人名进行格式化,其特别之处在于针对不同的人名格式又定义了一个自己的DSL(禁止套娃。这个函数名中的.并没有特殊含义,在BibTeX中.可以像正常字符一样出现在函数名当中

BibTeX简单示例

至此,本文第一节提出的若干个问题已经全部得到了解决(见加粗部分),BibTeX的语法也逐渐清晰了起来。下面介绍一个实例:如果针对unsrt格式,想设置发表年份斜体,应该怎么做?从上面的示例代码不难发现目标即是format.date函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FUNCTION {format.date}
{ year empty$
{ month empty$
{ "" }
{ "there's a month but no year in " cite$ * warning$
month
}
if$
}
{ month empty$
'year
{ month " " * year * }
if$
}
if$
}

可以看出该函数的控制流分为四种情况,首先year empty$是否定义了年份,其次month empty$是否定义了月份。那由于我们的目的是改年份格式,没有定义年份的就不用管了,只看第二个代码块即第10到14行。当月份为空时,执行第一个代码块'year也就是简简单单把年份入栈(注意输出是后面函数的工作),其中'的作用前面已经提到,就是将简单的语句year变成代码块,与{year}相同。当月份不为空时,代码month " " * year *中出现了运算符*。这个运算符的作用不是乘法而是字符串相连(实际上BibTeX内生不支持乘法),所以这行代码的意思实际上是把三个字符串相连,换成Python的话说就是

1
f"{month} {year}"

如此分析后,怎样设置年份斜体就呼之欲出了,以没有定义月份时为例,只需将'year替换为

1
{"\textit{" year * "}" *}

这行代码初看有点难懂,因为它是为了生成代码的代码。外侧的一对{}作用是让BibTeX解释器把这一段当成代码块不要立刻执行。内侧的一对{}是处于引号中的,目的是让渲染出的结果中有\textit{}。而*的作用是连接栈顶的两个字符串。

按照这种方式编码bst文件,任意的参考文献格式都可以轻松定义。