-- -- 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 = "
" .. str .. "
" 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", 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 string.match(str, "\\bibitem") then str = str .. "\n" else str = "\\bibitem{".. context.id .. "}\n" .. str .. "\n" end return str end, -- ["@display/block"] = false, -- ["@display/left-margin"] = '\n
%s
', -- ["@display/right-inline"] = '
%s
', -- ["@display/indent"] = '
%s
\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) if inline.div == "left-margin" then 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 end local res = self:write_children(inline, context) if inline.div == "left-margin" then if string.match(res, "%]") then res = "{" .. res .. "}" end res = string.format("\\bibitem[%s]{%s}\n", res, context.id) elseif inline.div == "right-inline" then return res elseif inline.div == "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"] = "
\n", ["bibend"] = "
", ["@font-style/italic"] = "%s", ["@font-style/oblique"] = "%s", ["@font-style/normal"] = '%s', ["@font-variant/small-caps"] = '%s', ["@font-variant/normal"] = '%s', ["@font-weight/bold"] = "%s", ["@font-weight/normal"] = '%s', ["@font-weight/light"] = false, ["@text-decoration/none"] = '%s', ["@text-decoration/underline"] = '%s', ["@vertical-align/sup"] = "%s", ["@vertical-align/sub"] = "%s", ["@vertical-align/baseline"] = '%s', ["@cite/entry"] = nil, ["@bibliography/entry"] = "
%s
\n", ["@display/block"] = '\n\n
%s
\n', ["@display/left-margin"] = '\n
%s
', ["@display/right-inline"] = '
%s
\n ', ["@display/indent"] = '
%s
\n ', } function HtmlWriter:write_escaped(str, context) str = string.gsub(str, "%&", "&") str = string.gsub(str, "<", "<") str = string.gsub(str, ">", ">") for char, sub in pairs(util.superscripts) do str = string.gsub(str, char, "" .. sub .. "") 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) if inline.div == "left-margin" then 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 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('%s', 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