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