• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

sile-typesetter / sile / 10866173456

14 Sep 2024 11:07PM UTC coverage: 65.603% (-3.3%) from 68.912%
10866173456

push

github

web-flow
Merge c40733634 into 8bed2e6bb

101 of 1236 new or added lines in 10 files covered. (8.17%)

72 existing lines in 7 files now uncovered.

12300 of 18749 relevant lines covered (65.6%)

5164.31 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

7.96
/csl/core/engine.lua
1
--- A rendering engine for CSL 1.0.2
2
--
3
-- @copyright License: MIT (c) 2024 Omikhleia
4
--
5
-- Public API:
6
--  - (constructor) CslEngine(style, locale) -> CslEngine
7
--  - CslEngine:cite(entries) -> string
8
--  - CslEngine:reference(entries) -> string
9
--
10
-- Important: while some consistency checks are performed, this engine is not
11
-- intended to handle errors in the locale, style or input data. It is assumed
12
-- that they are all valid.
13
--
14
-- THINGS NOT DONE
15
--  - disambiguation logic (not done at all)
16
--  - collapse logic in citations (not done at all)
17
--  - other FIXME/TODOs in the code on specific features
18
--
19
-- luacheck: no unused args
20

21
local CslLocale = require("csl.core.locale").CslLocale
1✔
22

23
local superfolding = require("csl.core.utils.superfolding")
1✔
24
local endash = luautf8.char(0x2013)
1✔
25
local emdash = luautf8.char(0x2014)
1✔
26

27
local CslEngine = pl.class()
1✔
28

29
--- (Constructor) Create a new CSL engine.
30
-- The optional extras table is for features not part of CSL 1.0.2.
31
-- Currently:
32
--   localizedPunctuation: boolean (default false) - use localized punctuation
33
--
34
-- @tparam CslStyle  style CSL style
35
-- @tparam CslLocale locale CSL locale
36
-- @tparam table     extras Additional data to pass to the engine
37
-- @treturn CslEngine
38
function CslEngine:_init (style, locale, extras)
1✔
NEW
39
   self.locale = locale
×
NEW
40
   self.style = style
×
NEW
41
   self.extras = extras
×
NEW
42
      or {
×
43
         localizedPunctuation = false,
44
         italicExtension = true,
45
         mathExtension = true,
46
      }
47

48
   -- Shortcuts for often used style elements
NEW
49
   self.macros = style.macros or {}
×
NEW
50
   self.citation = style.citation or {}
×
NEW
51
   self.locales = style.locales or {}
×
NEW
52
   self.bibliography = style.bibliography or {}
×
NEW
53
   self:_preprocess()
×
54

55
   -- Cache for some small string operations (e.g. XML escaping)
56
   -- to avoid repeated processing.
NEW
57
   self.cache = {}
×
58

59
   -- Early lookups for often used localized punctuation marks
NEW
60
   self.punctuation = {
×
61
      open_quote = self:_render_term("open-quote") or luautf8.char(0x201C), -- 0x201C curly left quote
62
      close_quote = self:_render_term("close-quote") or luautf8.char(0x201D), -- 0x201D curly right quote
63
      open_inner_quote = self:_render_term("open-inner-quote") or luautf8.char(0x2018), -- 0x2018 curly left single quote
64
      close_inner_quote = self:_render_term("close-inner-quote") or luautf8.char(0x2019), -- 0x2019 curly right single quote
65
      page_range_delimiter = self:_render_term("page-range-delimiter") or endash,
66
      [","] = self:_render_term("comma") or ",",
67
      [";"] = self:_render_term("semicolon") or ";",
68
      [":"] = self:_render_term("colon") or ":",
69
   }
70

71
   -- Small utility for page ranges, see text processing for <text variable="page">
NEW
72
   local sep = self.punctuation.page_range_delimiter
×
NEW
73
   if sep ~= endash and sep ~= emdash and sep ~= "-" then
×
74
      -- Unlikely there's a percent here, but let's be safe
NEW
75
      sep = luautf8.gsub(sep, "%%", "%%%%")
×
76
   end
NEW
77
   local dashes = "%-" .. endash .. emdash
×
NEW
78
   local textinrange = "[^" .. dashes .. "]+"
×
NEW
79
   local dashinrange = "[" .. dashes .. "]+"
×
NEW
80
   local page_range_capture = "(" .. textinrange .. ")%s*" .. dashinrange .. "%s*(" .. textinrange .. ")"
×
NEW
81
   local page_range_replacement = "%1" .. sep .. "%2"
×
82
   self.page_range_replace = function (t)
NEW
83
      return luautf8.gsub(t, page_range_capture, page_range_replacement)
×
84
   end
85

86
   -- Inheritable variables
87
   -- There's a long list of such variables, but let's be dumb and just merge everything.
NEW
88
   self.inheritable = {
×
89
      citation = pl.tablex.union(self.style.globalOptions, self.style.citation and self.style.citation.options or {}),
NEW
90
      bibliography = pl.tablex.union(
×
NEW
91
         self.style.globalOptions,
×
NEW
92
         self.style.bibliography and self.style.bibliography.options or {}
×
NEW
93
      ),
×
94
   }
95
end
96

97
function CslEngine:_prerender ()
1✔
98
   -- Stack for processing of cs:group as conditional
NEW
99
   self.groupQueue = {}
×
NEW
100
   self.groupState = { variables = {}, count = 0 }
×
101

102
   -- Track first name for name-as-sort-order
NEW
103
   self.firstName = true
×
104
end
105

106
function CslEngine:_merge_locales (locale1, locale2)
1✔
107
   -- FIXME TODO:
108
   --  - Should we care about date formats and style options?
109
   --    (PERHAPS, CHECK THE SPEC)
110
   --  - Should we move this to the CslLocale class?
111
   --    (LIKELY YES)
112
   --  - Should we deepcopy the locale1 first, so it can be reused independently?
113
   --    (LIKELY YES, instantiating a new CslLocale)
114
   -- Merge terms, overriding existing ones
NEW
115
   for term, forms in pairs(locale2.terms) do
×
NEW
116
      if not locale1.terms[term] then
×
NEW
117
         SU.debug("csl", "CSL local merging added:", term)
×
NEW
118
         locale1.terms[term] = forms
×
119
      else
NEW
120
         for form, genderfs in pairs(forms) do
×
NEW
121
            if not locale1.terms[term][form] then
×
NEW
122
               SU.debug("csl", "CSL local merging added:", term, form)
×
NEW
123
               locale1.terms[term][form] = genderfs
×
124
            else
NEW
125
               for genderform, value in pairs(genderfs) do
×
NEW
126
                  local replaced = locale1.terms[term][form][genderform]
×
NEW
127
                  SU.debug("csl", "CSL local merging", replaced and "replaced" or "added:", term, form, genderform)
×
NEW
128
                  locale1.terms[term][form][genderform] = value
×
129
               end
130
            end
131
         end
132
      end
133
   end
134
end
135

136
function CslEngine:_preprocess ()
1✔
137
   -- Handle locale overrides
NEW
138
   if self.locales[self.locale.lang] then -- Direct language match
×
NEW
139
      local override = CslLocale(self.locales[self.locale.lang])
×
NEW
140
      SU.debug("csl", "Locale override found for " .. self.locale.lang)
×
NEW
141
      self:_merge_locales(self.locale, override)
×
142
   else
NEW
143
      for lang, locale in pairs(self.locales) do -- Fuzzy language matching
×
NEW
144
         if self.locale.lang:sub(1, #lang) == lang then
×
NEW
145
            local override = CslLocale(locale)
×
NEW
146
            SU.debug("csl", "Locale override found for " .. self.locale.lang .. " -> " .. lang)
×
NEW
147
            self:_merge_locales(self.locale, override)
×
148
         end
149
      end
150
   end
151
end
152

153
-- GROUP LOGIC (tracking variables in groups, conditional rendering)
154

155
function CslEngine:_enterGroup ()
1✔
NEW
156
   self.groupState.count = self.groupState.count + 1
×
NEW
157
   SU.debug("csl", "Enter group", self.groupState.count, "level", #self.groupQueue)
×
158

NEW
159
   table.insert(self.groupQueue, self.groupState)
×
NEW
160
   self.groupState = { variables = {}, count = 0 }
×
161
end
162

163
function CslEngine:_leaveGroup (rendered)
1✔
164
   -- Groups implicitly act as a conditional: if all variables that are called
165
   -- are empty, the group is suppressed.
166
   -- But the group is kept if no variable is called.
NEW
167
   local emptyVariables = true
×
NEW
168
   local hasVariables = false
×
NEW
169
   for _, cond in pairs(self.groupState.variables) do
×
NEW
170
      hasVariables = true
×
NEW
171
      if cond then -- non-empty variable found
×
NEW
172
         emptyVariables = false
×
173
         break
174
      end
175
   end
NEW
176
   local suppressGroup = hasVariables and emptyVariables
×
NEW
177
   if suppressGroup then
×
NEW
178
      rendered = nil -- Suppress group
×
179
   end
NEW
180
   self.groupState = table.remove(self.groupQueue)
×
181
   -- A nested non-empty group is treated as a non-empty variable for the
182
   -- purposes of determining suppression of the outer group.
183
   -- So add a pseudo-variable for the inner group into the outer group, to
184
   -- track this.
NEW
185
   if not suppressGroup then
×
NEW
186
      local groupCond = "_group_" .. self.groupState.count
×
NEW
187
      self:_addGroupVariable(groupCond, true)
×
188
   end
NEW
189
   SU.debug(
×
190
      "csl",
191
      "Leave group",
NEW
192
      self.groupState.count,
×
193
      "level",
NEW
194
      #self.groupQueue,
×
NEW
195
      suppressGroup and "(suppressed)" or "(rendered)"
×
196
   )
NEW
197
   return rendered
×
198
end
199

200
function CslEngine:_addGroupVariable (variable, value)
1✔
NEW
201
   SU.debug("csl", "Group variable", variable, value and "true" or "false")
×
NEW
202
   self.groupState.variables[variable] = value and true or false
×
203
end
204

205
-- INTERNAL HELPERS
206

207
function CslEngine:_render_term (name, form, plural)
1✔
NEW
208
   local t = self.locale:term(name, form, plural)
×
NEW
209
   if t then
×
NEW
210
      if self.cache[t] then
×
NEW
211
         return self.cache[t]
×
212
      end
NEW
213
      t = self:_xmlEscape(t)
×
214
      -- The CSL specification states, regarding terms:
215
      --   "Superscripted Unicode characters can be used for superscripting."
216
      -- We replace the latter with their normal form, wrapped in a command.
217
      -- The result is cached in the term object to avoid repeated processing.
218
      -- (Done after XML escaping as superfolding may add commands.)
NEW
219
      t = superfolding(t)
×
NEW
220
      self.cache[t] = t
×
221
   end
NEW
222
   return t
×
223
end
224

225
function CslEngine:_render_text_specials (value)
1✔
226
   -- Extensions for italic and math...
227
   -- CAVEAT: the implementation is fairly naive.
NEW
228
   local pieces = {}
×
NEW
229
   for token in SU.gtoke(value, "%$([^$]+)%$") do
×
NEW
230
      if token.string then
×
NEW
231
         local s = token.string
×
NEW
232
         if self.extras.italicExtension then
×
233
            -- Typography:
234
            -- Use pseudo-markdown italic extension (_text_) to wrap
235
            -- the text in emphasis.
236
            -- Skip if sorting, as it's not supposed to affect sorting.
NEW
237
            local repl = self.sorting and "%1" or "<em>%1</em>"
×
NEW
238
            s = luautf8.gsub(s, "_([^_]+)_", repl)
×
239
         end
NEW
240
         table.insert(pieces, s)
×
241
      else
NEW
242
         local m = token.separator
×
NEW
243
         if self.extras.mathExtension then
×
244
            -- Typography:
245
            -- Use pseudo-markdown math extension ($text$) to wrap
246
            -- the text in math mode (assumed to be in TeX-like syntax).
NEW
247
            m = luautf8.gsub(m, "%$([^$]+)%$", "<math>%1</math>")
×
248
         end
NEW
249
         table.insert(pieces, m)
×
250
      end
251
   end
NEW
252
   return table.concat(pieces)
×
253
end
254

255
-- RENDERING ATTRIBUTES (strip-periods, affixes, formatting, text-case, display, quotes, delimiter)
256

257
function CslEngine:_xmlEscape (t)
1✔
NEW
258
   return t:gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;")
×
259
end
260

261
function CslEngine:_punctuation_extra (t)
1✔
NEW
262
   if self.cache[t] then
×
NEW
263
      return self.cache[t]
×
264
   end
NEW
265
   if self.extras.localizedPunctuation then
×
266
      -- non-standard: localized punctuation
NEW
267
      t = t:gsub("[,;:]", function (c)
×
NEW
268
         return self.punctuation[c] or c
×
269
      end)
270
   end
NEW
271
   t = self:_xmlEscape(t)
×
NEW
272
   self.cache[t] = t
×
NEW
273
   return t
×
274
end
275

276
function CslEngine:_render_stripPeriods (t, options)
1✔
NEW
277
   if t and options["strip-periods"] and t:sub(-1) == "." then
×
NEW
278
      t = t:sub(1, -2)
×
279
   end
NEW
280
   return t
×
281
end
282

283
function CslEngine:_render_affixes (t, options)
1✔
NEW
284
   if not t then
×
NEW
285
      return
×
286
   end
NEW
287
   if options.prefix then
×
NEW
288
      local pref = self:_punctuation_extra(options.prefix)
×
NEW
289
      t = pref .. t
×
290
   end
NEW
291
   if options.suffix then
×
NEW
292
      local suff = self:_punctuation_extra(options.suffix)
×
NEW
293
      t = t .. suff
×
294
   end
NEW
295
   return t
×
296
end
297

298
function CslEngine:_render_formatting (t, options)
1✔
NEW
299
   if not t then
×
NEW
300
      return
×
301
   end
NEW
302
   if self.sorting then
×
303
      -- Skip all formatting in sorting mode
NEW
304
      return t
×
305
   end
NEW
306
   if options["font-style"] == "italic" then -- FIXME: also normal, oblique, and how nesting is supposed to work?
×
NEW
307
      t = "<em>" .. t .. "</em>"
×
308
   end
NEW
309
   if options["font-variant"] == "small-caps" then
×
310
      -- To avoid (quoted) attributes in the output, as some global
311
      -- substitutions might affect quotes, we use a simple "wrapper" command.
NEW
312
      t = "<bibSmallCaps>" .. t .. "</bibSmallCaps>"
×
313
   end
NEW
314
   if options["font-weight"] == "bold" then -- FIXME: also light, normal, and how nesting is supposed to work?
×
NEW
315
      t = "<strong>" .. t .. "</strong>"
×
316
   end
NEW
317
   if options["text-decoration"] == "underline" then
×
NEW
318
      t = "<underline>" .. t .. "</underline>"
×
319
   end
NEW
320
   if options["vertical-align"] == "sup" then
×
NEW
321
      t = "<textsuperscript>" .. t .. "</textsuperscript>"
×
322
   end
NEW
323
   if options["vertical-align"] == "sub" then
×
NEW
324
      t = "<textsubscript>" .. t .. "</textsubscript>"
×
325
   end
NEW
326
   return t
×
327
end
328

329
function CslEngine:_render_textCase (t, options)
1✔
NEW
330
   if not t then
×
NEW
331
      return
×
332
   end
NEW
333
   if options["text-case"] then
×
NEW
334
      t = self.locale:case(t, options["text-case"])
×
335
   end
NEW
336
   return t
×
337
end
338

339
function CslEngine:_render_display (t, options)
1✔
NEW
340
   if not t then
×
NEW
341
      return
×
342
   end
343
   -- if options.display then
344
   -- FIXME Add rationale for not supporting it...
345
   -- Keep silent: it's not a critical feature yet
346
   -- SU.warn("CSL display not implemented")
347
   -- end
NEW
348
   return t
×
349
end
350

351
function CslEngine:_render_quotes (t, options)
1✔
NEW
352
   if not t then
×
NEW
353
      return
×
354
   end
NEW
355
   if self.sorting then
×
356
      -- Skip all quotes in sorting mode
NEW
357
      return luautf8.gsub(t, '[“”"]', "")
×
358
   end
NEW
359
   if t and options.quotes then
×
360
      -- Smart transform curly quotes in the input to localized inner quotes.
NEW
361
      t = luautf8.gsub(t, "“", self.punctuation.open_inner_quote)
×
NEW
362
      t = luautf8.gsub(t, "”", self.punctuation.close_inner_quote)
×
363
      -- Smart transform straight quotes in the input to localized inner quotes.
NEW
364
      t = luautf8.gsub(t, '^"', self.punctuation.open_inner_quote)
×
NEW
365
      t = luautf8.gsub(t, '"$', self.punctuation.close_inner_quote)
×
NEW
366
      t = luautf8.gsub(t, '([’%s])"', "%1" .. self.punctuation.open_inner_quote)
×
NEW
367
      t = luautf8.gsub(t, '"([%s%p])', self.punctuation.close_inner_quote .. "%1")
×
368
      -- Wrap the result in localized outer quotes.
NEW
369
      t = self.punctuation.open_quote .. t .. self.punctuation.close_quote
×
370
   end
NEW
371
   return t
×
372
end
373

374
function CslEngine:_render_link (t, link)
1✔
NEW
375
   if t and link and not self.sorting then
×
376
      -- We'll let the processor implement CSL 1.0.2 link handling.
377
      -- (appendix VI)
NEW
378
      t = "<bib" .. link .. ">" .. t .. "</bib" .. link .. ">"
×
379
   end
NEW
380
   return t
×
381
end
382

383
function CslEngine:_render_delimiter (ts, delimiter) -- ts is a table of strings
1✔
NEW
384
   local d = delimiter and self:_punctuation_extra(delimiter)
×
NEW
385
   return table.concat(ts, d)
×
386
end
387

388
-- RENDERING ELEMENTS: layout, text, date, number, names, label, group, choose
389

390
function CslEngine:_layout (options, content, entries)
1✔
NEW
391
   local output = {}
×
NEW
392
   for _, entry in ipairs(entries) do
×
NEW
393
      self:_prerender()
×
NEW
394
      local elem = self:_render_children(content, entry)
×
395
      -- affixes and formatting likely apply on elementary entries
396
      -- (The CSL 1.0.2 specification is not very clear on this point.)
NEW
397
      elem = self:_render_formatting(elem, options)
×
NEW
398
      elem = self:_render_affixes(elem, options)
×
NEW
399
      elem = self:_postrender(elem)
×
NEW
400
      if elem then
×
NEW
401
         table.insert(output, elem)
×
402
      end
403
   end
NEW
404
   if options.delimiter then
×
NEW
405
      return self:_render_delimiter(output, options.delimiter)
×
406
   end
407
   -- (Normally citations have a delimiter options, so we should only reach
408
   -- this point for the bibliography)
NEW
409
   local delim = self.mode == "citation" and "; " or "<par/>"
×
410
   -- references all belong to a different paragraph
411
   -- FIXME: should account for attributes on the toplevel bibliography element:
412
   --   line-spacing
413
   --   hanging-indent
NEW
414
   return table.concat(output, delim)
×
415
end
416

417
function CslEngine:_text (options, content, entry)
1✔
418
   local t
419
   local link
NEW
420
   if options.macro then
×
NEW
421
      if self.macros[options.macro] then
×
NEW
422
         t = self:_render_children(self.macros[options.macro], entry)
×
423
      else
NEW
424
         SU.error("CSL macro " .. options.macro .. " not found")
×
425
      end
NEW
426
   elseif options.term then
×
NEW
427
      t = self:_render_term(options.term, options.form, options.plural)
×
NEW
428
   elseif options.variable then
×
NEW
429
      local variable = options.variable
×
NEW
430
      t = entry[variable]
×
NEW
431
      self:_addGroupVariable(variable, t)
×
NEW
432
      if variable == "locator" then
×
NEW
433
         t = t and t.value
×
NEW
434
         variable = entry.locator.label
×
435
      end
NEW
436
      if variable == "page" and t then
×
437
         -- Replace any dash in page ranges
NEW
438
         t = self.page_range_replace(t)
×
439
      end
440

441
      -- FIXME NOT IMPLEMENTED SPEC:
442
      -- "May be accompanied by the form attribute to select the “long”
443
      -- (default) or “short” form of a variable (e.g. the full or short
444
      -- title). If the “short” form is selected but unavailable, the
445
      -- “long” form is rendered instead."
446
      -- But CSL-JSON etc. do not seem to have standard provision for it.
447

NEW
448
      if t and (variable == "URL" or variable == "DOI" or variable == "PMID" or variable == "PMCID") then
×
NEW
449
         link = variable
×
450
      end
NEW
451
   elseif options.value then
×
NEW
452
      t = options.value
×
453
   else
NEW
454
      SU.error("CSL text without macro, term, variable or value")
×
455
   end
NEW
456
   t = self:_render_stripPeriods(t, options)
×
NEW
457
   t = self:_render_textCase(t, options)
×
NEW
458
   t = self:_render_formatting(t, options)
×
NEW
459
   t = self:_render_quotes(t, options)
×
NEW
460
   t = self:_render_affixes(t, options)
×
NEW
461
   if link then
×
NEW
462
      t = self:_render_link(t, link)
×
NEW
463
   elseif t and options.variable then
×
NEW
464
      t = self:_render_text_specials(t)
×
465
   end
NEW
466
   t = self:_render_display(t, options)
×
NEW
467
   return t
×
468
end
469

470
function CslEngine:_a_day (options, day, month) -- month needed to get gender for ordinal
1✔
NEW
471
   local form = options.form
×
472
   local t
NEW
473
   if form == "numeric-leading-zeros" then
×
NEW
474
      t = ("%02d"):format(day)
×
NEW
475
   elseif form == "ordinal" then
×
476
      local genderForm
NEW
477
      if month then
×
NEW
478
         local monthKey = ("month-%02d"):format(month)
×
NEW
479
         local _, gender = self:_render_term(monthKey)
×
NEW
480
         genderForm = gender or "neuter"
×
481
      end
NEW
482
      if SU.boolean(self.locale.styleOptions["limit-day-ordinals-to-day-1"], false) then
×
NEW
483
         t = day == 1 and self.locale:ordinal(day, "short", genderForm) or ("%d"):format(day)
×
484
      else
NEW
485
         t = self.locale:ordinal(day, "short", genderForm)
×
486
      end
487
   else -- "numeric" by default
NEW
488
      t = ("%d"):format(day)
×
489
   end
NEW
490
   return t
×
491
end
492

493
function CslEngine:_a_month (options, month)
1✔
NEW
494
   local form = options.form
×
495
   local t
NEW
496
   if form == "numeric" then
×
NEW
497
      t = ("%d"):format(month)
×
NEW
498
   elseif form == "numeric-leading-zeros" then
×
NEW
499
      t = ("%02d"):format(month)
×
500
   else -- short or long (default)
NEW
501
      local monthKey = ("month-%02d"):format(month)
×
NEW
502
      t = self:_render_term(monthKey, form or "long")
×
503
   end
NEW
504
   t = self:_render_stripPeriods(t, options)
×
NEW
505
   return t
×
506
end
507

508
function CslEngine:_a_season (options, season)
1✔
NEW
509
   local form = options.form
×
510
   local t
NEW
511
   if form == "numeric" or form == "numeric-leading-zeros" then
×
512
      -- The CSL specification does not seem to forbid it, but a numeric value
513
      -- for the season is a weird idea, so we skip it for now.
NEW
514
      SU.warn("CSL season formatting as a number is ignored")
×
515
   else
NEW
516
      local seasonKey = ("season-%02d"):format(season)
×
NEW
517
      t = self:_render_term(seasonKey, form or "long")
×
518
   end
NEW
519
   t = self:_render_stripPeriods(t, options)
×
NEW
520
   return t
×
521
end
522

523
function CslEngine:_a_year (options, year)
1✔
NEW
524
   local form = options.form
×
525
   local t
NEW
526
   if tonumber(year) then
×
NEW
527
      if form == "numeric-leading-zeros" then
×
NEW
528
         t = ("%04d"):format(year)
×
NEW
529
      elseif form == "short" then
×
530
         -- The spec gives as example 2005 -> 05
NEW
531
         t = ("%02d"):format(year % 100)
×
532
      else -- "long" by default
NEW
533
         t = ("%d"):format(year)
×
534
      end
535
   else
536
      -- Compat with BibLaTeX (literal might not be a number)
NEW
537
      t = year
×
538
   end
NEW
539
   return t
×
540
end
541

542
function CslEngine:_a_date_day (options, date)
1✔
543
   local t
NEW
544
   if date.day then
×
NEW
545
      if type(date.day) == "table" then
×
NEW
546
         local t1 = self:_a_day(options, date.day[1], date.month)
×
NEW
547
         local t2 = self:_a_day(options, date.day[2], date.month)
×
NEW
548
         local sep = options["range-delimiter"] or endash
×
NEW
549
         t = t1 .. sep .. t2
×
550
      else
NEW
551
         t = self:_a_day(options, date.day, date.month)
×
552
      end
553
   end
NEW
554
   return t
×
555
end
556

557
function CslEngine:_a_date_month (options, date)
1✔
558
   local t
NEW
559
   if date.month then
×
NEW
560
      if type(date.month) == "table" then
×
NEW
561
         local t1 = self:_a_month(options, date.month[1])
×
NEW
562
         local t2 = self:_a_month(options, date.month[2])
×
NEW
563
         local sep = options["range-delimiter"] or endash
×
NEW
564
         t = t1 .. sep .. t2
×
565
      else
NEW
566
         t = self:_a_month(options, date.month)
×
567
      end
NEW
568
   elseif date.season then
×
NEW
569
      if type(date.season) == "table" then
×
NEW
570
         local t1 = self:_a_season(options, date.season[1])
×
NEW
571
         local t2 = self:_a_season(options, date.season[2])
×
NEW
572
         local sep = options["range-delimiter"] or endash
×
NEW
573
         t = t1 .. sep .. t2
×
574
      else
NEW
575
         t = self:_a_season(options, date.season)
×
576
      end
577
   end
NEW
578
   return t
×
579
end
580

581
function CslEngine:_a_date_year (options, date)
1✔
582
   local t
NEW
583
   if date.year then
×
NEW
584
      if type(date.year) == "table" then
×
NEW
585
         local t1 = self:_a_year(options, date.year[1])
×
NEW
586
         local t2 = self:_a_year(options, date.year[2])
×
NEW
587
         local sep = options["range-delimiter"] or endash
×
NEW
588
         t = t1 .. sep .. t2
×
589
      else
NEW
590
         t = self:_a_year(options, date.year)
×
591
      end
592
   end
NEW
593
   return t
×
594
end
595

596
function CslEngine:_date_part (options, content, date)
1✔
NEW
597
   local name = SU.required(options, "name", "cs:date-part")
×
598
   -- FIXME TODO full date range are not implemented properly
599
   -- But we need to decide how to encode them in the pseudo CSL-JSON...
600
   local t
NEW
601
   local callback = "_a_date_" .. name
×
NEW
602
   if self[callback] then
×
NEW
603
      t = self[callback](self, options, date)
×
604
   else
NEW
605
      SU.warn("CSL date part " .. name .. " not implemented yet")
×
606
   end
NEW
607
   t = self:_render_textCase(t, options)
×
NEW
608
   t = self:_render_formatting(t, options)
×
NEW
609
   t = self:_render_affixes(t, options)
×
NEW
610
   return t
×
611
end
612

613
function CslEngine:_date_parts (options, content, date)
1✔
NEW
614
   local output = {}
×
NEW
615
   local cond = false
×
NEW
616
   for _, part in ipairs(content) do
×
NEW
617
      local t = self:_date_part(part.options, part, date)
×
NEW
618
      if t then
×
NEW
619
         cond = true
×
NEW
620
         table.insert(output, t)
×
621
      end
622
   end
NEW
623
   if not cond then -- not a single part rendered
×
NEW
624
      self:_addGroupVariable(options.variable, false)
×
NEW
625
      return
×
626
   end
NEW
627
   self:_addGroupVariable(options.variable, true)
×
NEW
628
   return self:_render_delimiter(output, options.delimiter)
×
629
end
630

631
function CslEngine:_date (options, content, entry)
1✔
NEW
632
   local variable = SU.required(options, "variable", "CSL number")
×
NEW
633
   local date = entry[variable]
×
NEW
634
   if date then
×
NEW
635
      if options.form then
×
636
         -- Use locale date format (form is either "numeric" or "text")
NEW
637
         content = self.locale:date(options.form)
×
NEW
638
         options.delimiter = nil -- Not supposed to exist when calling a locale date
×
639
         -- When calling a localized date, the date-parts attribute is used to
640
         -- determine which parts of the date to render: year-month-day (default),
641
         -- year-month or year.
NEW
642
         local dp = options["date-parts"] or "year-month-day"
×
NEW
643
         local hasMonthOrSeason = dp == "year-month" or dp == "year-month-day"
×
NEW
644
         local hasDay = dp == "year-month-day"
×
NEW
645
         date = {
×
646
            year = date.year,
647
            month = hasMonthOrSeason and date.month or nil,
648
            season = hasMonthOrSeason and date.season or nil,
649
            day = hasDay and date.day or nil,
650
         }
651
      end
NEW
652
      local t = self:_date_parts(options, content, date)
×
NEW
653
      t = self:_render_textCase(t, options)
×
NEW
654
      t = self:_render_formatting(t, options)
×
NEW
655
      t = self:_render_affixes(t, options)
×
NEW
656
      t = self:_render_display(t, options)
×
NEW
657
      return t
×
658
   else
NEW
659
      self:_addGroupVariable(variable, false)
×
660
   end
661
end
662

663
function CslEngine:_number (options, content, entry)
1✔
NEW
664
   local variable = SU.required(options, "variable", "CSL number")
×
NEW
665
   local value = entry[variable]
×
NEW
666
   self:_addGroupVariable(variable, value)
×
NEW
667
   if variable == "locator" then -- special case
×
NEW
668
      value = value and value.value
×
669
   end
NEW
670
   if value then
×
NEW
671
      local _, gender = self:_render_term(variable)
×
NEW
672
      local genderForm = gender or "neuter"
×
673

674
      -- FIXME TODO: Some complex stuff about name ranges, commas, etc. in the spec.
675
      -- Moreover:
676
      -- "Numbers with prefixes or suffixes are never ordinalized or rendered in roman numerals"
677
      -- Interpretation: values that are not numbers are not formatted (?)
NEW
678
      local form = tonumber(value) and options.form or "numeric"
×
NEW
679
      if form == "ordinal" then
×
NEW
680
         value = self.locale:ordinal(value, "short", genderForm)
×
NEW
681
      elseif form == "long-ordinal" then
×
NEW
682
         value = self.locale:ordinal(value, "long", genderForm)
×
NEW
683
      elseif form == "roman" then
×
NEW
684
         value = SU.formatNumber(value, { system = "roman" })
×
685
      end
686
   end
NEW
687
   value = self:_render_textCase(value, options)
×
NEW
688
   value = self:_render_formatting(value, options)
×
NEW
689
   value = self:_render_affixes(value, options)
×
NEW
690
   value = self:_render_display(value, options)
×
NEW
691
   return value
×
692
end
693

694
function CslEngine:_enterSubstitute (t)
1✔
NEW
695
   SU.debug("csl", "Enter substitute")
×
696
   -- Some group and variable cancellation logic applies to cs:substitute.
697
   -- Wrap it in a pseudo-group to track referenced variables.
NEW
698
   self:_enterGroup()
×
NEW
699
   return t
×
700
end
701

702
function CslEngine:_leaveSubstitute (t, entry)
1✔
NEW
703
   SU.debug("csl", "Leave substitute")
×
NEW
704
   local vars = self.groupState.variables
×
705
   -- "Substituted variables are considered empty for the purposes of
706
   -- determining whether to suppress an enclosing cs:group."
707
   -- So it's as if we hadn't seen any variable in our substitute.
NEW
708
   self.groupState.variables = {}
×
709
   -- "Substituted variables are suppressed in the rest of the output
710
   -- to prevent duplication"
711
   -- So if the substitution was successful, we remove referenced variables
712
   -- from the entry.
NEW
713
   if t then
×
NEW
714
      for field, cond in pairs(vars) do
×
NEW
715
         if cond then
×
NEW
716
            entry[field] = nil
×
717
         end
718
      end
719
   end
720
   -- Terminate the pseudo-group
NEW
721
   t = self:_leaveGroup(t)
×
NEW
722
   return t
×
723
end
724

725
function CslEngine:_substitute (options, content, entry)
1✔
726
   local t
NEW
727
   for _, child in ipairs(content) do
×
NEW
728
      self:_enterSubstitute()
×
NEW
729
      if child.command == "cs:names" then
×
NEW
730
         SU.required(child.options, "variable", "CSL cs:names in cs:substitute")
×
NEW
731
         local opts = pl.tablex.union(options, child.options)
×
NEW
732
         t = self:_names_with_resolved_opts(opts, nil, entry)
×
733
      else
NEW
734
         t = self:_render_node(child, entry)
×
735
      end
NEW
736
      t = self:_leaveSubstitute(t, entry)
×
NEW
737
      if t then -- First non-empty child is returned
×
738
         break
739
      end
740
   end
NEW
741
   return t
×
742
end
743

744
function CslEngine:_name_et_al (options)
1✔
NEW
745
   local t = self:_render_term(options.term or "et-al")
×
NEW
746
   t = self:_render_formatting(t, options)
×
NEW
747
   return t
×
748
end
749

750
function CslEngine:_a_name (options, content, entry)
1✔
NEW
751
   if not entry.family then
×
752
      -- There's one element in a name we can't do without.
NEW
753
      SU.error("Name without family: what do you expect me to do with it?")
×
754
   end
NEW
755
   local demoteNonDroppingParticle = options["demote-non-dropping-particle"] or "never"
×
756

NEW
757
   if self.sorting then
×
758
      -- Implicitely we are in long form, name-as-sort-order all, and no formatting.
NEW
759
      if demoteNonDroppingParticle == "never" then
×
760
         -- Order is: [NDP] Family [Given] [Suffix] e.g. van Gogh Vincent III
NEW
761
         local name = {}
×
NEW
762
         if entry["non-dropping-particle"] then table.insert(name, entry["non-dropping-particle"]) end
×
NEW
763
         table.insert(name, entry.family)
×
NEW
764
         if entry.given then table.insert(name, entry.given) end
×
NEW
765
         if entry.suffix then table.insert(name, entry.suffix) end
×
NEW
766
         return table.concat(name, " ")
×
767
      end
768
      -- Order is: Family [Given] [DP] [Suffix] e.g. Gogh Vincent van III
NEW
769
      local name = { entry.family }
×
NEW
770
      if entry.given then table.insert(name, entry.given) end
×
NEW
771
      if entry["dropping-particle"] then table.insert(name, entry["dropping-particle"]) end
×
NEW
772
      if entry["non-dropping-particle"] then table.insert(name, entry["non-dropping-particle"]) end
×
NEW
773
      if entry.suffix then table.insert(name, entry.suffix) end
×
NEW
774
      return table.concat(name, " ")
×
775
   end
776

NEW
777
   local form = options.form
×
NEW
778
   local nameAsSortOrder = options["name-as-sort-order"] or "first"
×
779

780
   -- TODO FIXME: content can consists in name-part elements for formatting, text-case, affixes
781
   -- Chigaco style does not seem to use them, so we keep it "simple" for now.
782

NEW
783
   if form == "short" then
×
784
      -- Order is: [NDP] Family, e.g. van Gogh
NEW
785
      if entry["non-dropping-particle"] then
×
NEW
786
         return table.concat({
×
NEW
787
            entry["non-dropping-particle"],
×
788
            entry.family
NEW
789
         }, " ")
×
790
      end
NEW
791
      return entry.family
×
792
   end
793

NEW
794
   if nameAsSortOrder ~= "all" and not self.firstName then
×
795
      -- Order is: [Given] [DP] [NDP] Family [Suffix] e.g. Vincent van Gogh III
NEW
796
      local t = {}
×
NEW
797
      if entry.given then table.insert(t, entry.given) end
×
NEW
798
      if entry["dropping-particle"] then table.insert(t, entry["dropping-particle"]) end
×
NEW
799
      if entry["non-dropping-particle"] then table.insert(t, entry["non-dropping-particle"]) end
×
NEW
800
      table.insert(t, entry.family)
×
NEW
801
      if entry.suffix then table.insert(t, entry.suffix) end
×
NEW
802
      return table.concat(t, " ")
×
803
   end
804

NEW
805
   local sep = options["sort-separator"] or (self.punctuation[","] .. " ")
×
NEW
806
   if demoteNonDroppingParticle == "display-and-sort" then
×
807
      -- Order is: Family, [Given] [DP] [NDP], [Suffix] e.g. Gogh, Vincent van, III
NEW
808
      local mid = {}
×
NEW
809
      if entry.given then table.insert(mid, entry.given) end
×
NEW
810
      if entry["dropping-particle"] then table.insert(mid, entry["dropping-particle"]) end
×
NEW
811
      if entry["non-dropping-particle"] then table.insert(mid, entry["non-dropping-particle"]) end
×
NEW
812
      local midname = table.concat(mid, " ")
×
NEW
813
      if #midname > 0 then
×
NEW
814
         return table.concat({
×
NEW
815
            entry.family,
×
816
            midname,
817
            entry.suffix -- may be nil
NEW
818
         }, sep)
×
819
      end
NEW
820
      return table.concat({
×
NEW
821
         entry.family,
×
822
         entry.suffix -- may be nil
NEW
823
      }, sep)
×
824
   end
825

826
   -- Order is: [NDP] Family, [Given] [DP], [Suffix] e.g. van Gogh, Vincent, III
NEW
827
   local beg = {}
×
NEW
828
   if entry["non-dropping-particle"] then table.insert(beg, entry["non-dropping-particle"]) end
×
NEW
829
   table.insert(beg, entry.family)
×
NEW
830
   local begname = table.concat(beg, " ")
×
NEW
831
   local mid = {}
×
NEW
832
   if entry.given then table.insert(mid, entry.given) end
×
NEW
833
   if entry["dropping-particle"] then table.insert(mid, entry["dropping-particle"]) end
×
NEW
834
   local midname = table.concat(mid, " ")
×
NEW
835
   if #midname > 0 then
×
NEW
836
      return table.concat({
×
837
         begname,
838
         midname,
839
         entry.suffix -- may be nil
NEW
840
      }, sep)
×
841
   end
NEW
842
   return table.concat({
×
843
      begname,
844
      entry.suffix -- may be nil
NEW
845
   }, sep)
×
846
end
847

848
local function hasField (list, field)
849
   -- N.B. we want a true boolean here
NEW
850
   if string.match(" " .. list .. " ", " " .. field .. " ") then
×
NEW
851
      return true
×
852
   end
NEW
853
   return false
×
854
end
855

856
function CslEngine:_names_with_resolved_opts (options, substitute_node, entry)
1✔
NEW
857
   local variable = options.variable
×
NEW
858
   local et_al_min = options.et_al_min
×
NEW
859
   local et_al_use_first = options.et_al_use_first
×
NEW
860
   local and_word = options.and_word
×
NEW
861
   local name_delimiter = options.name_delimiter
×
NEW
862
   local is_label_first = options.is_label_first
×
NEW
863
   local label_opts = options.label_opts
×
NEW
864
   local et_al_opts = options.et_al_opts
×
NEW
865
   local name_node = options.name_node
×
NEW
866
   local names_delimiter = options.names_delimiter
×
867

868
   -- Special case if both editor and translator are wanted and are the same person(s)
NEW
869
   local editortranslator = false
×
NEW
870
   if hasField(variable, "editor") and hasField(variable, "translator") then
×
NEW
871
      editortranslator = entry.translator and entry.editor and pl.tablex.deepcompare(entry.translator, entry.editor)
×
NEW
872
      if editortranslator then
×
NEW
873
         entry.editortranslator = entry.editor
×
874
      end
875
   end
876

877
   -- Process
NEW
878
   local vars = pl.stringx.split(variable, " ")
×
NEW
879
   local output = {}
×
NEW
880
   for _, var in ipairs(vars) do
×
NEW
881
      self:_addGroupVariable(var, entry[var])
×
882

NEW
883
      local skip = editortranslator and var == "translator" -- done via the "editor" field
×
NEW
884
      if not skip and entry[var] then
×
885
         local label
NEW
886
         if label_opts and not self.sorting then
×
887
            -- (labels in names are skipped in sorting mode)
NEW
888
            local v = var == "editor" and editortranslator and "editortranslator" or var
×
NEW
889
            local opts = pl.tablex.union(label_opts, { variable = v })
×
NEW
890
            label = self:_label(opts, nil, entry)
×
891
         end
NEW
892
         local needEtAl = false
×
NEW
893
         local names = type(entry[var]) == "table" and entry[var] or { entry[var] }
×
NEW
894
         local l = {}
×
NEW
895
         for i, name in ipairs(names) do
×
NEW
896
            if #names >= et_al_min and i > et_al_use_first then
×
NEW
897
               needEtAl = true
×
898
               break
899
            end
NEW
900
            local t = self:_a_name(name_node.options, name_node, name)
×
NEW
901
            self.firstName = false
×
NEW
902
            table.insert(l, t)
×
903
         end
904
         local joined
NEW
905
         if needEtAl then
×
906
            -- TODO THINGS TO SUPPORT THAT MIGHT REQUIRE A REFACTOR
907
            -- They are not needed in Chicago style, so let's keep it simple for now.
908
            --    delimiter-precedes-et-al ("contextual" by default = hard-coded)
909
            --    et-al-use-last (default false, if true, the last is rendered as ", ... Name) instead of using et-al.
NEW
910
            local rendered_et_all = self:_name_et_al(et_al_opts)
×
NEW
911
            local sep_et_al = #l > 1 and name_delimiter or " "
×
NEW
912
            joined = table.concat(l, name_delimiter) .. sep_et_al .. rendered_et_all
×
NEW
913
         elseif #l == 1 then
×
NEW
914
            joined = l[1]
×
915
         else
916
            -- TODO THINGS TO SUPPORT THAT MIGHT REQUIRE A REFACTOR
917
            -- They are needed in Chicago style, but let's keep it simple for now.
918
            --   delimiter-precedes-last ("contextual" by default)
919
            -- Chicago has "always", let's hard-code it for now.
NEW
920
            local last = table.remove(l)
×
NEW
921
            joined = table.concat(l, name_delimiter) .. name_delimiter .. and_word .. " " .. last
×
922
         end
NEW
923
         if label then
×
NEW
924
            joined = is_label_first and (label .. joined) or (joined .. label)
×
925
         end
NEW
926
         table.insert(output, joined)
×
927
      end
928
   end
929

NEW
930
   if #output == 0 and substitute_node then
×
NEW
931
      return self:_substitute(options, substitute_node, entry)
×
932
   end
NEW
933
   if #output == 0 then
×
NEW
934
      return nil
×
935
   end
NEW
936
   local t = self:_render_delimiter(output, names_delimiter)
×
NEW
937
   t = self:_render_formatting(t, options)
×
NEW
938
   t = self:_render_affixes(t, options)
×
NEW
939
   t = self:_render_display(t, options)
×
NEW
940
   return t
×
941
end
942

943
function CslEngine:_names (options, content, entry)
1✔
944
   -- Extract needed elements and options from the content
945
   local name_node = nil
946
   local label_opts = nil
NEW
947
   local et_al_opts = {}
×
948
   local substitute = nil
NEW
949
   local is_label_first = false
×
NEW
950
   for _, child in ipairs(content) do
×
NEW
951
      if child.command == "cs:substitute" then
×
NEW
952
         substitute = child
×
NEW
953
      elseif child.command == "cs:et-al" then
×
NEW
954
         et_al_opts = child.options
×
NEW
955
      elseif child.command == "cs:label" then
×
NEW
956
         if not name_node then
×
NEW
957
            is_label_first = true
×
958
         end
NEW
959
         label_opts = child.options
×
NEW
960
      elseif child.command == "cs:name" then
×
NEW
961
         name_node = child
×
962
      end
963
   end
NEW
964
   if not name_node then
×
NEW
965
      name_node = { command = "cs:name", options = {} }
×
966
   end
967
   -- Build inherited options
NEW
968
   local inherited_opts = pl.tablex.union(self.inheritable[self.mode], options)
×
NEW
969
   name_node.options = pl.tablex.union(inherited_opts, name_node.options)
×
NEW
970
   name_node.options.form = name_node.options.form or inherited_opts["name-form"]
×
NEW
971
   local et_al_min = tonumber(name_node.options["et-al-min"]) or 4 -- No default in the spec, using Chicago's
×
NEW
972
   local et_al_use_first = tonumber(name_node.options["et-al-use-first"]) or 1
×
NEW
973
   local and_opt = name_node.options["and"] or "text"
×
NEW
974
   local and_word = and_opt == "symbol" and "&amp;" or self:_render_term("and") -- text by default
×
NEW
975
   local name_delimiter = name_node.options.delimiter or inherited_opts["names-delimiter"] or ", "
×
976
   -- local delimiter_precedes_et_al = name_node.options["delimiter-precedes-et-al"] -- TODO NOT IMPLEMENTED
977

NEW
978
   if name_delimiter and not self.cache[name_delimiter] then
×
NEW
979
      name_delimiter = self:_xmlEscape(name_delimiter)
×
NEW
980
      self.cache[name_delimiter] = name_delimiter
×
981
   end
982

NEW
983
   local resolved = {
×
984
      variable = SU.required(name_node.options, "variable", "CSL names"),
985
      et_al_min = et_al_min,
986
      et_al_use_first = et_al_use_first,
987
      and_word = and_word,
988
      name_delimiter = name_delimiter and self.cache[name_delimiter],
989
      is_label_first = is_label_first,
990
      label_opts = label_opts,
991
      et_al_opts = et_al_opts,
992
      name_node = name_node,
993
      names_delimiter = options.delimiter or inherited_opts["names-delimiter"],
994
   }
NEW
995
   resolved = pl.tablex.union(options, resolved)
×
996

NEW
997
   return self:_names_with_resolved_opts(resolved, substitute, entry)
×
998
end
999

1000
function CslEngine:_label (options, content, entry)
1✔
NEW
1001
   local variable = SU.required(options, "variable", "CSL label")
×
NEW
1002
   local value = entry[variable]
×
NEW
1003
   self:_addGroupVariable(variable, value)
×
NEW
1004
   if variable == "locator" then
×
NEW
1005
      variable = value and value.label
×
NEW
1006
      value = value and value.value
×
1007
   end
NEW
1008
   if value then
×
NEW
1009
      local plural = options.plural
×
NEW
1010
      if plural == "always" then
×
NEW
1011
         plural = true
×
NEW
1012
      elseif plural == "never" then
×
NEW
1013
         plural = false
×
1014
      else -- "contextual" by default
NEW
1015
         if variable == "number-of-pages" or variable == "number-of-volumes" then
×
NEW
1016
            local v = tonumber(value)
×
NEW
1017
            plural = v and v > 1 or false
×
1018
         else
NEW
1019
            if type(value) == "table" then
×
NEW
1020
               plural = #value > 1
×
1021
            else
NEW
1022
               local _, count = string.gsub(tostring(value), "%d+", "") -- naive count of numbers
×
NEW
1023
               plural = count > 1
×
1024
            end
1025
         end
1026
      end
NEW
1027
      value = self:_render_term(variable, options.form or "long", plural)
×
NEW
1028
      value = self:_render_stripPeriods(value, options)
×
NEW
1029
      value = self:_render_textCase(value, options)
×
NEW
1030
      value = self:_render_formatting(value, options)
×
NEW
1031
      value = self:_render_affixes(value, options)
×
NEW
1032
      return value
×
1033
   end
NEW
1034
   return value
×
1035
end
1036

1037
function CslEngine:_group (options, content, entry)
1✔
NEW
1038
   self:_enterGroup()
×
1039

NEW
1040
   local t = self:_render_children(content, entry, { delimiter = options.delimiter })
×
NEW
1041
   t = self:_render_formatting(t, options)
×
NEW
1042
   t = self:_render_affixes(t, options)
×
NEW
1043
   t = self:_render_display(t, options)
×
1044

NEW
1045
   t = self:_leaveGroup(t) -- Takes care of group suppression
×
NEW
1046
   return t
×
1047
end
1048

1049
function CslEngine:_if (options, content, entry)
1✔
NEW
1050
   local match = options.match or "all"
×
NEW
1051
   local conds = {}
×
NEW
1052
   if options.variable then
×
NEW
1053
      local vars = pl.stringx.split(options.variable, " ")
×
NEW
1054
      for _, var in ipairs(vars) do
×
NEW
1055
         local cond = entry[var] and true or false
×
NEW
1056
         table.insert(conds, cond)
×
1057
      end
1058
   end
NEW
1059
   if options.type then
×
NEW
1060
      local types = pl.stringx.split(options.type, " ")
×
NEW
1061
      local cond = false
×
1062
      -- Different from other conditions:
1063
      -- For types, Zeping Lee explained the matching is always "any".
NEW
1064
      for _, typ in ipairs(types) do
×
NEW
1065
         if entry.type == typ then
×
NEW
1066
            cond = true
×
1067
            break
1068
         end
1069
      end
NEW
1070
      table.insert(conds, cond)
×
1071
   end
NEW
1072
   if options["is-numeric"] then
×
NEW
1073
      for _, var in ipairs(pl.stringx.split(options["is-numeric"], " ")) do
×
1074
         -- TODO FIXME NOT IMPLEMENTED FULLY
1075
         -- Content is considered numeric if it solely consists of numbers.
1076
         -- Numbers may have prefixes and suffixes (“D2”, “2b”, “L2d”), and may
1077
         -- be separated by a comma, hyphen, or ampersand, with or without
1078
         -- spaces (“2, 3”, “2-4”, “2 & 4”). For example, “2nd” tests “true” whereas
1079
         -- “second” and “2nd edition” test “false”.
NEW
1080
         local cond = tonumber(entry[var]) and true or false
×
NEW
1081
         table.insert(conds, cond)
×
1082
      end
1083
   end
NEW
1084
   if options["is-uncertain-date"] then
×
NEW
1085
      for _, var in ipairs(pl.stringx.split(options["is-uncertain-date"], " ")) do
×
NEW
1086
         local d = type(entry[var]) == "table" and entry[var]
×
NEW
1087
         local cond = d and d.approximate and true or false
×
NEW
1088
         table.insert(conds, cond)
×
1089
      end
1090
   end
NEW
1091
   if options.locator then
×
NEW
1092
      for _, loc in ipairs(pl.stringx.split(options.locator, " ")) do
×
NEW
1093
         local cond = entry.locator and entry.locator.label == loc or false
×
NEW
1094
         table.insert(conds, cond)
×
1095
      end
1096
   end
1097
   -- FIXME TODO other conditions: position, disambiguate
NEW
1098
   for _, v in ipairs({ "position", "disambiguate" }) do
×
NEW
1099
      if options[v] then
×
NEW
1100
         SU.warn("CSL if condition " .. v .. " not implemented yet")
×
NEW
1101
         table.insert(conds, false)
×
1102
      end
1103
   end
1104
   -- Apply match
NEW
1105
   local matching = match ~= "any"
×
NEW
1106
   for _, cond in ipairs(conds) do
×
NEW
1107
      if match == "all" then
×
NEW
1108
         if not cond then
×
NEW
1109
            matching = false
×
1110
            break
1111
         end
NEW
1112
      elseif match == "any" then
×
NEW
1113
         if cond then
×
NEW
1114
            matching = true
×
1115
            break
1116
         end
NEW
1117
      elseif match == "none" then
×
NEW
1118
         if cond then
×
NEW
1119
            matching = false
×
1120
            break
1121
         end
1122
      end
1123
   end
NEW
1124
   if matching then
×
NEW
1125
      return self:_render_children(content, entry), true
×
1126
      -- FIXME:
1127
      -- The CSL specification says: "Delimiters from the nearest delimiters
1128
      -- from the nearest ancestor delimiting element are applied within the
1129
      -- output of cs:choose (i.e., the output of the matching cs:if,
1130
      -- cs:else-if, or cs:else; see delimiter).""
1131
      -- Ugh. This is rather obscure and not implemented yet (?)
1132
   end
NEW
1133
   return nil, false
×
1134
end
1135

1136
function CslEngine:_choose (options, content, entry)
1✔
NEW
1137
   for _, child in ipairs(content) do
×
NEW
1138
      if child.command == "cs:if" or child.command == "cs:else-if" then
×
NEW
1139
         local t, match = self:_if(child.options, child, entry)
×
NEW
1140
         if match then
×
NEW
1141
            return t
×
1142
         end
NEW
1143
      elseif child.command == "cs:else" then
×
NEW
1144
         return self:_render_children(child, entry)
×
1145
      end
1146
   end
1147
end
1148

1149
local function dateToYYMMDD (date)
1150
   --- Year from BibLaTeX year field may be a literal
NEW
1151
   local y = type(date.year) == "number" and date.year or tonumber(date.year) or 0
×
NEW
1152
   local m = date.month or 0
×
NEW
1153
   local d = date.day or 0
×
NEW
1154
   return ("%04d%02d%02d"):format(y, m, d)
×
1155
end
1156

1157
function CslEngine:_key (options, content, entry)
1✔
1158
   -- Attribute 'sort' is managed at a higher level
1159
   -- NOT IMPLEMENTED:
1160
   -- Attributes 'names-min', 'names-use-first', and 'names-use-last'
1161
   -- (overrides for the 'et-al-xxx' attributes)
NEW
1162
   if options.macro then
×
NEW
1163
      return self:_render_children(self.macros[options.macro], entry)
×
1164
   end
NEW
1165
   if options.variable then
×
NEW
1166
      local value = entry[options.variable]
×
NEW
1167
      if type(value) == "table" then
×
NEW
1168
         if value.range then
×
NEW
1169
            if value.startdate and value.enddate then
×
NEW
1170
               return dateToYYMMDD(value.startdate) .. "-" .. dateToYYMMDD(value.enddate)
×
1171
            end
NEW
1172
            if value.startdate then
×
NEW
1173
               return dateToYYMMDD(value.startdate) .. "-"
×
1174
            end
NEW
1175
            if value.enddate then
×
NEW
1176
               return dateToYYMMDD(value.enddate)
×
1177
            end
NEW
1178
            return dateToYYMMDD(value.from) .. "-" .. dateToYYMMDD(value.to)
×
1179
         end
NEW
1180
         if value.year or value.month or value.day then
×
NEW
1181
            return dateToYYMMDD(value)
×
1182
         end
1183
         -- FIXME names need a special rule here
1184
         -- Chicago style use macro here, so not considered for now.
NEW
1185
         SU.error("CSL variable not yet usable for sorting: " .. options.variable)
×
1186
      end
NEW
1187
      return value
×
1188
   end
NEW
1189
   SU.error("CSL key without variable or macro")
×
1190
end
1191

1192
-- FIXME: A bit ugly: When implementing SU.collatedSort, I didn't consider
1193
-- sorting structured tables, so we need to go low level here.
1194
-- Moreover, I made icu.compare return a boolean, so we have to pay twice
1195
-- the comparison cost to check equality...
1196
-- See PR #2105
1197
local icu = require("justenoughicu")
1✔
1198

1199
function CslEngine:_sort (options, content, entries)
1✔
NEW
1200
   if not self.sorting then
×
1201
      -- Skipped at rendering
NEW
1202
      return
×
1203
   end
1204
   -- Store the sort order for each key
NEW
1205
   local ordering = {}
×
NEW
1206
   for _, child in ipairs(content) do
×
NEW
1207
      if child.command == "cs:key" then
×
NEW
1208
         table.insert(ordering, child.options.sort ~= "descending") -- true for ascending (default)
×
1209
      end
1210
   end
1211
   -- Compute the sorting keys for each entry
NEW
1212
   for _, entry in ipairs(entries) do
×
NEW
1213
      local keys = {}
×
NEW
1214
      for _, child in ipairs(content) do
×
NEW
1215
         if child.command == "cs:key" then
×
NEW
1216
            self:_prerender()
×
1217
            -- Deep copy the entry as cs:substitute may remove fields
1218
            -- And we may need them back in actual rendering
NEW
1219
            local ent = pl.tablex.deepcopy(entry)
×
NEW
1220
            local key = self:_key(child.options, child, ent)
×
1221
            -- No _postrender here, as we don't want to apply punctuation (?)
NEW
1222
            table.insert(keys, key or "")
×
1223
         end
1224
      end
NEW
1225
      entry._keys = keys
×
1226
   end
1227
   -- Perform the sort
1228
   -- Using the locale language (BCP47).
NEW
1229
   local lang = self.locale.lang
×
NEW
1230
   local collator = icu.collation_create(lang, {})
×
NEW
1231
   table.sort(entries, function (a, b)
×
NEW
1232
      if (a["citation-key"] == b["citation-key"]) then
×
1233
         -- Lua can invoke the comparison function with the same entry.
1234
         -- Really! Due to the way it handles it pivot on partitioning.
1235
         -- Shortcut the inner keys comparison in that case.
NEW
1236
         return false
×
1237
      end
NEW
1238
      local ak = a._keys
×
NEW
1239
      local bk = b._keys
×
NEW
1240
      for i = 1, #ordering do
×
1241
         -- "Items with an empty sort key value are placed at the end of the sort,
1242
         -- both for ascending and descending sorts."
NEW
1243
         if ak[i] == "" then return bk[i] == "" end
×
NEW
1244
         if bk[i] == "" then return true
×
1245
         end
1246

NEW
1247
         if ak[i] ~= bk[i] then -- HACK: See comment above, ugly inequality check
×
NEW
1248
            local cmp = icu.compare(collator, ak[i], bk[i])
×
1249
            -- Hack to keep on working whenever PR #2105 lands and changes icu.compare
1250
            local islower
NEW
1251
            if type(cmp) == "number" then islower = cmp < 0
×
NEW
1252
            else islower = cmp end
×
1253
            -- Now order accordingly
NEW
1254
            if ordering[i] then return islower
×
NEW
1255
            else return not islower end
×
1256
         end
1257
      end
1258
      -- If we reach this point, the keys are equal (or we had no keys)
1259
      -- Probably unlikely in real life, and not mentioned in the CSL spec
1260
      -- unless I missed it. Let's fallback to the citation order, so at
1261
      -- least cited entries are ordered predictably.
NEW
1262
      SU.warn("CSL sort keys are equal for " .. a["citation-key"] .. " and " .. b["citation-key"])
×
NEW
1263
      return a["citation-number"] < b["citation-number"]
×
1264
   end)
NEW
1265
   icu.collation_destroy(collator)
×
1266
end
1267

1268
-- PROCESSING
1269

1270
function CslEngine:_render_node (node, entry)
1✔
NEW
1271
   local callback = node.command:gsub("cs:", "_")
×
NEW
1272
   if self[callback] then
×
NEW
1273
      return self[callback](self, node.options, node, entry)
×
1274
   else
NEW
1275
      SU.warn("Unknown CSL element " .. node.command .. " (" .. callback .. ")")
×
1276
   end
1277
end
1278

1279
function CslEngine:_render_children (ast, entry, context)
1✔
NEW
1280
   if not ast then
×
NEW
1281
      return
×
1282
   end
NEW
1283
   local ret = {}
×
NEW
1284
   context = context or {}
×
NEW
1285
   for _, content in ipairs(ast) do
×
NEW
1286
      if type(content) == "table" and content.command then
×
NEW
1287
         local r = self:_render_node(content, entry)
×
NEW
1288
         if r then
×
NEW
1289
            table.insert(ret, r)
×
1290
         end
1291
      else
NEW
1292
         SU.error("CSL unexpected content") -- Should not happen
×
1293
      end
1294
   end
NEW
1295
   return #ret > 0 and self:_render_delimiter(ret, context.delimiter) or nil
×
1296
end
1297

1298
function CslEngine:_postrender (text)
1✔
NEW
1299
   local rdquote = self.punctuation.close_quote
×
NEW
1300
   local ldquote = self.punctuation.open_quote
×
NEW
1301
   local rsquote = self.punctuation.close_inner_quote
×
NEW
1302
   local piquote = SU.boolean(self.locale.styleOptions["punctuation-in-quote"], false)
×
1303

1304
   -- Typography: Ensure there are no double straight quotes left from the input.
NEW
1305
   text = luautf8.gsub(text, '^"', ldquote)
×
NEW
1306
   text = luautf8.gsub(text, '"$', rdquote)
×
NEW
1307
   text = luautf8.gsub(text, '([%s%p])"', "%1" .. ldquote)
×
NEW
1308
   text = luautf8.gsub(text, '"([%s%p])', rdquote .. "%1")
×
1309
   -- HACK: punctuation-in-quote is applied globally, not just to generated quotes.
1310
   -- Not so sure it's the intended behavior from the specification?
NEW
1311
   if piquote then
×
1312
      -- move commas and periods before closing quotes
NEW
1313
      text = luautf8.gsub(text, "([" .. rdquote .. rsquote .. "]+)%s*([.,])", "%2%1")
×
1314
   end
1315
   -- HACK: fix some double punctuation issues.
1316
   -- Maybe some more robust way to handle affixes and delimiters would be better?
NEW
1317
   text = luautf8.gsub(text, "%.%.", ".")
×
1318
   -- Typography: Prefer to have commas and periods inside italics.
1319
   -- (Better looking if italic automated corrections are applied.)
NEW
1320
   text = luautf8.gsub(text, "(</em>)([%.,])", "%2%1")
×
1321
   -- HACK: remove extraneous periods after exclamation and question marks.
1322
   -- (Follows the preceding rule to also account for moved periods.)
NEW
1323
   text = luautf8.gsub(text, "([…!?])%.", "%1")
×
NEW
1324
   if not piquote then
×
1325
      -- HACK: remove extraneous periods after quotes.
1326
      -- Opinionated, e.g. for French at least, some typographers wouldn't
1327
      -- frown upon a period after a quote ending with an exclamation mark
1328
      -- or a question mark. But it's ugly.
NEW
1329
      text = luautf8.gsub(text, "([…!?%.]" .. rdquote .. ")%.", "%1")
×
1330
   end
NEW
1331
   return text
×
1332
end
1333

1334
function CslEngine:_process (entries, mode)
1✔
NEW
1335
   if mode ~= "citation" and mode ~= "bibliography" then
×
NEW
1336
      SU.error("CSL processing mode must be 'citation' or 'bibliography'")
×
1337
   end
NEW
1338
   self.mode = mode
×
1339
   -- Deep copy the entries as cs:substitute may remove fields
NEW
1340
   entries = pl.tablex.deepcopy(entries)
×
1341

NEW
1342
   local ast = self[mode]
×
NEW
1343
   if not ast then
×
NEW
1344
      SU.error("CSL style has no " .. mode .. " definition")
×
1345
   end
NEW
1346
   local sort = SU.ast.findInTree(ast, "cs:sort")
×
NEW
1347
   if sort then
×
NEW
1348
      self.sorting = true
×
NEW
1349
      self:_sort(sort.options, sort, entries)
×
NEW
1350
      self.sorting = false
×
1351
   else
1352
      -- The CSL specification says:
1353
      -- "In the absence of cs:sort, cites and bibliographic entries appear in
1354
      -- the order in which they are cited."
1355
      -- We tracked the first citation number in 'citation-number', so for
1356
      -- the bibliography, using it makes sense.
1357
      -- For citations, we use the exact order of the input. Consider a cite
1358
      -- (work1, work2) and a subsequent cite (work2, work1). The order of
1359
      -- the bibliography should be (work1, work2), but the order of the cites
1360
      -- should be (work1, work2) and (work2, work1) respectively.
1361
      -- It seeems to be the case: Some styles (ex. American Chemical Society)
1362
      -- have an explicit sort by 'citation-number' in the citations section,
1363
      -- which would be useless if that order was impplied.
NEW
1364
      if mode == "bibliography" then
×
NEW
1365
         table.sort(entries, function (e1, e2)
×
NEW
1366
            if not e1["citation-number"] or not e2["citation-number"] then
×
NEW
1367
               return false; -- Safeguard?
×
1368
            end
NEW
1369
            return e1["citation-number"] < e2["citation-number"]
×
1370
         end)
1371
      end
1372
   end
1373

NEW
1374
   return self:_render_children(ast, entries)
×
1375
end
1376

1377
--- Generate a citation string.
1378
-- @tparam table entry List of CSL entries
1379
-- @treturn string The XML citation string
1380
function CslEngine:cite (entries)
1✔
NEW
1381
   entries = type(entries) == "table" and not entries.type and entries or { entries }
×
NEW
1382
   return self:_process(entries, "citation")
×
1383
end
1384

1385
--- Generate a reference string.
1386
-- @tparam table entry List of CSL entries
1387
-- @treturn string The XML reference string
1388
function CslEngine:reference (entries)
1✔
NEW
1389
   entries = type(entries) == "table" and not entries.type and entries or { entries }
×
NEW
1390
   return self:_process(entries, "bibliography")
×
1391
end
1392

1393
return {
1✔
1394
   CslEngine = CslEngine,
1✔
1395
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc