Adding internal hyperlinks to Zotero author–year citations in Word
这篇文章记录一个用于 Word 和 Zotero 的 VBA 宏。它的目标是为正文中的 Zotero 作者–年份 引用自动添加内部超链接,使读者点击文中引用时,可以跳转到文末参考文献列表中对应的条目。
这个需求主要来自学术论文写作和排版过程。Zotero 可以自动管理文献引用和参考文献列表,但在 Word 中,作者–年份格式的正文引用通常只是普通的 Zotero 字段结果,并不会自动链接到文末 bibliography。对于较长论文而言,如果读者需要从正文引用快速定位到参考文献条目,手动查找会比较低效。因此,这个宏的作用是自动识别 Zotero 引用字段、读取其中的文献标题、在参考文献列表中创建书签,并将正文引用指向对应书签。
1. 使用场景
这个宏适用于以下情况:
- 文档使用 Zotero 插件插入引用;
- 引用格式为作者–年份格式,例如
(Smith, 2020)或(Smith, 2020; Zhang and Li, 2021); - 文末已经生成 Zotero bibliography;
- 希望正文引用可以点击跳转到文末参考文献;
- 希望正文引用的字体显示样式高亮,如变蓝(如果不需要高亮,或者需要显示为其他颜色,可以修改代码
.Color = wdColorBlue,默认颜色改为.Color = oldColor或者红色.Color = wdColorRed)。
它不适用于已经将 Zotero 字段完全转为纯文本的文档。因为宏需要读取 Zotero 字段代码中的文献信息,如果字段已经被取消链接,宏就无法从 field code 中提取对应的 title。
2. 基本原理
Word 中的 Zotero 引用本质上是一个特殊字段。正文中的引用通常包含 ADDIN ZOTERO_ITEM,文末参考文献列表通常包含 ADDIN ZOTERO_BIBL。宏的基本逻辑是:
- 在当前 Word 文档中查找 Zotero bibliography;
- 遍历全文字段,识别所有 Zotero in-text citation;
- 从 Zotero citation 的 field code 中提取文献标题;
- 在 bibliography 中搜索对应标题;
- 给 bibliography 中的参考文献条目添加书签;
- 给正文中的作者–年份引用添加内部超链接;
- 保持正文引用的原始样式,避免出现蓝色或下划线。
这个过程并不改变 Zotero 的引用数据本身,而是在 Word 文档层面增加 bookmark 和 hyperlink。因此,它更接近排版增强工具,而不是 Zotero 引用样式修改工具。
3. 与数字引用格式宏的区别
很多已有的 Zotero 超链接宏是为数字引用格式设计的,例如:
1
2
3
[3]
[8]–[10]
[2], [4], [5]
这类宏通常会在 citation 的 plainCitation 中查找方括号和数字,然后把数字部分链接到参考文献条目。
但是作者–年份格式并不遵循这种结构。例如:
1
2
3
(Smith, 2020)
(Smith and Jones, 2021)
(Smith, 2020; Zhang and Li, 2021)
因此,原本依赖 [ ] 和数字编号的逻辑不能直接使用。作者–年份格式下,更合理的做法是从 Zotero 字段代码中读取每条文献的 title,再用 title 去 bibliography 中定位对应参考文献条目。
4. 宏代码
代码语言是 VBA,即 Visual Basic for Applications。它需要放在 Word 的 VBA 编辑器中运行,而不是在 Python、JavaScript 或普通 Markdown 环境中运行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
Option Explicit
Public Sub CitingHyperlink_AuthorYear()
Dim nStart As Long, nEnd As Long
nStart = Selection.Start
nEnd = Selection.End
Application.ScreenUpdating = False
ActiveWindow.View.ShowFieldCodes = False
Dim f As Field
Dim bibRange As Range
Dim idx As Long
For Each f In ActiveDocument.Fields
If InStr(1, f.code.Text, "ADDIN ZOTERO_BIBL", vbTextCompare) > 0 Then
Set bibRange = f.Result.Duplicate
Exit For
End If
Next f
If bibRange Is Nothing Then
MsgBox "未找到 Zotero bibliography。请先插入或刷新参考文献列表。", vbExclamation
GoTo CleanExit
End If
For idx = ActiveDocument.Fields.Count To 1 Step -1
Set f = ActiveDocument.Fields(idx)
If InStr(1, f.code.Text, "ADDIN ZOTERO_ITEM", vbTextCompare) > 0 Then
Dim titles As Collection
Set titles = ExtractTitlesFromZoteroCode(f.code.Text)
If titles.Count = 0 Then GoTo NextField
Dim shownText As String
shownText = f.Result.Text
Dim segs As Collection
Set segs = GetCitationSegments(shownText)
Dim i As Long
Dim bmName As String
Dim linkRange As Range
If titles.Count = 1 Or segs.Count <> titles.Count Then
bmName = SafeBookmarkName(CStr(titles(1)))
If EnsureTitleBookmark(ActiveDocument, bibRange, CStr(titles(1)), bmName) Then
Set linkRange = f.Result.Duplicate
AddInternalHyperlink ActiveDocument, linkRange, bmName
End If
Else
For i = 1 To titles.Count
bmName = SafeBookmarkName(CStr(titles(i)))
If EnsureTitleBookmark(ActiveDocument, bibRange, CStr(titles(i)), bmName) Then
Set linkRange = FindTextInRange(f.Result.Duplicate, CStr(segs(i)))
If Not linkRange Is Nothing Then
AddInternalHyperlink ActiveDocument, linkRange, bmName
End If
End If
Next i
End If
End If
NextField:
Next idx
CleanExit:
ActiveWindow.View.ShowFieldCodes = False
Application.ScreenUpdating = True
ActiveDocument.Range(nStart, nEnd).Select
End Sub
Private Function ExtractTitlesFromZoteroCode(ByVal codeText As String) As Collection
Dim col As New Collection
Dim p As Long, q As Long
Dim title As String
p = 1
Do
p = InStr(p, codeText, """title"":""")
If p = 0 Then Exit Do
p = p + Len("""title"":""")
q = FindJsonStringEnd(codeText, p)
If q > p Then
title = Mid$(codeText, p, q - p)
title = JsonUnescape(title)
If Len(Trim$(title)) > 0 Then
col.Add title
End If
p = q + 1
Else
Exit Do
End If
Loop
Set ExtractTitlesFromZoteroCode = col
End Function
Private Function FindJsonStringEnd(ByVal s As String, ByVal startPos As Long) As Long
Dim i As Long
Dim j As Long
Dim slashCount As Long
For i = startPos To Len(s)
If Mid$(s, i, 1) = """" Then
slashCount = 0
j = i - 1
Do While j >= startPos And Mid$(s, j, 1) = "\"
slashCount = slashCount + 1
j = j - 1
Loop
If slashCount Mod 2 = 0 Then
FindJsonStringEnd = i
Exit Function
End If
End If
Next i
FindJsonStringEnd = 0
End Function
Private Function GetCitationSegments(ByVal shownText As String) As Collection
Dim col As New Collection
Dim t As String
Dim inner As String
Dim parts() As String
Dim i As Long
Dim part As String
t = Trim$(shownText)
If Len(t) >= 2 And Left$(t, 1) = "(" And Right$(t, 1) = ")" Then
inner = Mid$(t, 2, Len(t) - 2)
Else
inner = t
End If
If InStr(1, inner, ";", vbTextCompare) > 0 Then
parts = Split(inner, ";")
For i = LBound(parts) To UBound(parts)
part = Trim$(parts(i))
If Len(part) > 0 Then
col.Add part
End If
Next i
Else
If Len(inner) > 0 Then
col.Add inner
End If
End If
Set GetCitationSegments = col
End Function
Private Function EnsureTitleBookmark(ByVal doc As Document, ByVal bibRange As Range, ByVal title As String, ByVal bmName As String) As Boolean
Dim r As Range
If Len(Trim$(title)) = 0 Then Exit Function
If doc.Bookmarks.Exists(bmName) Then
EnsureTitleBookmark = True
Exit Function
End If
Set r = bibRange.Duplicate
With r.Find
.ClearFormatting
.Text = Left$(title, 250)
.Replacement.Text = ""
.Forward = True
.Wrap = wdFindStop
.Format = False
.MatchCase = False
.MatchWholeWord = False
.MatchWildcards = False
.MatchSoundsLike = False
.MatchAllWordForms = False
End With
If r.Find.Execute Then
doc.Bookmarks.Add Range:=r, Name:=bmName
EnsureTitleBookmark = True
Else
EnsureTitleBookmark = False
End If
End Function
Private Function FindTextInRange(ByVal baseRange As Range, ByVal textToFind As String) As Range
Dim r As Range
Set r = baseRange.Duplicate
With r.Find
.ClearFormatting
.Text = Left$(textToFind, 250)
.Replacement.Text = ""
.Forward = True
.Wrap = wdFindStop
.Format = False
.MatchCase = False
.MatchWholeWord = False
.MatchWildcards = False
.MatchSoundsLike = False
.MatchAllWordForms = False
End With
If r.Find.Execute Then
Set FindTextInRange = r
Else
Set FindTextInRange = Nothing
End If
End Function
Private Sub AddInternalHyperlink(ByVal doc As Document, ByVal anchorRange As Range, ByVal bmName As String)
Dim shown As String
Dim oldStyle As Variant
Dim oldColor As Long
Dim h As Hyperlink
Dim r As Range
shown = anchorRange.Text
If Len(Trim$(shown)) = 0 Then Exit Sub
oldStyle = anchorRange.style
oldColor = anchorRange.Font.Color
Set h = Nothing
If anchorRange.Hyperlinks.Count > 0 Then
Set h = anchorRange.Hyperlinks(1)
On Error Resume Next
h.Address = ""
h.SubAddress = bmName
h.ScreenTip = "Go to reference"
Set r = h.Range
On Error GoTo 0
Else
On Error Resume Next
Set h = doc.Hyperlinks.Add(Anchor:=anchorRange, Address:="", SubAddress:=bmName, ScreenTip:="Go to reference", TextToDisplay:=shown)
Set r = h.Range
On Error GoTo 0
End If
If r Is Nothing Then Set r = anchorRange
On Error Resume Next
r.style = oldStyle
With r.Font
.Underline = wdUnderlineNone
.Color = wdColorBlue
End With
On Error GoTo 0
End Sub
Private Function JsonUnescape(ByVal s As String) As String
s = Replace(s, "\""", """")
s = Replace(s, "\\", "\")
s = Replace(s, "\/", "/")
s = DecodeUnicodeEscapes(s)
JsonUnescape = s
End Function
Private Function DecodeUnicodeEscapes(ByVal s As String) As String
Dim p As Long
Dim hs As String
p = InStr(1, s, "\u", vbTextCompare)
Do While p > 0 And p + 5 <= Len(s)
hs = Mid$(s, p + 2, 4)
If IsHex4(hs) Then
s = Left$(s, p - 1) & ChrW$(CLng("&H" & hs)) & Mid$(s, p + 6)
p = InStr(p + 1, s, "\u", vbTextCompare)
Else
p = InStr(p + 2, s, "\u", vbTextCompare)
End If
Loop
DecodeUnicodeEscapes = s
End Function
Private Function IsHex4(ByVal s As String) As Boolean
Dim i As Long
Dim c As String
If Len(s) <> 4 Then Exit Function
For i = 1 To 4
c = Mid$(s, i, 1)
If InStr(1, "0123456789ABCDEFabcdef", c, vbBinaryCompare) = 0 Then
Exit Function
End If
Next i
IsHex4 = True
End Function
Private Function SafeBookmarkName(ByVal title As String) As String
Dim base As String
Dim h As String
Dim maxBaseLen As Long
h = SimpleHash(title)
maxBaseLen = 40 - Len("Z__") - Len(h)
If maxBaseLen < 1 Then maxBaseLen = 1
base = Left$(MakeValidBMName_AuthorYear(title), maxBaseLen)
If Len(base) = 0 Then base = "Ref"
SafeBookmarkName = "Z_" & base & "_" & h
End Function
Private Function MakeValidBMName_AuthorYear(ByVal strIn As String) As String
Dim i As Long
Dim ch As String
Dim code As Long
Dim tempStr As String
strIn = Trim$(strIn)
For i = 1 To Len(strIn)
ch = Mid$(strIn, i, 1)
code = AscW(ch)
If code < 0 Then code = code + 65536
Select Case code
Case 48 To 57, 65 To 90, 95, 97 To 122
tempStr = tempStr & ch
Case Else
tempStr = tempStr & "_"
End Select
Next i
Do While InStr(tempStr, "__") > 0
tempStr = Replace(tempStr, "__", "_")
Loop
If Left$(tempStr, 1) Like "[0-9_]" Then
tempStr = "A_" & tempStr
End If
MakeValidBMName_AuthorYear = tempStr
End Function
Private Function SimpleHash(ByVal s As String) As String
Dim h As Double
Dim i As Long
Dim ch As Long
h = 0
For i = 1 To Len(s)
ch = AscW(Mid$(s, i, 1))
If ch < 0 Then ch = ch + 65536
h = h * 131 + ch
h = h - Int(h / 1000000007#) * 1000000007#
Next i
SimpleHash = CStr(CLng(h))
End Function
Leave a comment