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

sile-typesetter / sile / 10864440887

14 Sep 2024 06:02PM UTC coverage: 58.033% (-10.9%) from 68.912%
10864440887

push

github

web-flow
Merge dea1be4ad into 8bed2e6bb

0 of 1173 new or added lines in 10 files covered. (0.0%)

1202 existing lines in 39 files now uncovered.

10797 of 18605 relevant lines covered (58.03%)

2074.85 hits per line

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

0.0
/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

NEW
21
local CslLocale = require("csl.core.locale").CslLocale
×
22

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

NEW
27
local CslEngine = pl.class()
×
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
NEW
38
function CslEngine:_init (style, locale, extras)
×
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
   -- For page ranges, see text processing for <text variable="page">
NEW
72
   local sep = self.punctuation.page_range_delimiter
×
NEW
73
   local dashes = "%-" .. endash .. emdash
×
NEW
74
   if sep ~= "-" and sep ~= endash and sep ~= emdash then
×
75
      local escape = function (str)
NEW
76
         local escapeInLua = "[%.%+%*%[%?%-%%]"
×
NEW
77
         return string.gsub(str, "([%.%+%*%[%?%-%%])", "%%%1")
×
78
      end
NEW
79
      dashes = dashes .. string.gsub(sep, "([%.%+%*%?%-%]%[%%])", "%%%1")
×
80
   end
NEW
81
   local textinrange = "[^" .. dashes .. "]+"
×
NEW
82
   local dashinrange = "[" .. dashes .. "]+"
×
NEW
83
   self.page_range_capture = "(" .. textinrange .. ")%s*" .. dashinrange .. "%s*(" .. textinrange .. ")"
×
84

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

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

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

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

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

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

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

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

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

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

204
-- INTERNAL HELPERS
205

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

NEW
416
function CslEngine:_text (options, content, entry)
×
417
   local t
418
   local link
NEW
419
   if options.macro then
×
NEW
420
      if self.macros[options.macro] then
×
NEW
421
         t = self:_render_children(self.macros[options.macro], entry)
×
422
      else
NEW
423
         SU.error("CSL macro " .. options.macro .. " not found")
×
424
      end
NEW
425
   elseif options.term then
×
NEW
426
      t = self:_render_term(options.term, options.form, options.plural)
×
NEW
427
   elseif options.variable then
×
NEW
428
      local variable = options.variable
×
NEW
429
      t = entry[variable]
×
NEW
430
      self:_addGroupVariable(variable, t)
×
NEW
431
      if variable == "locator" then
×
NEW
432
         t = t and t.value
×
NEW
433
         variable = entry.locator.label
×
434
      end
NEW
435
      if variable == "page" and t then
×
436
         -- Replace any dash in page ranges
NEW
437
         local sep = self.punctuation.page_range_delimiter
×
NEW
438
         t = luautf8.gsub(t, self.page_range_capture, "%1" .. sep .. "%2")
×
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

NEW
470
function CslEngine:_a_day (options, day, month) -- month needed to get gender for ordinal
×
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

NEW
493
function CslEngine:_a_month (options, month)
×
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

NEW
508
function CslEngine:_a_season (options, season)
×
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

NEW
523
function CslEngine:_a_year (options, year)
×
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

NEW
542
function CslEngine:_a_date_day (options, date)
×
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

NEW
557
function CslEngine:_a_date_month (options, date)
×
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

NEW
581
function CslEngine:_a_date_year (options, date)
×
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

NEW
596
function CslEngine:_date_part (options, content, date)
×
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

NEW
613
function CslEngine:_date_parts (options, content, date)
×
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

NEW
631
function CslEngine:_date (options, content, entry)
×
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

NEW
663
function CslEngine:_number (options, content, entry)
×
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

NEW
694
function CslEngine:_enterSubstitute (t)
×
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

NEW
702
function CslEngine:_leaveSubstitute (t, entry)
×
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

NEW
725
function CslEngine:_substitute (options, content, entry)
×
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

NEW
744
function CslEngine:_name_et_al (options)
×
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

NEW
750
function CslEngine:_a_name (options, content, entry)
×
NEW
751
   local form = options.form
×
NEW
752
   local nameAsSortOrder = options["name-as-sort-order"]
×
NEW
753
   if self.sorting then
×
754
      -- Ovveride form and name-as-sort-order in sorting mode
NEW
755
      form = "long"
×
NEW
756
      nameAsSortOrder = "all"
×
757
   end
758

759
   -- TODO FIXME: content can consists in name-part elements for formatting, text-case, affixes
760
   -- Chigaco style does not seem to use them, so we keep it simple for now.
761
   -- TODO FIXME: demote-non-dropping-particle option not implemented, and name particle not implemented at all!
762

NEW
763
   if form == "short" then
×
NEW
764
      return entry.family
×
765
   end
NEW
766
   if nameAsSortOrder ~= "all" and not self.firstName then
×
767
      -- Order is: Given Family
NEW
768
      return entry.given and (entry.given .. " " .. entry.family) or entry.family
×
769
   end
770
   -- Order is: Family, Given
NEW
771
   local sep = options["sort-separator"] or (self.punctuation[","] .. " ")
×
NEW
772
   return entry.given and (entry.family .. sep .. entry.given) or entry.family
×
773
end
774

775
local function hasField (list, field)
776
   -- N.B. we want a true boolean here
NEW
777
   if string.match(" " .. list .. " ", " " .. field .. " ") then
×
NEW
778
      return true
×
779
   end
NEW
780
   return false
×
781
end
782

NEW
783
function CslEngine:_names_with_resolved_opts (options, substitute_node, entry)
×
NEW
784
   local variable = options.variable
×
NEW
785
   local et_al_min = options.et_al_min
×
NEW
786
   local et_al_use_first = options.et_al_use_first
×
NEW
787
   local and_word = options.and_word
×
NEW
788
   local name_delimiter = options.name_delimiter
×
NEW
789
   local is_label_first = options.is_label_first
×
NEW
790
   local label_opts = options.label_opts
×
NEW
791
   local et_al_opts = options.et_al_opts
×
NEW
792
   local name_node = options.name_node
×
NEW
793
   local names_delimiter = options.names_delimiter
×
794

795
   -- Special case if both editor and translator are wanted and are the same person(s)
NEW
796
   local editortranslator = false
×
NEW
797
   if hasField(variable, "editor") and hasField(variable, "translator") then
×
NEW
798
      editortranslator = entry.translator and entry.editor and pl.tablex.deepcompare(entry.translator, entry.editor)
×
NEW
799
      if editortranslator then
×
NEW
800
         entry.editortranslator = entry.editor
×
801
      end
802
   end
803

804
   -- Process
NEW
805
   local vars = pl.stringx.split(variable, " ")
×
NEW
806
   local output = {}
×
NEW
807
   for _, var in ipairs(vars) do
×
NEW
808
      self:_addGroupVariable(var, entry[var])
×
809

NEW
810
      local skip = editortranslator and var == "translator" -- done via the "editor" field
×
NEW
811
      if not skip and entry[var] then
×
812
         local label
NEW
813
         if label_opts and not self.sorting then
×
814
            -- (labels in names are skipped in sorting mode)
NEW
815
            local v = var == "editor" and editortranslator and "editortranslator" or var
×
NEW
816
            local opts = pl.tablex.union(label_opts, { variable = v })
×
NEW
817
            label = self:_label(opts, nil, entry)
×
818
         end
NEW
819
         local needEtAl = false
×
NEW
820
         local names = type(entry[var]) == "table" and entry[var] or { entry[var] }
×
NEW
821
         local l = {}
×
NEW
822
         for i, name in ipairs(names) do
×
NEW
823
            if #names >= et_al_min and i > et_al_use_first then
×
NEW
824
               needEtAl = true
×
825
               break
826
            end
NEW
827
            local t = self:_a_name(name_node.options, name_node, name)
×
NEW
828
            self.firstName = false
×
NEW
829
            table.insert(l, t)
×
830
         end
831
         local joined
NEW
832
         if needEtAl then
×
833
            -- TODO THINGS TO SUPPORT THAT MIGHT REQUIRE A REFACTOR
834
            -- They are not needed in Chicago style, so let's keep it simple for now.
835
            --    delimiter-precedes-et-al ("contextual" by default = hard-coded)
836
            --    et-al-use-last (default false, if true, the last is rendered as ", ... Name) instead of using et-al.
NEW
837
            local rendered_et_all = self:_name_et_al(et_al_opts)
×
NEW
838
            local sep_et_al = #l > 1 and name_delimiter or " "
×
NEW
839
            joined = table.concat(l, name_delimiter) .. sep_et_al .. rendered_et_all
×
NEW
840
         elseif #l == 1 then
×
NEW
841
            joined = l[1]
×
842
         else
NEW
843
            local last = table.remove(l)
×
NEW
844
            joined = table.concat(l, name_delimiter) .. " " .. and_word .. " " .. last
×
845
         end
NEW
846
         if label then
×
NEW
847
            joined = is_label_first and (label .. joined) or (joined .. label)
×
848
         end
NEW
849
         table.insert(output, joined)
×
850
      end
851
   end
852

NEW
853
   if #output == 0 and substitute_node then
×
NEW
854
      return self:_substitute(options, substitute_node, entry)
×
855
   end
NEW
856
   if #output == 0 then
×
NEW
857
      return nil
×
858
   end
NEW
859
   local t = self:_render_delimiter(output, names_delimiter)
×
NEW
860
   t = self:_render_formatting(t, options)
×
NEW
861
   t = self:_render_affixes(t, options)
×
NEW
862
   t = self:_render_display(t, options)
×
NEW
863
   return t
×
864
end
865

NEW
866
function CslEngine:_names (options, content, entry)
×
867
   -- Extract needed elements and options from the content
868
   local name_node = nil
869
   local label_opts = nil
NEW
870
   local et_al_opts = {}
×
871
   local substitute = nil
NEW
872
   local is_label_first = false
×
NEW
873
   for _, child in ipairs(content) do
×
NEW
874
      if child.command == "cs:substitute" then
×
NEW
875
         substitute = child
×
NEW
876
      elseif child.command == "cs:et-al" then
×
NEW
877
         et_al_opts = child.options
×
NEW
878
      elseif child.command == "cs:label" then
×
NEW
879
         if not name_node then
×
NEW
880
            is_label_first = true
×
881
         end
NEW
882
         label_opts = child.options
×
NEW
883
      elseif child.command == "cs:name" then
×
NEW
884
         name_node = child
×
885
      end
886
   end
NEW
887
   if not name_node then
×
NEW
888
      name_node = { command = "cs:name", options = {} }
×
889
   end
890
   -- Build inherited options
NEW
891
   local inherited_opts = pl.tablex.union(self.inheritable[self.mode], options)
×
NEW
892
   name_node.options = pl.tablex.union(inherited_opts, name_node.options)
×
NEW
893
   name_node.options.form = name_node.options.form or inherited_opts["name-form"]
×
NEW
894
   local et_al_min = tonumber(name_node.options["et-al-min"]) or 4 -- No default in the spec, using Chicago's
×
NEW
895
   local et_al_use_first = tonumber(name_node.options["et-al-use-first"]) or 1
×
NEW
896
   local and_opt = name_node.options["and"] or "text"
×
NEW
897
   local and_word = and_opt == "symbol" and "&amp;" or self:_render_term("and") -- text by default
×
NEW
898
   local name_delimiter = name_node.options.delimiter or inherited_opts["names-delimiter"]
×
899
   -- local delimiter_precedes_et_al = name_node.options["delimiter-precedes-et-al"] -- TODO NOT IMPLEMENTED
900

NEW
901
   if name_delimiter and not self.cache[name_delimiter] then
×
NEW
902
      name_delimiter = self:_xmlEscape(name_delimiter)
×
NEW
903
      self.cache[name_delimiter] = name_delimiter
×
904
   end
905

NEW
906
   local resolved = {
×
907
      variable = SU.required(name_node.options, "variable", "CSL names"),
908
      et_al_min = et_al_min,
909
      et_al_use_first = et_al_use_first,
910
      and_word = and_word,
911
      name_delimiter = name_delimiter and self.cache[name_delimiter],
912
      is_label_first = is_label_first,
913
      label_opts = label_opts,
914
      et_al_opts = et_al_opts,
915
      name_node = name_node,
916
      names_delimiter = options.delimiter or inherited_opts["names-delimiter"],
917
   }
NEW
918
   resolved = pl.tablex.union(options, resolved)
×
919

NEW
920
   return self:_names_with_resolved_opts(resolved, substitute, entry)
×
921
end
922

NEW
923
function CslEngine:_label (options, content, entry)
×
NEW
924
   local variable = SU.required(options, "variable", "CSL label")
×
NEW
925
   local value = entry[variable]
×
NEW
926
   self:_addGroupVariable(variable, value)
×
NEW
927
   if variable == "locator" then
×
NEW
928
      variable = value and value.label
×
NEW
929
      value = value and value.value
×
930
   end
NEW
931
   if value then
×
NEW
932
      local plural = options.plural
×
NEW
933
      if plural == "always" then
×
NEW
934
         plural = true
×
NEW
935
      elseif plural == "never" then
×
NEW
936
         plural = false
×
937
      else -- "contextual" by default
NEW
938
         if variable == "number-of-pages" or variable == "number-of-volumes" then
×
NEW
939
            local v = tonumber(value)
×
NEW
940
            plural = v and v > 1 or false
×
941
         else
NEW
942
            if type(value) == "table" then
×
NEW
943
               plural = #value > 1
×
944
            else
NEW
945
               local _, count = string.gsub(tostring(value), "%d+", "") -- naive count of numbers
×
NEW
946
               plural = count > 1
×
947
            end
948
         end
949
      end
NEW
950
      value = self:_render_term(variable, options.form or "long", plural)
×
NEW
951
      value = self:_render_stripPeriods(value, options)
×
NEW
952
      value = self:_render_textCase(value, options)
×
NEW
953
      value = self:_render_formatting(value, options)
×
NEW
954
      value = self:_render_affixes(value, options)
×
NEW
955
      return value
×
956
   end
NEW
957
   return value
×
958
end
959

NEW
960
function CslEngine:_group (options, content, entry)
×
NEW
961
   self:_enterGroup()
×
962

NEW
963
   local t = self:_render_children(content, entry, { delimiter = options.delimiter })
×
NEW
964
   t = self:_render_formatting(t, options)
×
NEW
965
   t = self:_render_affixes(t, options)
×
NEW
966
   t = self:_render_display(t, options)
×
967

NEW
968
   t = self:_leaveGroup(t) -- Takes care of group suppression
×
NEW
969
   return t
×
970
end
971

NEW
972
function CslEngine:_if (options, content, entry)
×
NEW
973
   local match = options.match or "all"
×
NEW
974
   local conds = {}
×
NEW
975
   if options.variable then
×
NEW
976
      local vars = pl.stringx.split(options.variable, " ")
×
NEW
977
      for _, var in ipairs(vars) do
×
NEW
978
         local cond = entry[var] and true or false
×
NEW
979
         table.insert(conds, cond)
×
980
      end
981
   end
NEW
982
   if options.type then
×
NEW
983
      local types = pl.stringx.split(options.type, " ")
×
NEW
984
      local cond = false
×
985
      -- Different from other conditions:
986
      -- For types, Zeping Lee explained the matching is always "any".
NEW
987
      for _, typ in ipairs(types) do
×
NEW
988
         if entry.type == typ then
×
NEW
989
            cond = true
×
990
            break
991
         end
992
      end
NEW
993
      table.insert(conds, cond)
×
994
   end
NEW
995
   if options["is-numeric"] then
×
NEW
996
      for _, var in ipairs(pl.stringx.split(options["is-numeric"], " ")) do
×
997
         -- TODO FIXME NOT IMPLEMENTED FULLY
998
         -- Content is considered numeric if it solely consists of numbers.
999
         -- Numbers may have prefixes and suffixes (“D2”, “2b”, “L2d”), and may
1000
         -- be separated by a comma, hyphen, or ampersand, with or without
1001
         -- spaces (“2, 3”, “2-4”, “2 & 4”). For example, “2nd” tests “true” whereas
1002
         -- “second” and “2nd edition” test “false”.
NEW
1003
         local cond = tonumber(entry[var]) and true or false
×
NEW
1004
         table.insert(conds, cond)
×
1005
      end
1006
   end
NEW
1007
   if options["is-uncertain-date"] then
×
NEW
1008
      for _, var in ipairs(pl.stringx.split(options["is-uncertain-date"], " ")) do
×
NEW
1009
         local d = type(entry[var]) == "table" and entry[var]
×
NEW
1010
         local cond = d and d.approximate and true or false
×
NEW
1011
         table.insert(conds, cond)
×
1012
      end
1013
   end
NEW
1014
   if options.locator then
×
NEW
1015
      for _, loc in ipairs(pl.stringx.split(options.locator, " ")) do
×
NEW
1016
         local cond = entry.locator and entry.locator.label == loc or false
×
NEW
1017
         table.insert(conds, cond)
×
1018
      end
1019
   end
1020
   -- FIXME TODO other conditions: position, disambiguate
NEW
1021
   for _, v in ipairs({ "position", "disambiguate" }) do
×
NEW
1022
      if options[v] then
×
NEW
1023
         SU.warn("CSL if condition " .. v .. " not implemented yet")
×
NEW
1024
         table.insert(conds, false)
×
1025
      end
1026
   end
1027
   -- Apply match
NEW
1028
   local matching = match ~= "any"
×
NEW
1029
   for _, cond in ipairs(conds) do
×
NEW
1030
      if match == "all" then
×
NEW
1031
         if not cond then
×
NEW
1032
            matching = false
×
1033
            break
1034
         end
NEW
1035
      elseif match == "any" then
×
NEW
1036
         if cond then
×
NEW
1037
            matching = true
×
1038
            break
1039
         end
NEW
1040
      elseif match == "none" then
×
NEW
1041
         if cond then
×
NEW
1042
            matching = false
×
1043
            break
1044
         end
1045
      end
1046
   end
NEW
1047
   if matching then
×
NEW
1048
      return self:_render_children(content, entry), true
×
1049
      -- FIXME:
1050
      -- The CSL specification says: "Delimiters from the nearest delimiters
1051
      -- from the nearest ancestor delimiting element are applied within the
1052
      -- output of cs:choose (i.e., the output of the matching cs:if,
1053
      -- cs:else-if, or cs:else; see delimiter).""
1054
      -- Ugh. This is rather obscure and not implemented yet (?)
1055
   end
NEW
1056
   return nil, false
×
1057
end
1058

NEW
1059
function CslEngine:_choose (options, content, entry)
×
NEW
1060
   for _, child in ipairs(content) do
×
NEW
1061
      if child.command == "cs:if" or child.command == "cs:else-if" then
×
NEW
1062
         local t, match = self:_if(child.options, child, entry)
×
NEW
1063
         if match then
×
NEW
1064
            return t
×
1065
         end
NEW
1066
      elseif child.command == "cs:else" then
×
NEW
1067
         return self:_render_children(child, entry)
×
1068
      end
1069
   end
1070
end
1071

1072
local function dateToYYMMDD (date)
1073
   --- Year from BibLaTeX year field may be a literal
NEW
1074
   local y = type(date.year) == "number" and date.year or tonumber(date.year) or 0
×
NEW
1075
   local m = date.month or 0
×
NEW
1076
   local d = date.day or 0
×
NEW
1077
   return ("%04d%02d%02d"):format(y, m, d)
×
1078
end
1079

NEW
1080
function CslEngine:_key (options, content, entry)
×
1081
   -- Attribute 'sort' is managed at a higher level
1082
   -- NOT IMPLEMENTED:
1083
   -- Attributes 'names-min', 'names-use-first', and 'names-use-last'
1084
   -- (overrides for the 'et-al-xxx' attributes)
NEW
1085
   if options.macro then
×
NEW
1086
      return self:_render_children(self.macros[options.macro], entry)
×
1087
   end
NEW
1088
   if options.variable then
×
NEW
1089
      local value = entry[options.variable]
×
NEW
1090
      if type(value) == "table" then
×
NEW
1091
         if value.range then
×
NEW
1092
            if value.startdate and value.enddate then
×
NEW
1093
               return dateToYYMMDD(value.startdate) .. "-" .. dateToYYMMDD(value.enddate)
×
1094
            end
NEW
1095
            if value.startdate then
×
NEW
1096
               return dateToYYMMDD(value.startdate) .. "-"
×
1097
            end
NEW
1098
            if value.enddate then
×
NEW
1099
               return dateToYYMMDD(value.enddate)
×
1100
            end
NEW
1101
            return dateToYYMMDD(value.from) .. "-" .. dateToYYMMDD(value.to)
×
1102
         end
NEW
1103
         if value.year or value.month or value.day then
×
NEW
1104
            return dateToYYMMDD(value)
×
1105
         end
1106
         -- FIXME names need a special rule here
1107
         -- Chicago style use macro here, so not considered for now.
NEW
1108
         SU.error("CSL variable not yet usable for sorting: " .. options.variable)
×
1109
      end
NEW
1110
      return value
×
1111
   end
NEW
1112
   SU.error("CSL key without variable or macro")
×
1113
end
1114

1115
-- FIXME: A bit ugly: When implementing SU.collatedSort, I didn't consider
1116
-- sorting structured tables, so we need to go low level here.
1117
-- Moreover, I made icu.compare return a boolean, so we have to pay twice
1118
-- the comparison cost to check equality...
1119
-- See PR #2105
NEW
1120
local icu = require("justenoughicu")
×
1121

NEW
1122
function CslEngine:_sort (options, content, entries)
×
NEW
1123
   if not self.sorting then
×
1124
      -- Skipped at rendering
NEW
1125
      return
×
1126
   end
1127
   -- Store the sort order for each key
NEW
1128
   local ordering = {}
×
NEW
1129
   for _, child in ipairs(content) do
×
NEW
1130
      if child.command == "cs:key" then
×
NEW
1131
         table.insert(ordering, child.options.sort ~= "descending") -- true for ascending (default)
×
1132
      end
1133
   end
1134
   -- Compute the sorting keys for each entry
NEW
1135
   for _, entry in ipairs(entries) do
×
NEW
1136
      local keys = {}
×
NEW
1137
      for _, child in ipairs(content) do
×
NEW
1138
         if child.command == "cs:key" then
×
NEW
1139
            self:_prerender()
×
1140
            -- Deep copy the entry as cs:substitute may remove fields
1141
            -- And we may need them back in actual rendering
NEW
1142
            local ent = pl.tablex.deepcopy(entry)
×
NEW
1143
            local key = self:_key(child.options, child, ent)
×
1144
            -- No _postrender here, as we don't want to apply punctuation (?)
NEW
1145
            table.insert(keys, key or "")
×
1146
         end
1147
      end
NEW
1148
      entry._keys = keys
×
1149
   end
1150
   -- Perform the sort
1151
   -- Using the locale language (BCP47).
NEW
1152
   local lang = self.locale.lang
×
NEW
1153
   local collator = icu.collation_create(lang, {})
×
NEW
1154
   table.sort(entries, function (a, b)
×
NEW
1155
      if (a["citation-key"] == b["citation-key"]) then
×
1156
         -- Lua can invoke the comparison function with the same entry.
1157
         -- Really! Due to the way it handles it pivot on partitioning.
1158
         -- Shortcut the inner keys comparison in that case.
NEW
1159
         return false
×
1160
      end
NEW
1161
      local ak = a._keys
×
NEW
1162
      local bk = b._keys
×
NEW
1163
      for i = 1, #ordering do
×
1164
         -- "Items with an empty sort key value are placed at the end of the sort,
1165
         -- both for ascending and descending sorts."
NEW
1166
         if ak[i] == "" then return bk[i] == "" end
×
NEW
1167
         if bk[i] == "" then return true
×
1168
         end
1169

NEW
1170
         if ak[i] ~= bk[i] then -- HACK: See comment above, ugly inequality check
×
NEW
1171
            local cmp = icu.compare(collator, ak[i], bk[i])
×
1172
            -- Hack to keep on working whenever PR #2105 lands and changes icu.compare
1173
            local islower
NEW
1174
            if type(cmp) == "number" then islower = cmp < 0
×
NEW
1175
            else islower = cmp end
×
1176
            -- Now order accordingly
NEW
1177
            if ordering[i] then return islower
×
NEW
1178
            else return not islower end
×
1179
         end
1180
      end
1181
      -- If we reach this point, the keys are equal (or we had no keys)
1182
      -- Probably unlikely in real life, and not mentioned in the CSL spec
1183
      -- unless I missed it. Let's fallback to the citation order, so at
1184
      -- least cited entries are ordered predictably.
NEW
1185
      SU.warn("CSL sort keys are equal for " .. a["citation-key"] .. " and " .. b["citation-key"])
×
NEW
1186
      return a["citation-number"] < b["citation-number"]
×
1187
   end)
NEW
1188
   icu.collation_destroy(collator)
×
1189
end
1190

1191
-- PROCESSING
1192

NEW
1193
function CslEngine:_render_node (node, entry)
×
NEW
1194
   local callback = node.command:gsub("cs:", "_")
×
NEW
1195
   if self[callback] then
×
NEW
1196
      return self[callback](self, node.options, node, entry)
×
1197
   else
NEW
1198
      SU.warn("Unknown CSL element " .. node.command .. " (" .. callback .. ")")
×
1199
   end
1200
end
1201

NEW
1202
function CslEngine:_render_children (ast, entry, context)
×
NEW
1203
   if not ast then
×
NEW
1204
      return
×
1205
   end
NEW
1206
   local ret = {}
×
NEW
1207
   context = context or {}
×
NEW
1208
   for _, content in ipairs(ast) do
×
NEW
1209
      if type(content) == "table" and content.command then
×
NEW
1210
         local r = self:_render_node(content, entry)
×
NEW
1211
         if r then
×
NEW
1212
            table.insert(ret, r)
×
1213
         end
1214
      else
NEW
1215
         SU.error("CSL unexpected content") -- Should not happen
×
1216
      end
1217
   end
NEW
1218
   return #ret > 0 and self:_render_delimiter(ret, context.delimiter) or nil
×
1219
end
1220

NEW
1221
function CslEngine:_postrender (text)
×
NEW
1222
   local rdquote = self.punctuation.close_quote
×
NEW
1223
   local ldquote = self.punctuation.open_quote
×
NEW
1224
   local rsquote = self.punctuation.close_inner_quote
×
NEW
1225
   local piquote = SU.boolean(self.locale.styleOptions["punctuation-in-quote"], false)
×
1226

1227
   -- Typography: Ensure there are no double straight quotes left from the input.
NEW
1228
   text = luautf8.gsub(text, '^"', ldquote)
×
NEW
1229
   text = luautf8.gsub(text, '"$', rdquote)
×
NEW
1230
   text = luautf8.gsub(text, '([%s%p])"', "%1" .. ldquote)
×
NEW
1231
   text = luautf8.gsub(text, '"([%s%p])', rdquote .. "%1")
×
1232
   -- HACK: punctuation-in-quote is applied globally, not just to generated quotes.
1233
   -- Not so sure it's the intended behavior from the specification?
NEW
1234
   if piquote then
×
1235
      -- move commas and periods before closing quotes
NEW
1236
      text = luautf8.gsub(text, "([" .. rdquote .. rsquote .. "]+)%s*([.,])", "%2%1")
×
1237
   end
1238
   -- HACK: fix some double punctuation issues.
1239
   -- Maybe some more robust way to handle affixes and delimiters would be better?
NEW
1240
   text = luautf8.gsub(text, "%.%.", ".")
×
1241
   -- Typography: Prefer to have commas and periods inside italics.
1242
   -- (Better looking if italic automated corrections are applied.)
NEW
1243
   text = luautf8.gsub(text, "(</em>)([%.,])", "%2%1")
×
1244
   -- HACK: remove extraneous periods after exclamation and question marks.
1245
   -- (Follows the preceding rule to also account for moved periods.)
NEW
1246
   text = luautf8.gsub(text, "([…!?])%.", "%1")
×
NEW
1247
   if not piquote then
×
1248
      -- HACK: remove extraneous periods after quotes.
1249
      -- Opinionated, e.g. for French at least, some typographers wouldn't
1250
      -- frown upon a period after a quote ending with an exclamation mark
1251
      -- or a question mark. But it's ugly.
NEW
1252
      text = luautf8.gsub(text, "([…!?%.]" .. rdquote .. ")%.", "%1")
×
1253
   end
NEW
1254
   return text
×
1255
end
1256

NEW
1257
function CslEngine:_process (entries, mode)
×
NEW
1258
   if mode ~= "citation" and mode ~= "bibliography" then
×
NEW
1259
      SU.error("CSL processing mode must be 'citation' or 'bibliography'")
×
1260
   end
NEW
1261
   self.mode = mode
×
1262
   -- Deep copy the entries as cs:substitute may remove fields
NEW
1263
   entries = pl.tablex.deepcopy(entries)
×
1264

NEW
1265
   local ast = self[mode]
×
NEW
1266
   if not ast then
×
NEW
1267
      SU.error("CSL style has no " .. mode .. " definition")
×
1268
   end
NEW
1269
   local sort = SU.ast.findInTree(ast, "cs:sort")
×
NEW
1270
   if sort then
×
NEW
1271
      self.sorting = true
×
NEW
1272
      self:_sort(sort.options, sort, entries)
×
NEW
1273
      self.sorting = false
×
1274
   else
1275
      -- The CSL specification says:
1276
      -- "In the absence of cs:sort, cites and bibliographic entries appear in
1277
      -- the order in which they are cited."
1278
      -- We tracked the first citation number in 'citation-number', so for
1279
      -- the bibliography, using it makes sense.
1280
      -- For citations, we use the exact order of the input. Consider a cite
1281
      -- (work1, work2) and a subsequent cite (work2, work1). The order of
1282
      -- the bibliography should be (work1, work2), but the order of the cites
1283
      -- should be (work1, work2) and (work2, work1) respectively.
1284
      -- It seeems to be the case: Some styles (ex. American Chemical Society)
1285
      -- have an explicit sort by 'citation-number' in the citations section,
1286
      -- which would be useless if that order was impplied.
NEW
1287
      if mode == "bibliography" then
×
NEW
1288
         table.sort(entries, function (e1, e2)
×
NEW
1289
            if not e1["citation-number"] or not e2["citation-number"] then
×
NEW
1290
               return false; -- Safeguard?
×
1291
            end
NEW
1292
            return e1["citation-number"] < e2["citation-number"]
×
1293
         end)
1294
      end
1295
   end
1296

NEW
1297
   return self:_render_children(ast, entries)
×
1298
end
1299

1300
--- Generate a citation string.
1301
-- @tparam table entry List of CSL entries
1302
-- @treturn string The XML citation string
NEW
1303
function CslEngine:cite (entries)
×
NEW
1304
   entries = type(entries) == "table" and not entries.type and entries or { entries }
×
NEW
1305
   return self:_process(entries, "citation")
×
1306
end
1307

1308
--- Generate a reference string.
1309
-- @tparam table entry List of CSL entries
1310
-- @treturn string The XML reference string
NEW
1311
function CslEngine:reference (entries)
×
NEW
1312
   entries = type(entries) == "table" and not entries.type and entries or { entries }
×
NEW
1313
   return self:_process(entries, "bibliography")
×
1314
end
1315

NEW
1316
return {
×
1317
   CslEngine = CslEngine,
1318
}
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