summaryrefslogtreecommitdiff
path: root/biblio/citation-style-language/citeproc-output.lua
diff options
context:
space:
mode:
Diffstat (limited to 'biblio/citation-style-language/citeproc-output.lua')
-rw-r--r--biblio/citation-style-language/citeproc-output.lua1534
1 files changed, 1534 insertions, 0 deletions
diff --git a/biblio/citation-style-language/citeproc-output.lua b/biblio/citation-style-language/citeproc-output.lua
new file mode 100644
index 0000000000..6aca4533da
--- /dev/null
+++ b/biblio/citation-style-language/citeproc-output.lua
@@ -0,0 +1,1534 @@
+--
+-- Copyright (c) 2021-2022 Zeping Lee
+-- Released under the MIT license.
+-- Repository: https://github.com/zepinglee/citeproc-lua
+--
+
+local output_module = {}
+
+local unicode = require("unicode")
+local dom = require("luaxml-domobject")
+
+local util = require("citeproc-util")
+
+
+local LocalizedQuotes = {
+ outer_open = util.unicode['left double quotation mark'],
+ outer_close = util.unicode['right double quotation mark'],
+ inner_open = util.unicode['left single quotation mark'],
+ inner_close = util.unicode['right single quotation mark'],
+ punctuation_in_quote = false,
+}
+
+function LocalizedQuotes:new(outer_open, outer_close, inner_open, inner_close, punctuation_in_quote)
+ local o = {
+ outer_open = outer_open or util.unicode['left double quotation mark'],
+ outer_close = outer_close or util.unicode['right double quotation mark'],
+ inner_open = inner_open or util.unicode['left single quotation mark'],
+ inner_close = inner_close or util.unicode['right single quotation mark'],
+ punctuation_in_quote = punctuation_in_quote,
+ }
+ setmetatable(o, self)
+ self.__index = self
+ return o
+end
+
+
+local InlineElement = {
+ _type = "InlineElement",
+ _base_class = "InlineElement",
+}
+
+function InlineElement:derive(type)
+ local o = {
+ _type = type,
+ }
+ self[type] = o
+ setmetatable(o, self)
+ self.__index = self
+ o.__index = o
+ return o
+end
+
+function InlineElement:new(inlines)
+ local o = {
+ inlines = inlines,
+ _type = self._type,
+ }
+ setmetatable(o, self)
+ return o
+end
+
+
+local PlainText = InlineElement:derive("PlainText")
+
+function PlainText:new(value)
+ local o = InlineElement.new(self)
+ o.value = value
+ setmetatable(o, self)
+ return o
+end
+
+
+local Formatted = InlineElement:derive("Formatted")
+
+function Formatted:new(inlines, formatting)
+ local o = InlineElement.new(self)
+ o.inlines = inlines
+ o.formatting = formatting
+ setmetatable(o, self)
+ return o
+end
+
+
+local Micro = InlineElement:derive("Micro")
+
+-- This is how we can flip-flop only user-supplied styling.
+-- Inside this is parsed micro html
+function Micro:new(inlines)
+ local o = InlineElement.new(self)
+ o.inlines = inlines
+ setmetatable(o, self)
+ return o
+end
+
+
+local Quoted = InlineElement:derive("Quoted")
+
+function Quoted:new(inlines, localized_quotes)
+ local o = InlineElement.new(self)
+ o.inlines = inlines
+ o.is_inner = false
+ if localized_quotes then
+ o.quotes = localized_quotes
+ else
+ o.quotes = LocalizedQuotes:new()
+ end
+
+ setmetatable(o, self)
+ return o
+end
+
+
+local NoCase = InlineElement:derive("NoCase")
+
+function NoCase:new(inlines)
+ local o = InlineElement.new(self)
+ o.inlines = inlines
+ setmetatable(o, self)
+ return o
+end
+
+
+local NoDecor = InlineElement:derive("NoDecor")
+
+function NoDecor:new(inlines)
+ local o = InlineElement.new(self)
+ o.inlines = inlines
+ setmetatable(o, self)
+ return o
+end
+
+
+local Linked = InlineElement:derive("Linked")
+
+function Linked:new(value, href)
+ local o = InlineElement.new(self)
+ o.value = value
+ o.href = href
+ setmetatable(o, self)
+ return o
+end
+
+
+local Div = InlineElement:derive("Div")
+
+function Div:new(inlines, display)
+ local o = InlineElement.new(self)
+ o.inlines = inlines
+ o.div = display
+ setmetatable(o, self)
+ return o
+end
+
+
+function InlineElement:parse(str, context)
+ -- Return a list of inlines
+ if type(str) ~= "string" then
+ print(debug.traceback())
+ end
+ local html_str = "<div>" .. str .. "</div>"
+ local ok, html = pcall(dom.parse, html_str)
+ local inlines
+ if ok then
+ local div = html:get_path("div")[1]
+ local el = InlineElement:from_node(div)
+ inlines = el.inlines
+ else
+ local el = PlainText:new(str)
+ inlines = {el}
+ end
+
+ inlines = InlineElement:parse_quotes(inlines, context)
+ return inlines
+end
+
+function InlineElement:from_node(node)
+ local tag_name = node:get_element_name()
+ local inlines = {}
+
+ for _, child in ipairs(node:get_children()) do
+ local inline
+ if child:is_text() then
+ inline = PlainText:new(child:get_text())
+ elseif child:is_element() then
+ inline = InlineElement:from_node(child)
+ end
+ table.insert(inlines, inline)
+ end
+
+ if tag_name == "i" then
+ return Formatted:new(inlines, {["font-style"] = "italic"})
+ elseif tag_name == "b" then
+ return Formatted:new(inlines, {["font-weight"] = "bold"})
+ elseif tag_name == "sup" then
+ return Formatted:new(inlines, {["vertical-align"] = "sup"})
+ elseif tag_name == "sub" then
+ return Formatted:new(inlines, {["vertical-align"] = "sub"})
+ elseif tag_name == "sc" then
+ return Formatted:new(inlines, {["font-variant"] = "small-caps"})
+ elseif tag_name == "span" then
+ local style = node:get_attribute("style")
+ local class = node:get_attribute("class")
+ if style == "font-variant:small-caps;" or style == "font-variant: small-caps;" then
+ return Formatted:new(inlines, {["font-variant"] = "small-caps"})
+ elseif class == "nocase" then
+ return NoCase:new(inlines)
+ elseif class == "nodecor" then
+ return NoDecor:new(inlines)
+ end
+ end
+
+ return InlineElement:new(inlines)
+end
+
+function InlineElement:parse_quotes(inlines, context)
+ local quote_fragments = InlineElement:get_quote_fragments(inlines)
+ -- util.debug(quote_fragments)
+
+ local quote_stack = {}
+ local text_stack = {{}}
+
+ local localized_quotes = context:get_localized_quotes()
+
+ for _, fragment in ipairs(quote_fragments) do
+ local top_text_list = text_stack[#text_stack]
+
+ if type(fragment) == "table" then
+ if fragment.inlines then
+ fragment.inlines = self:parse_quotes(fragment.inlines, context)
+ end
+ table.insert(top_text_list, fragment)
+
+ elseif type(fragment) == "string" then
+ local quote = fragment
+ local top_quote = quote_stack[#quote_stack]
+
+ if quote == "'" then
+ if top_quote == "'" and #top_text_list > 0 then
+ table.remove(quote_stack)
+ local quoted = Quoted:new(top_text_list, localized_quotes)
+ table.remove(text_stack)
+ table.insert(text_stack[#text_stack], quoted)
+ else
+ table.insert(quote_stack, quote)
+ table.insert(text_stack, {})
+ end
+
+ elseif quote == '"' then
+ if top_quote == '"' then
+ table.remove(quote_stack)
+ local quoted = Quoted:new(top_text_list, localized_quotes)
+ table.remove(text_stack)
+ table.insert(text_stack[#text_stack], quoted)
+ else
+ table.insert(quote_stack, quote)
+ table.insert(text_stack, {})
+ end
+
+ elseif quote == util.unicode["left single quotation mark"] or
+ quote == util.unicode["left double quotation mark"] or
+ quote == util.unicode["left-pointing double angle quotation mark"] then
+ table.insert(quote_stack, quote)
+ table.insert(text_stack, {})
+
+ elseif (quote == util.unicode["right single quotation mark"] and
+ top_quote == util.unicode["left single quotation mark"]) or
+ (quote == util.unicode["right double quotation mark"] and
+ top_quote == util.unicode["left double quotation mark"]) or
+ (quote == util.unicode["right-pointing double angle quotation mark"] and
+ top_quote == util.unicode["left-pointing double angle quotation mark"]) then
+ table.remove(quote_stack)
+ local quoted = Quoted:new(top_text_list, localized_quotes)
+ table.remove(text_stack)
+ table.insert(text_stack[#text_stack], quoted)
+
+ else
+ local last_inline = top_text_list[#top_text_list]
+ if last_inline and last_inline._type == "PlainText" then
+ last_inline.value = last_inline.value .. fragment
+ else
+ table.insert(top_text_list, PlainText:new(fragment))
+ end
+ end
+
+ end
+ end
+
+ local elements = text_stack[1]
+ if #text_stack > 1 then
+ assert(#text_stack == #quote_stack + 1)
+ for i, quote in ipairs(quote_stack) do
+ if quote == "'" then
+ quote = util.unicode["apostrophe"]
+ end
+ local last_inline = elements[#elements]
+ if last_inline and last_inline._type == "PlainText" then
+ last_inline.value = last_inline.value .. quote
+ else
+ table.insert(elements, PlainText:new(quote))
+ end
+
+ for _, inline in ipairs(text_stack[i + 1]) do
+ if inline._type == "PlainText" then
+ local last_inline = elements[#elements]
+ if last_inline and last_inline._type == "PlainText" then
+ last_inline.value = last_inline.value .. inline.value
+ else
+ table.insert(elements, inline)
+ end
+ else
+ table.insert(elements, inline)
+ end
+ end
+ end
+ end
+
+ return elements
+end
+
+local function merge_fragments_at(fragments, i)
+ if type(fragments[i+1]) == "string" then
+ fragments[i] = fragments[i] .. fragments[i+1]
+ table.remove(fragments, i+1)
+ end
+ if type(fragments[i-1]) == "string" then
+ fragments[i-1] = fragments[i-1] .. fragments[i]
+ table.remove(fragments, i)
+ end
+end
+
+-- Return a list of strings and InlineElement
+function InlineElement:get_quote_fragments(inlines)
+ local fragments = {}
+ for _, inline in ipairs(inlines) do
+ if inline._type == "PlainText" then
+ local quote_tuples = {}
+ for _, quote in ipairs({
+ "'",
+ '"',
+ util.unicode["left single quotation mark"],
+ util.unicode["right single quotation mark"],
+ util.unicode["left double quotation mark"],
+ util.unicode["right double quotation mark"],
+ util.unicode["left-pointing double angle quotation mark"],
+ util.unicode["right-pointing double angle quotation mark"],
+ }) do
+ string.gsub(inline.value, "()(" .. quote .. ")()", function (idx, qt, next_idx)
+ table.insert(quote_tuples, {idx, qt, next_idx})
+ end)
+ end
+ table.sort(quote_tuples, function (a, b)
+ return a[1] < b[1]
+ end)
+ local start_idx = 1
+ for _, quote_tuple in ipairs(quote_tuples) do
+ local idx, qt, next_idx = table.unpack(quote_tuple)
+ local fragment = string.sub(inline.value, start_idx, idx-1)
+ if fragment ~= "" then
+ table.insert(fragments, fragment)
+ end
+ table.insert(fragments, qt)
+ start_idx = next_idx
+ end
+ -- Insert last fragment
+ local fragment = string.sub(inline.value, start_idx, #inline.value)
+ if fragment ~= "" then
+ table.insert(fragments, fragment)
+ end
+ else
+ table.insert(fragments, inline)
+ end
+
+ for i = #fragments, 1, -1 do
+ local fragment = fragments[i]
+ if fragment == "'" or fragment == '"' then
+ local left, right
+ if i > 1 then
+ left = fragments[i - 1]
+ if type(left) == "table" then
+ left = left:get_right_most_string()
+ end
+ end
+ if i < #fragments then
+ right = fragments[i + 1]
+ if type(right) == "table" then
+ right = right:get_left_most_string()
+ end
+ end
+ -- TODO: consider utf-8
+ if left and right then
+ if string.match(left, "%s$") and string.match(right, "^%s") then
+ -- Orphan quote: bugreports_SingleQuote.txt
+ if fragment == "'" then
+ fragments[i] = util.unicode['apostrophe']
+ end
+ merge_fragments_at(fragments, i)
+ end
+ if not string.match(left, "[%s%p]$") and not string.match(right, "^[%s%p]") then
+ if fragment == "'" then
+ fragments[i] = util.unicode['apostrophe']
+ end
+ merge_fragments_at(fragments, i)
+ end
+ end
+ end
+ end
+ end
+ return fragments
+end
+
+function InlineElement:get_left_most_string()
+ if self.value then
+ return self.value
+ elseif self.inlines then
+ return self.inlines[1]:get_left_most_string()
+ end
+end
+
+function InlineElement:get_right_most_string()
+ if self.value then
+ return self.value
+ elseif self.inlines then
+ return self.inlines[#self.inlines]:get_left_most_string()
+ end
+end
+
+function InlineElement:capitalize_first_term()
+ -- util.debug(self)
+ if self._type == "PlainText" then
+ self.value = util.capitalize(self.value)
+ elseif self.inlines[1] then
+ self.inlines[1]:capitalize_first_term()
+ end
+end
+
+
+local OutputFormat = {}
+
+function OutputFormat:new(format_name)
+ local o = {
+ name = format_name,
+ }
+ setmetatable(o, self)
+ self.__index = self
+ return o
+end
+
+function OutputFormat:flatten_ir(ir)
+ if self.group_var == "missing" or self.collapse_suppressed then
+ return {}
+ end
+ local inlines = {}
+ if ir._type == "SeqIr" or ir._type == "NameIr" then
+ inlines = self:flatten_seq_ir(ir)
+ else
+ inlines = self:with_format(ir.inlines, ir.formatting)
+ inlines = self:affixed_quoted(inlines, ir.affixes, ir.quotes);
+ inlines = self:with_display(inlines, ir.display);
+ end
+ return inlines
+end
+
+function OutputFormat:flatten_seq_ir(ir)
+ if not ir.children then
+ print(debug.traceback())
+ end
+ if #ir.children == 0 then
+ return {}
+ end
+ local inlines_list = {}
+ for _, child in ipairs(ir.children) do
+ if child.group_var ~= "missing" and not child.collapse_suppressed then
+ local child_inlines = self:flatten_ir(child)
+ if #child_inlines > 0 then
+ table.insert(inlines_list, child_inlines)
+ end
+ end
+ end
+
+ if #inlines_list == 0 then
+ return {}
+ end
+
+ local inlines = self:group(inlines_list, ir.delimiter, ir.formatting)
+ -- assert ir.quotes == localized quotes
+ inlines = self:affixed_quoted(inlines, ir.affixes, ir.quotes);
+ inlines = self:with_display(inlines, ir.display);
+ return inlines
+end
+
+function OutputFormat:group(inlines_list, delimiter, formatting)
+ -- Each node is list of InlineElements
+ if #inlines_list == 1 then
+ return self:format_list(inlines_list[1], formatting)
+ else
+ local inlines = OutputFormat:join_many(inlines_list, delimiter)
+ return self:format_list(inlines, formatting)
+ end
+end
+
+function OutputFormat:format_list(inlines, formatting)
+ if formatting then
+ return {Formatted:new(inlines, formatting)}
+ else
+ return inlines
+ end
+end
+
+function OutputFormat:join_many(lists, delimiter)
+ local res = {}
+ for i, list in ipairs(lists) do
+ if delimiter and i > 1 then
+ table.insert(res, PlainText:new(delimiter))
+ end
+ for _, el in ipairs(list) do
+ table.insert(res, el)
+ end
+ end
+ return res
+end
+
+function OutputFormat:with_format(inlines, formatting)
+ return self:format_list(inlines, formatting)
+end
+
+function OutputFormat:apply_text_case(inlines, text_case, is_english)
+ if not inlines or #inlines == 0 or not text_case then
+ return
+ end
+ -- Title case conversion only affects English-language items.
+ if text_case == "title" and not is_english then
+ return
+ end
+ local is_uppercase = false -- TODO
+ self:apply_text_case_inner(inlines, text_case, false, is_uppercase)
+end
+
+local function string_contains_word(str)
+ return string.match(str, "%w") ~= nil
+end
+
+local function inline_contains_word(inline)
+ if inline._type == "PlainText" then
+ return string_contains_word(inline.value)
+ elseif inline.inlines then
+ for _, el in ipairs(inline.inlines) do
+ if inline_contains_word(el) then
+ return true
+ end
+ end
+ end
+ return false
+end
+
+function OutputFormat:apply_text_case_inner(inlines, text_case, seen_one, is_uppercase)
+ for i, inline in ipairs(inlines) do
+ if seen_one and text_case == "capitalize-first" then
+ break
+ end
+ local is_last = (i == #inlines)
+ if inline._type == "PlainText" then
+ inline.value = self:transform_case(inline.value, text_case, seen_one, is_last, is_uppercase);
+ seen_one = seen_one or string_contains_word(inline.value)
+ elseif inline._type == "NoCase" or
+ inline._type == "NoDecor" or
+ (inline._type == "Formatted" and inline.formatting["font-variant"] == "small-caps") or
+ (inline._type == "Formatted" and inline.formatting["vertical-align"] == "sup") or
+ (inline._type == "Formatted" and inline.formatting["vertical-align"] == "sub") then
+ seen_one = seen_one or inline_contains_word(inline)
+
+ elseif inline._type == "Formatted" or inline._type == "Quoted" then
+ seen_one = self:apply_text_case_inner(inline.inlines, text_case, seen_one, is_uppercase) or seen_one
+ end
+ end
+ return seen_one
+end
+
+local function transform_lowercase(str)
+ return string.gsub(str, utf8.charpattern, unicode.utf8.lower)
+end
+
+local function transform_uppercase(str)
+ -- TODO: locale specific uppercase: textcase_LocaleUnicode.txt
+ return string.gsub(str, utf8.charpattern, unicode.utf8.upper)
+end
+
+local function transform_first_word(str, transform)
+ -- TODO: [Unicode word boundaries](https://www.unicode.org/reports/tr29/#Word_Boundaries)
+ local segments = util.segment_words(str)
+ for _, segment in ipairs(segments) do
+ if segment[1] ~= "" then
+ segment[1] = transform(segment[1])
+ break
+ end
+ end
+ local res = ""
+ for _, segment in ipairs(segments) do
+ res = res .. segment[1] .. segment[2]
+ end
+ return res
+end
+
+local function transform_each_word(str, seen_one, is_last, transform)
+ -- util.debug(str)
+ local segments = util.segment_words(str)
+ -- util.debug(segments)
+ -- print(debug.traceback())
+
+ local first_idx
+ local last_idx
+ for i, segment in ipairs(segments) do
+ if segment[1] ~= "" then
+ if not first_idx then
+ first_idx = i
+ end
+ last_idx = i
+ end
+ end
+
+ local immediate_before = ""
+ local last_punct = ""
+ for i, segment in ipairs(segments) do
+ local is_first_word = not seen_one and i == first_idx
+ local is_last_word = is_last and i == last_idx
+ local follows_colon = (
+ last_punct == ":" or
+ last_punct == "!" or
+ last_punct == "?" or
+ last_punct == "?")
+ local no_stop_word = is_first_word or is_last_word or follows_colon or (segment[2] == "-" and immediate_before ~= "-")
+
+ if (immediate_before == "." or immediate_before == "-") and #segment[1] == 1 then
+ else
+ segment[1] = transform(segment[1], no_stop_word)
+ end
+
+ if segment[1] ~= "" then
+ immediate_before = segment[1]
+ last_punct = string.match(segment[1], "(%S)%s*$") or last_punct
+ end
+ if segment[2] ~= "" then
+ immediate_before = segment[2]
+ last_punct = string.match(segment[2], "(%S)%s*$") or last_punct
+ end
+ end
+ local res = ""
+ for _, segment in ipairs(segments) do
+ res = res .. segment[1] .. segment[2]
+ end
+ return res
+end
+
+local function transform_capitalize_word_if_lower(word)
+ if util.is_lower(word) then
+ return string.gsub(word, utf8.charpattern, unicode.utf8.upper, 1)
+ else
+ return word
+ end
+end
+
+local function title_case_word(word, no_stop_word)
+ -- Entirely non-English
+ -- e.g. "β" in "β-Carotine"
+ if string.match(word, "%a") and (not util.stop_words[word] or no_stop_word) and util.is_lower(word) then
+ return string.gsub(word, utf8.charpattern, unicode.utf8.upper, 1)
+ else
+ return word
+ end
+end
+
+function OutputFormat:transform_case(str, text_case, seen_one, is_last, is_uppercase)
+ local res = str
+ if text_case == "lowercase" then
+ res = transform_lowercase(str)
+ elseif text_case == "uppercase" then
+ res = transform_uppercase(str)
+ elseif text_case == "capitalize-first" then
+ res = transform_first_word(str, transform_capitalize_word_if_lower)
+ elseif text_case == "capitalize-all" then
+ res = transform_each_word(str, false, false, transform_capitalize_word_if_lower)
+ elseif text_case == "sentence" then
+ -- TODO: if uppercase convert all to lowercase
+ res = transform_first_word(str, transform_capitalize_word_if_lower)
+ elseif text_case == "title" then
+ -- TODO: if uppercase convert all to lowercase
+ res = transform_each_word(str, seen_one, is_last, title_case_word)
+ end
+ return res
+end
+
+function OutputFormat:affixed(inlines, affixes)
+ if affixes and affixes.prefix then
+ table.insert(inlines, 1, PlainText:new(affixes.prefix))
+ end
+ if affixes and affixes.suffix then
+ table.insert(inlines, PlainText:new(affixes.suffix))
+ end
+ return inlines
+end
+
+function OutputFormat:affixed_quoted(inlines, affixes, localized_quotes)
+ inlines = util.clone(inlines)
+ if localized_quotes then
+ inlines = self:quoted(inlines, localized_quotes)
+ end
+ if affixes and affixes.prefix then
+ table.insert(inlines, 1, PlainText:new(affixes.prefix))
+ end
+ if affixes and affixes.suffix then
+ table.insert(inlines, PlainText:new(affixes.suffix))
+ end
+ return inlines
+end
+
+function OutputFormat:quoted(inlines, localized_quotes)
+ return {Quoted:new(inlines, localized_quotes)}
+end
+
+function OutputFormat:with_display(nodes, display)
+ if display then
+ return {Div:new(nodes, display)}
+ else
+ return nodes
+ end
+end
+
+function OutputFormat:output(inlines, context)
+ self:flip_flop_inlines(inlines)
+
+ self:move_punctuation(inlines)
+
+ -- util.debug(inlines)
+
+ return self:write_inlines(inlines, context)
+end
+
+function OutputFormat:output_bibliography_entry(inlines, context)
+ self:flip_flop_inlines(inlines)
+ -- util.debug(inlines)
+ self:move_punctuation(inlines)
+ -- TODO:
+ -- if self.format == "html" then
+ -- elseif self.format == "latex" then
+ -- end
+ local res = self:write_inlines(inlines, context)
+ local markup = self.markups["@bibliography/entry"]
+ if type(markup) == "string" then
+ res = string.format(markup, res)
+ elseif type(markup) == "function" then
+ res = markup(res, context)
+ end
+ return res
+end
+
+function OutputFormat:flip_flop_inlines(inlines)
+ local flip_flop_state = {
+ ["font-style"] = "normal",
+ ["font-variant"] = "normal",
+ ["font-weight"] = "normal",
+ ["text-decoration"] = "none",
+ ["vertical-alignment"] = "baseline",
+ in_inner_quotes = false,
+ }
+ self:flip_flop(inlines, flip_flop_state)
+end
+
+function OutputFormat:flip_flop(inlines, state)
+ for i, inline in ipairs(inlines) do
+ if inline._type == "Micro" then
+ self:flip_flop_micro_inlines(inline.inlines, state)
+
+ elseif inline._type == "Formatted" then
+ local new_state = util.clone(state)
+ local formatting = inline.formatting
+
+ for _, attribute in ipairs({"font-style", "font-variant", "font-weight"}) do
+ local value = formatting[attribute]
+ if value then
+ if value == state[attribute] then
+ if value == "normal" then
+ formatting[attribute] = nil
+ -- Formatting outside Micro is not reset to "normal".
+ -- else
+ -- formatting[attribute] = "normal"
+ end
+ end
+ new_state[attribute] = value
+ end
+ end
+ self:flip_flop(inline.inlines, new_state)
+
+ elseif inline._type == "Quoted" then
+ inline.is_inner = state.in_inner_quotes
+ local new_state = util.clone(state)
+ new_state.in_inner_quotes = not new_state.in_inner_quotes
+ self:flip_flop(inline.inlines, new_state)
+
+ elseif inline._type == "NoDecor" then
+ local new_state = {
+ ["font-style"] = "normal",
+ ["font-variant"] = "normal",
+ ["font-weight"] = "normal",
+ ["text-decoration"] = "none",
+ }
+ self:flip_flop(inline.inlines, new_state)
+ for attr, value in pairs(new_state) do
+ if value and state[attr] ~= value then
+ if not inline.formatting then
+ inline._type = "Formatted"
+ inline.formatting = {}
+ end
+ inline.formatting[attr] = value
+ end
+ end
+
+ elseif inline.inlines then -- Div, ...
+ self:flip_flop(inline.inlines, state)
+ end
+ end
+end
+
+function OutputFormat:flip_flop_micro_inlines(inlines, state)
+ for i, inline in ipairs(inlines) do
+ if inline._type == "Micro" then
+ self:flip_flop_micro_inlines(inline.inlines, state)
+
+ elseif inline._type == "Formatted" then
+ local new_state = util.clone(state)
+ local formatting = inline.formatting
+
+ for _, attribute in ipairs({"font-style", "font-variant", "font-weight"}) do
+ local value = formatting[attribute]
+ if value then
+ if value == state[attribute] then
+ if value == "normal" then
+ formatting[attribute] = nil
+ else
+ -- Formatting inside Micro is reset to "normal".
+ formatting[attribute] = "normal"
+ value = "normal"
+ end
+ end
+ new_state[attribute] = value
+ end
+ end
+ self:flip_flop_micro_inlines(inline.inlines, new_state)
+
+ elseif inline._type == "Quoted" then
+ inline.is_inner = state.in_inner_quotes
+ local new_state = util.clone(state)
+ new_state.in_inner_quotes = not new_state.in_inner_quotes
+ self:flip_flop_micro_inlines(inline.inlines, new_state)
+
+ elseif inline._type == "NoDecor" then
+ local new_state = {
+ ["font-style"] = "normal",
+ ["font-variant"] = "normal",
+ ["font-weight"] = "normal",
+ ["text-decoration"] = "none",
+ }
+ self:flip_flop_micro_inlines(inline.inlines, new_state)
+ for attr, value in pairs(new_state) do
+ if value and state[attr] ~= value then
+ if not inline.formatting then
+ inline._type = "Formatted"
+ inline.formatting = {}
+ end
+ inline.formatting[attr] = value
+ end
+ end
+
+ elseif inline.inlines then -- Div, ...
+ self:flip_flop_micro_inlines(inline.inlines, state)
+ end
+ end
+end
+
+local function find_left(inline)
+ if not inline then
+ print(debug.traceback())
+ end
+ if inline._type == "PlainText" then
+ return inline
+ -- elseif inline._type == "Micro" then
+ -- return nil
+ elseif inline.inlines and inline._type~="Quoted" then
+ return find_left(inline.inlines[1])
+ else
+ return nil
+ end
+end
+
+local function find_right(inline)
+ if inline._type == "PlainText" then
+ return inline
+ -- elseif inline._type == "Micro" then
+ -- return nil
+ elseif inline.inlines and #inline.inlines > 0 and inline._type ~= "Quoted" then
+ return find_right(inline.inlines[#inline.inlines])
+ else
+ return nil
+ end
+end
+
+local function find_right_in_quoted(inline)
+ if inline._type == "PlainText" then
+ return inline
+ -- elseif inline._type == "Micro" then
+ -- return nil
+ elseif inline.inlines and #inline.inlines > 0 then
+ return find_right_in_quoted(inline.inlines[#inline.inlines])
+ else
+ return nil
+ end
+end
+
+-- "'Foo,' bar" => ,
+local function find_right_quoted(inline)
+ if inline._type == "Quoted" and #inline.inlines > 0 then
+ return find_right_in_quoted(inline.inlines[#inline.inlines]), inline.quotes.punctuation_in_quote
+ -- elseif inline._type == "Micro" then
+ -- return nil
+ elseif inline.inlines and #inline.inlines > 0 then
+ return find_right_quoted(inline.inlines[#inline.inlines])
+ else
+ return nil, false
+ end
+end
+
+local function smash_string_push(first, second)
+ local first_char = string.sub(first.value, -1)
+ local second_char = string.sub(second.value, 1, 1)
+ -- util.debug(first_char)
+ -- util.debug(second_char)
+
+ local punct_map = output_module.quote_punctuation_map
+ if second_char == " " and (first_char == " " or
+ util.endswith(first.value, util.unicode["no-break space"])) then
+ second.value = string.sub(second.value, 2)
+ elseif punct_map[first_char] then
+ if first_char == second_char then
+ second.value = string.sub(second.value, 2)
+ else
+ local combined = punct_map[first_char][second_char]
+ if combined and #combined == 1 then
+ second.value = string.sub(second.value, 2)
+ first.value = string.sub(first.value, 1, -2) .. combined
+ end
+ end
+ end
+end
+
+local function smash_just_punc(first, second)
+ first = find_right(first) -- PlainText
+ second = find_left(second) -- PlainText
+ if first and second then
+ local first_char = string.sub(first.value, -1)
+ local second_char = string.sub(second.value, 1, 1)
+ -- util.debug(first_char)
+ -- util.debug(second_char)
+
+ local punct_map = output_module.quote_punctuation_map
+ if second_char == " " and (first_char == " " or
+ util.endswith(first.value, util.unicode["no-break space"])) then
+ second.value = string.sub(second.value, 2)
+ return true
+ elseif punct_map[first_char] then
+ if first_char == second_char then
+ second.value = string.sub(second.value, 2)
+ return true
+ else
+ local combined = punct_map[first_char][second_char]
+ if combined and #combined == 1 then
+ second.value = string.sub(second.value, 2)
+ first.value = string.sub(first.value, 1, -2) .. combined
+ return true
+ end
+ end
+ end
+ else
+ return false
+ end
+end
+
+local function normalise_text_elements(inlines)
+ -- 1. Merge punctuations: "?." => "?"
+ -- 2. Merge spaces: " " => " "
+ local idx = 1
+ while idx < #inlines do
+ local first = inlines[idx]
+ local second = inlines[idx+1]
+
+ if first._type == "PlainText" and second._type == "PlainText" then
+ smash_string_push(first, second)
+ first.value = first.value .. second.value
+ table.remove(inlines, idx + 1)
+
+ elseif first._type == "Micro" and second._type == "PlainText" then
+ local success = smash_just_punc(first, second)
+ if success then
+ if second.value == "" then
+ table.remove(inlines, idx + 1)
+ end
+ else
+ idx = idx + 1
+ end
+
+ elseif first._type == "Formatted" and second._type == "PlainText" then
+ local success = smash_just_punc(first, second)
+ if success then
+ if second.value == "" then
+ table.remove(inlines, idx + 1)
+ end
+ else
+ idx = idx + 1
+ end
+
+ else
+ idx = idx + 1
+ end
+ end
+
+end
+
+local function move_in_quotes(first, second)
+ local first_char = string.sub(first.value, -1)
+ local second_char = string.sub(second.value, 1, 1)
+ local success = false
+ if output_module.move_in_puncts[second_char] then
+ if first_char == second_char then
+ second.value = string.sub(second.value, 2)
+ success = true
+ elseif output_module.quote_punctuation_map[first_char] then
+ local combined = output_module.quote_punctuation_map[first_char][second_char]
+ first.value = string.sub(first.value, 1, -2) .. combined
+ second.value = string.sub(second.value, 2)
+ success = true
+ else
+ first.value = first.value .. second_char
+ second.value = string.sub(second.value, 2)
+ success = true
+ end
+ end
+ return success
+end
+
+local function move_out_quotes(first, second)
+ local first_char = string.sub(first.value, -1)
+ local second_char = string.sub(second.value, 1, 1)
+ local success = false
+ if output_module.move_out_puncts[first_char] then
+ if first_char == second_char then
+ first.value = string.sub(first.value, 1, -2)
+ success = true
+ elseif output_module.quote_punctuation_map[second_char] then
+ local combined = output_module.quote_punctuation_map[first_char][second_char]
+ first.value = string.sub(first.value, 1, -2)
+ second.value = combined .. string.sub(second.value, 2)
+ success = true
+ else
+ first.value = string.sub(first.value, 1, -2)
+ second.value = first_char .. second.value
+ success = true
+ end
+ end
+ return success
+end
+
+local function move_around_quote(inlines)
+ local idx = 1
+
+ while idx < #inlines do
+ -- Move punctuation into quotes as needed
+ local first, punctuation_in_quote = find_right_quoted(inlines[idx])
+
+ local second = find_left(inlines[idx+1])
+ local success = false
+ if first and second then
+ if punctuation_in_quote then
+ success = move_in_quotes(first, second)
+ else
+ success = move_out_quotes(first, second)
+ end
+ if not success then
+ idx = idx + 1
+ end
+ else
+ idx = idx + 1
+ end
+ end
+end
+
+function OutputFormat:move_punctuation(inlines, piq)
+ -- Merge punctuations
+ normalise_text_elements(inlines)
+
+ move_around_quote(inlines)
+
+ for _, inline in ipairs(inlines) do
+ if inline._type == "Quoted" or inline._type == "Formatted" or
+ inline._type == "Div" then
+ self:move_punctuation(inline.inlines)
+ end
+ end
+end
+
+function OutputFormat:write_inlines(inlines, context)
+ local res = ""
+ for _, inline in ipairs(inlines) do
+ res = res .. self:write_inline(inline, context)
+ end
+ return res
+end
+
+function OutputFormat:write_inline(inline, context)
+ if inline.value then
+ return self:write_escaped(inline.value)
+ elseif inline.inlines then
+ return self:write_inlines(inline.inlines)
+ end
+ return ""
+end
+
+function OutputFormat:write_escaped(str, context)
+ return str
+end
+
+function OutputFormat:write_link(inline, context)
+ return self:write_escaped(inline.value)
+end
+
+
+
+local Markup = OutputFormat:new()
+
+function Markup:write_inline(inline, context)
+ -- Should be deprecated code
+ if type(inline) == "string" then
+ return self:write_escaped(inline, context)
+
+ elseif type(inline) == "table" then
+ -- util.debug(inline._type)
+ if inline._type == "PlainText" then
+ return self:write_escaped(inline.value, context)
+
+ elseif inline._type == "InlineElement" then
+ return self:write_children(inline, context)
+
+ elseif inline._type == "Formatted" then
+ return self:write_formatted(inline, context)
+
+ elseif inline._type == "Quoted" then
+ return self:write_quoted(inline, context)
+
+ elseif inline._type == "Div" then
+ return self:write_display(inline, context)
+
+ elseif inline._type == "Linked" then
+ return self:write_link(inline, context)
+
+ elseif inline._type == "NoCase" or inline._type == "NoDecor" then
+ return self:write_inlines(inline.inlines)
+
+ else
+ return self:write_inlines(inline.inlines)
+ end
+ end
+ return ""
+end
+
+function Markup:write_children(inline, context)
+ local res = ""
+ for _, child_inline in ipairs(inline.inlines) do
+ res = res .. self:write_inline(child_inline, context)
+ end
+ return res
+end
+
+function Markup:write_quoted(inline, context)
+ local res = self:write_children(inline, context)
+ local quotes = inline.quotes
+ if inline.is_inner then
+ return quotes.inner_open .. res .. quotes.inner_close
+ else
+ return quotes.outer_open .. res .. quotes.outer_close
+ end
+end
+
+
+local LatexWriter = Markup:new()
+
+LatexWriter.markups = {
+ ["bibstart"] = function (engine)
+ return string.format("\\begin{thebibliography}{%s}\n\n", engine.registry.longest_label)
+ end,
+ ["bibend"] = "\\end{thebibliography}",
+ ["@font-style/normal"] = "{\\normalshape %s}",
+ ["@font-style/italic"] = "\\textit{%s}",
+ ["@font-style/oblique"] = "\\textsl{%s}",
+ ["@font-variant/normal"] = "{\\normalshape %s}",
+ ["@font-variant/small-caps"] = "\\textsc{%s}",
+ ["@font-weight/normal"] = "{\\fontseries{m}\\selectfont %s}",
+ ["@font-weight/bold"] = "\\textbf{%s}",
+ ["@font-weight/light"] = "{\\fontseries{l}\\selectfont %s}",
+ ["@text-decoration/none"] = false,
+ ["@text-decoration/underline"] = "\\underline{%s}",
+ ["@vertical-align/sup"] = "\\textsuperscript{%s}",
+ ["@vertical-align/sub"] = "\\textsubscript{%s}",
+ ["@vertical-align/baseline"] = false,
+ ["@cite/entry"] = false,
+ ["@bibliography/entry"] = function (str, context)
+ if not string.match(str, "\\bibitem") then
+ str = "\\bibitem{".. context.id .. "}\n" .. str .. "\n"
+ end
+ return str
+ end,
+ ["@display/block"] = false,
+ ["@display/left-margin"] = '\n <div class="csl-left-margin">%s</div>',
+ ["@display/right-inline"] = '<div class="csl-right-inline">%s</div>\n ',
+ ["@display/indent"] = '<div class="csl-indent">%s</div>\n ',
+}
+
+function LatexWriter:write_escaped(str, context)
+ -- TeXbook, p. 38
+ str = str:gsub("\\", "\\textbackslash{}")
+ str = str:gsub("{", "\\{")
+ str = str:gsub("}", "\\}")
+ str = str:gsub("%$", "\\$")
+ str = str:gsub("&", "\\&")
+ str = str:gsub("#", "\\#")
+ str = str:gsub("%^", "\\^")
+ str = str:gsub("_", "\\_")
+ str = str:gsub("%%", "\\%%")
+ str = str:gsub("~", "\\~")
+ str = str:gsub(util.unicode["em space"], "\\quad ")
+ str = str:gsub(util.unicode["no-break space"], "~")
+ for char, sub in pairs(util.superscripts) do
+ str = string.gsub(str, char, "\\textsuperscript{" .. sub .. "}")
+ end
+ return str
+end
+
+function LatexWriter:write_formatted(inline, context)
+ local res = self:write_children(inline, context)
+ for _, key in ipairs({"font-style", "font-variant", "font-weight", "text-decoration", "vertical-align"}) do
+ local value = inline.formatting[key]
+ if value then
+ key = "@" .. key .. "/" .. value
+ local format_str = self.markups[key]
+ if format_str then
+ res = string.format(format_str, res)
+ end
+ end
+ end
+ return res
+end
+
+function LatexWriter:write_display(inline, context)
+ local plainter_text_writer = output_module.PlainTextWriter:new()
+ local str = plainter_text_writer:write_inline(inline)
+ local len = utf8.len(str)
+ if len > context.engine.registry.maxoffset then
+ context.engine.registry.maxoffset = len
+ context.engine.registry.longest_label = str
+ end
+
+ local res = self:write_children(inline, context)
+ if inline.display == "left-margin" then
+ if string.match(res, "%]") then
+ res = "{" .. res .. "}"
+ end
+ res = string.format("\\bibitem[%s]{%s}\n", res, context.id)
+
+ elseif inline.display == "right-inline" then
+ return res
+
+ elseif inline.display == "block" then
+ return ""
+ end
+ return res
+end
+
+function LatexWriter:write_link(inline, context)
+ if inline.href == inline.value then
+ return string.format("\\url{%s}", inline.value)
+ else
+ return string.format("\\href{%s}{%s}", inline.href, inline.value)
+ end
+end
+
+
+local HtmlWriter = Markup:new()
+
+HtmlWriter.markups = {
+ ["bibstart"] = "<div class=\"csl-bib-body\">\n",
+ ["bibend"] = "</div>",
+ ["@font-style/italic"] = "<i>%s</i>",
+ ["@font-style/oblique"] = "<em>%s</em>",
+ ["@font-style/normal"] = '<span style="font-style:normal;">%s</span>',
+ ["@font-variant/small-caps"] = '<span style="font-variant:small-caps;">%s</span>',
+ ["@font-variant/normal"] = '<span style="font-variant:normal;">%s</span>',
+ ["@font-weight/bold"] = "<b>%s</b>",
+ ["@font-weight/normal"] = '<span style="font-weight:normal;">%s</span>',
+ ["@font-weight/light"] = false,
+ ["@text-decoration/none"] = '<span style="text-decoration:none;">%s</span>',
+ ["@text-decoration/underline"] = '<span style="text-decoration:underline;">%s</span>',
+ ["@vertical-align/sup"] = "<sup>%s</sup>",
+ ["@vertical-align/sub"] = "<sub>%s</sub>",
+ ["@vertical-align/baseline"] = '<span style="baseline">%s</span>',
+ ["@cite/entry"] = nil,
+ ["@bibliography/entry"] = "<div class=\"csl-entry\">%s</div>\n",
+ ["@display/block"] = '\n\n <div class="csl-block">%s</div>\n',
+ ["@display/left-margin"] = '\n <div class="csl-left-margin">%s</div>',
+ ["@display/right-inline"] = '<div class="csl-right-inline">%s</div>\n ',
+ ["@display/indent"] = '<div class="csl-indent">%s</div>\n ',
+}
+
+function HtmlWriter:write_escaped(str, context)
+ str = string.gsub(str, "%&", "&#38;")
+ str = string.gsub(str, "<", "&#60;")
+ str = string.gsub(str, ">", "&#62;")
+ for char, sub in pairs(util.superscripts) do
+ str = string.gsub(str, char, "<sup>" .. sub .. "</sup>")
+ end
+ return str
+end
+
+function HtmlWriter:write_formatted(inline, context)
+ local res = self:write_children(inline, context)
+ for _, key in ipairs({"font-style", "font-variant", "font-weight", "text-decoration", "vertical-align"}) do
+ local value = inline.formatting[key]
+ if value then
+ key = "@" .. key .. "/" .. value
+ local format_str = self.markups[key]
+ if format_str then
+ res = string.format(format_str, res)
+ end
+ end
+ end
+ return res
+end
+
+function HtmlWriter:write_display(inline, context)
+ local plainter_text_writer = output_module.PlainTextWriter:new()
+ local str = plainter_text_writer:write_inline(inline)
+ local len = utf8.len(str)
+ if len > context.engine.registry.maxoffset then
+ context.engine.registry.maxoffset = len
+ context.engine.registry.longest_label = str
+ end
+
+ if #inline.inlines == 0 then
+ return ""
+ end
+ local res = self:write_children(inline, context)
+ local key = string.format("@display/%s", inline.div)
+ if inline.div == "right-inline" then
+ -- Strip trailing spaces
+ -- variables_ContainerTitleShort.txt
+ res = string.gsub(res, "%s+$", "")
+ end
+ local format_str = self.markups[key]
+ res = string.format(format_str, res)
+ return res
+end
+
+function HtmlWriter:write_link(inline, context)
+ return string.format('<a href="%s">%s</a>', inline.href, inline.value)
+end
+
+
+local PlainTextWriter = Markup:new()
+
+PlainTextWriter.markups = {}
+
+function PlainTextWriter:write_escaped(str, context)
+ return str
+end
+
+function PlainTextWriter:write_formatted(inline, context)
+ return self:write_children(inline, context)
+end
+
+function PlainTextWriter:write_display(inline, context)
+ return self:write_children(inline, context)
+end
+
+
+local SortStringFormat = OutputFormat:new()
+
+function SortStringFormat:output(inlines, context)
+ -- self:flip_flop_inlines(inlines)
+ -- self:move_punctuation(inlines)
+ return self:write_inlines(inlines, context)
+end
+
+function SortStringFormat:write_escaped(str, context)
+ str = string.gsub(str, ",", "")
+ return str
+end
+
+
+local DisamStringFormat = OutputFormat:new()
+
+function DisamStringFormat:output(inlines, context)
+ -- self:flip_flop_inlines(inlines)
+ -- self:move_punctuation(inlines)
+ return self:write_inlines(inlines, context)
+end
+
+function DisamStringFormat:flatten_ir(ir)
+ if self.group_var == "missing" then
+ return {}
+ end
+ local inlines
+ if ir._type == "SeqIr" or ir._type == "NameIr" then
+ inlines = self:flatten_seq_ir(ir)
+ elseif ir._type == "YearSuffix" then
+ -- Don't include year-suffix in disambiguation
+ inlines = {}
+ else
+ inlines = self:affixed_quoted(ir.inlines, ir.affixes, ir.quotes);
+ inlines = self:with_display(inlines, ir.display);
+ end
+ return inlines
+end
+
+function DisamStringFormat:flatten_seq_ir(ir)
+ if not ir.children then
+ print(debug.traceback())
+ end
+ if #ir.children == 0 then
+ return {}
+ end
+ local inlines_list = {}
+ for _, child in ipairs(ir.children) do
+ if child.group_var ~= "missing" then
+ -- and not child.collapse_suppressed
+ -- Suppressed irs are stil kept in the DisamStringFormat
+ table.insert(inlines_list, self:flatten_ir(child))
+ end
+ end
+
+ local inlines = self:group(inlines_list, ir.delimiter, ir.formatting)
+ -- assert ir.quotes == localized quotes
+ inlines = self:affixed_quoted(inlines, ir.affixes, ir.quotes);
+ inlines = self:with_display(inlines, ir.display);
+ return inlines
+end
+
+
+output_module.move_in_puncts = {
+ ["."] = true,
+ ["!"] = true,
+ ["?"] = true,
+ [","] = true,
+}
+
+output_module.move_out_puncts = {
+ [","] = true,
+ [";"] = true,
+ [":"] = true,
+}
+
+-- https://github.com/Juris-M/citeproc-js/blob/aa2683f48fe23be459f4ed3be3960e2bb56203f0/src/queue.js#L724
+-- Also merge duplicate punctuations.
+output_module.quote_punctuation_map = {
+ ["!"] = {
+ ["."] = "!",
+ ["?"] = "!?",
+ [":"] = "!",
+ [","] = "!,",
+ [";"] = "!;",
+ },
+ ["?"] = {
+ ["!"] = "?!",
+ ["."] = "?",
+ [":"] = "?",
+ [","] = "?,",
+ [";"] = "?;",
+ },
+ ["."] = {
+ ["!"] = ".!",
+ ["?"] = ".?",
+ [":"] = ".:",
+ [","] = ".,",
+ [";"] = ".;",
+ },
+ [":"] = {
+ ["!"] = "!",
+ ["?"] = "?",
+ ["."] = ":",
+ [","] = ":,",
+ [";"] = ":;",
+ },
+ [","] = {
+ ["!"] = ",!",
+ ["?"] = ",?",
+ [":"] = ",:",
+ ["."] = ",.",
+ [";"] = ",;",
+ },
+ [";"] = {
+ ["!"] = "!",
+ ["?"] = "?",
+ [":"] = ";",
+ [","] = ";,",
+ ["."] = ";",
+ }
+}
+
+
+output_module.LocalizedQuotes = LocalizedQuotes
+
+output_module.InlineElement = InlineElement
+output_module.PlainText = PlainText
+output_module.Formatted = Formatted
+output_module.Micro = Micro
+output_module.Quoted = Quoted
+output_module.Linked = Linked
+output_module.Div = Div
+output_module.NoCase = NoCase
+output_module.NoDecor = NoDecor
+
+output_module.OutputFormat = OutputFormat
+
+output_module.Markup = Markup
+output_module.LatexWriter = LatexWriter
+output_module.HtmlWriter = HtmlWriter
+output_module.PlainTextWriter = PlainTextWriter
+output_module.DisamStringFormat = DisamStringFormat
+output_module.SortStringFormat = SortStringFormat
+
+return output_module