-- -- Copyright (c) 2021-2022 Zeping Lee -- Released under the MIT license. -- Repository: https://github.com/zepinglee/citeproc-lua -- local richtext = {} local unicode = require("unicode") local util = require("citeproc-util") local RichText = { contents = nil, formats = nil, _type = "RichText", } function RichText:shallow_copy() local res = richtext.new() for _, text in ipairs(self.contents) do table.insert(res.contents, text) end for key, value in pairs(self.formats) do res.formats[key] = value end return res end function RichText:render(formatter, context, punctuation_in_quote) self:merge_punctuations() if punctuation_in_quote == nil and context then punctuation_in_quote = context.style:get_locale_option("punctuation-in-quote") end if punctuation_in_quote then self:move_punctuation_in_quote() end self:change_case() self:flip_flop() self:clean_formats() return self:_render(formatter, context) end function RichText:_render(formatter, context) local res = "" for _, text in ipairs(self.contents) do local str if type(text) == "string" then if formatter and formatter.text_escape then str = formatter.text_escape(text) else str = text end else -- RichText str = text:_render(formatter, context) end -- Remove leading spaces if string.sub(res, -1) == " " and string.sub(str, 1, 1) == " " then str = string.gsub(str, "^%s+", "") end res = res .. str end for _, attr in ipairs(richtext.format_sequence) do local value = self.formats[attr] if value then local key = string.format("@%s/%s", attr, value) if formatter then local format = formatter[key] if type(format) == "string" then res = string.format(format, res) elseif type(format) == "function" then res = format(res, context) end end end end return res end function RichText:merge_punctuations(contents, index) for i, text in ipairs(self.contents) do if text._type == "RichText" then contents, index = text:merge_punctuations(contents, index) elseif type(text) == "string" then if contents and index then local previous_string = contents[index] local last_char = string.sub(previous_string, -1) local right_punct_map = richtext.punctuation_map[last_char] if right_punct_map then local first_char = string.sub(text, 1, 1) local new_punctuations = nil if first_char == last_char then new_punctuations = last_char elseif contents == self.contents then new_punctuations = right_punct_map[first_char] end if new_punctuations then if #text == 1 then table.remove(self.contents, i) else self.contents[i] = string.sub(text, 2) end contents[index] = string.sub(previous_string, 1, -2) .. new_punctuations end end end contents = self.contents index = i end end return contents, index end function RichText:move_punctuation_in_quote() local i = 1 while i <= #self.contents do local text = self.contents[i] if type(text) == "table" and text._type == "RichText" then text:move_punctuation_in_quote() if text.formats["quotes"] then local contents = self.contents local last_string = text while type(last_string) == "table" and last_string._type == "RichText" do contents = last_string.contents last_string = contents[#contents] end local done = false while not done do done = true last_string = contents[#contents] local last_char = string.sub(last_string, -1) if i < #self.contents then local next_text = self.contents[i + 1] if type(next_text) == "string" then local first_char = string.sub(next_text, 1, 1) if richtext.in_quote_punctuations[first_char] then done = false local right_punct_map = richtext.punctuation_map[last_char] if right_punct_map then first_char = right_punct_map[first_char] last_string = string.sub(last_string, 1, -2) end contents[#contents] = last_string .. first_char if #next_text == 1 then table.remove(self.contents, i + 1) else self.contents[i + 1] = string.sub(next_text, 2) end end end end end end end i = i + 1 end end function RichText:change_case() for _, text in ipairs(self.contents) do if type(text) == "table" and text._type == "RichText" then text:change_case() end end local text_case = self.formats["text-case"] if text_case then if text_case == "lowercase" then self:lowercase() elseif text_case == "uppercase" then self:uppercase() elseif text_case == "capitalize-first" then self:capitalize_first() elseif text_case == "capitalize-all" then self:capitalize_all() elseif text_case == "sentence" then self:sentence() elseif text_case == "title" then self:title() end end end function RichText:_change_word_case(state, word_transform, first_tranform, is_phrase) if self.formats["text-case"] == "nocase" then return end if is_phrase and (self.formats["vertical-align"] == "sup" or self.formats["vertical-align"] == "sub" or self.formats["font-variant"] == "small-caps") then return end state = state or "after-sentence" word_transform = word_transform or function (x) return x end first_tranform = first_tranform or word_transform for i, text in ipairs(self.contents) do if type(text) == "string" then local res = "" local word_seps = { " ", "%-", "/", util.unicode["no-break space"], util.unicode["en dash"], util.unicode["em dash"], } for _, tuple in ipairs(util.split(text, word_seps, nil, true)) do local word, punctuation = table.unpack(tuple) if state == "after-sentence" then res = res .. first_tranform(word) if string.match(word, "%w") then state = "after-word" end else res = res .. word_transform(word, punctuation) end res = res .. punctuation if string.match(word, "[.!?:]%s*$") then state = "after-sentence" end end -- local word_index = 0 -- local res = string.gsub(text, "%w+", function (word) -- word_index = word_index + 1 -- if word_index == 1 then -- return first_tranform(word) -- else -- return word_transform(word) -- end -- end) -- if string.match(res, "[.!?:]%s*$") then -- state = "after-sentence" -- end self.contents[i] = res else state = text:_change_word_case(state, word_transform, first_tranform, true) end end return state end function RichText:lowercase() local word_transform = unicode.utf8.lower self:_change_word_case("after-sentence", word_transform) end function RichText:uppercase() local word_transform = unicode.utf8.upper self:_change_word_case("after-sentence", word_transform) end local function capitalize(str) local res = string.gsub(str, utf8.charpattern, unicode.utf8.upper, 1) return res end local function capitalize_if_lower(word) if util.is_lower(word) then return capitalize(word) else return word end end function RichText:capitalize_first(state) local first_tranform = capitalize_if_lower self:_change_word_case("after-sentence", nil, first_tranform) end function RichText:capitalize_all() local word_transform = capitalize_if_lower self:_change_word_case("after-sentence", word_transform) end function RichText:is_upper() for _, text in ipairs(self.contents) do if type(text) == "string" then if not util.is_upper(text) then return false end else local res = text:is_upper() if not res then return false end end end return true end function RichText:sentence() if self:is_upper() then local first_tranform = function(word) return capitalize(unicode.utf8.lower(word)) end local word_transform = unicode.utf8.lower self:_change_word_case("after-sentence", word_transform, first_tranform) else local first_tranform = capitalize_if_lower self:_change_word_case("after-sentence", nil, first_tranform) end end function RichText:title() if self:is_upper() then local first_tranform = function(word) return capitalize(unicode.utf8.lower(word)) end local word_transform = function(word, sep) local res = unicode.utf8.lower(word) if not util.stop_words[res] then res = capitalize(res) end return res end self:_change_word_case("after-sentence", word_transform, first_tranform) else local first_tranform = capitalize_if_lower local word_transform = function(word, sep) local lower = unicode.utf8.lower(word) -- Stop word before hyphen is treated as a normal word. if util.stop_words[lower] and sep ~= "-" then return lower elseif word == lower then return capitalize(word) else return word end end self:_change_word_case("after-sentence", word_transform, first_tranform) end end function richtext.concat(str1, str2) assert(str1 and str2) if type(str1) == "string" then str1 = richtext.new(str1) end local res if next(str1.formats) == nil or str2 == "" then -- shallow copy res = str1 else res = richtext.new() res.contents = {str1} end if str2._type == "RichText" then if next(str2.formats) == nil then for _, text in ipairs(str2.contents) do table.insert(res.contents, text) end else table.insert(res.contents, str2) end elseif str2 ~= "" then table.insert(res.contents, str2) end return res end function richtext.concat_list(list, delimiter) -- Strings in the list may be nil thus ipairs() should be avoided. -- The delimiter may be nil. local res = nil for i = 1, #list do local text = list[i] if text and text ~= "" then if res then if delimiter and delimiter ~= "" then res = richtext.concat(res, delimiter) end res = richtext.concat(res, text) else if type(text) == "string" then text = richtext.new(text) end res = text end end end return res end function RichText:strip_periods() local last_string = self local contents = self.contents while last_string._type == "RichText" do contents = last_string.contents last_string = contents[#contents] end if string.sub(last_string, -1) == "." then contents[#contents] = string.sub(last_string, 1, -2) end end function RichText:add_format(attr, value) self.formats[attr] = value end function RichText:flip_flop(attr, value) if not attr then for attr, _ in pairs(richtext.flip_flop_formats) do self:flip_flop(attr) end return end local default_value = richtext.default_formats[attr] if value and value ~= default_value and self.formats[attr] == value then self.formats[attr] = richtext.flip_flop_values[attr][value] end if self.formats[attr] then value = self.formats[attr] end for _, text in ipairs(self.contents) do if type(text) == "table" and text._type == "RichText" then text:flip_flop(attr, value) end end end function RichText:clean_formats(format) -- Remove the formats that are default values if not format then for format, _ in pairs(richtext.default_formats) do self:clean_formats(format) end return end if self.formats[format] then if self.formats[format] == richtext.default_formats[format] then self.formats[format] = nil else return end end for _, text in ipairs(self.contents) do if type(text) == "table" and text._type == "RichText" then text:clean_formats(format) end end end local RichText_mt = { __index = RichText, __concat = richtext.concat, } local function table_update(t, new_t) for key, value in pairs(new_t) do t[key] = value end return t end function RichText._split_tags(str) -- Normalize markup str = string.gsub(str, '', '') str = string.gsub(str, '', '') str = string.gsub(str, '', '') local strings = {} local start_index = 1 local i = 1 while i <= #str do local substr = string.sub(str, i) local starts_with_tag = false for tag, _ in pairs(richtext.tags) do if util.startswith(substr, tag) then if start_index <= i - 1 then table.insert(strings, string.sub(str, start_index, i-1)) end table.insert(strings, tag) i = i + #tag start_index = i starts_with_tag = true break end end if not starts_with_tag then i = i + 1 end end if start_index <= #str then table.insert(strings, string.sub(str, start_index)) end for i = 1, #strings do str = strings[i] if str == "'" or str == util.unicode["apostrophe"] then local previous_str = strings[i - 1] local next_str = strings[i + 1] if previous_str and next_str then local previous_code_point = nil for _, code_point in utf8.codes(previous_str) do previous_code_point = code_point end local next_code_point = utf8.codepoint(next_str) if util.is_romanesque(previous_code_point) and util.is_romanesque(next_code_point) then -- An apostrophe strings[i-1] = strings[i-1] .. util.unicode["apostrophe"] .. strings[i+1] table.remove(strings, i+1) table.remove(strings, i) end end end end return strings end function richtext.new(text, formats) local res = { contents = {}, formats = formats or {}, } setmetatable(res, RichText_mt) if not text then return res end if type(text) == "string" then local strings = RichText._split_tags(text) local contents = {} for _, str in ipairs(strings) do table.insert(contents, str) local end_tag = nil if str == '"' then local last_text = contents[#contents - 1] if last_text and type(last_text) == "string" and string.match(last_text, "%s$") then end_tag = nil else end_tag = str end elseif richtext.end_tags[str] then end_tag = str end if end_tag then for i = #contents - 1, 1, -1 do local start_tag = contents[i] if type(start_tag) == "string" and richtext.tag_pairs[start_tag] == end_tag then local subtext = richtext.new() -- subtext.contents = util.slice(contents, i + 1, #contents - 1) if start_tag == "'" and end_tag == "'" and i == #contents - 1 then contents[i] = util.unicode["apostrophe"] contents[#contents] = util.unicode["apostrophe"] break end for j = i + 1, #contents - 1 do local substr = contents[j] if substr == "'" then substr = util.unicode["apostrophe"] end local last_text = subtext.contents[#subtext.contents] if type(substr) == "string" and type(last_text) == "string" then subtext.contents[#subtext.contents] = last_text .. substr else table.insert(subtext.contents, substr) end end if start_tag == '' then for attr, value in pairs(richtext.default_formats) do subtext.formats[attr] = value end subtext.formats["text-case"] = "nocase" else for attr, value in pairs(richtext.tag_formats[start_tag]) do subtext.formats[attr] = value end end for j = #contents, i, -1 do table.remove(contents, j) end table.insert(contents, subtext) break end end end end for i = #contents, 1, -1 do if contents[i] == "'" then contents[i] = util.unicode["apostrophe"] end if type(contents[i]) == "string" and type(contents[i+1]) == "string" then contents[i] = contents[i] .. contents[i+1] table.remove(contents, i+1) end end if #contents == 1 and type(contents[1]) == "table" then res = contents[1] else res.contents = contents end return res elseif type(text) == "table" and text._type == "RichText" then return text elseif type(text) == "table" then return text end return nil end richtext.tag_formats = { [""] = {["font-style"] = "italic"}, [""] = {["font-weight"] = "bold"}, [""] = {["vertical-align"] = "sup"}, [""] = {["vertical-align"] = "sub"}, [""] = {["font-variant"] = "small-caps"}, [''] = {["font-variant"] = "small-caps"}, [''] = {["text-case"] = "nocase"}, ['"'] = {["quotes"] = "true"}, [util.unicode['left double quotation mark']] = {["quotes"] = "true"}, ["'"] = {["quotes"] = "true"}, [util.unicode['left single quotation mark']] = {["quotes"] = "true"}, } richtext.default_formats = { ["URL"] = "false", ["DOI"] = "false", ["PMID"] = "false", ["PMCID"] = "false", ["font-style"] = "normal", ["font-variant"] = "normal", ["font-weight"] = "normal", ["text-decoration"] = "none", ["vertical-align"] = "baseline", ["quotes"] = "false", } richtext.format_sequence = { "URL", "DOI", "PMID", "PMCID", "font-style", "font-variant", "font-weight", "text-decoration", "vertical-align", "quotes", "display", } richtext.flip_flop_formats = { ["font-style"] = true, ["font-weight"] = true, ["font-variant"] = true, ["quotes"] = true, } richtext.flip_flop_values = { ["font-style"] = { italic = "normal", normal = "italic", }, ["font-weight"] = { bold = "normal", normal = "bold", }, ["font-variant"] = { ["small-caps"] = "normal", normal = "small-caps", }, ["quotes"] = { ["true"] = "inner", inner = "true", }, } -- https://github.com/Juris-M/citeproc-js/blob/aa2683f48fe23be459f4ed3be3960e2bb56203f0/src/queue.js#L724 -- Also merge duplicate punctuations. richtext.punctuation_map = { ["!"] = { ["."] = "!", ["?"] = "!?", [":"] = "!", [","] = "!,", [";"] = "!;", }, ["?"] = { ["!"] = "?!", ["."] = "?", [":"] = "?", [","] = "?,", [";"] = "?;", }, ["."] = { ["!"] = ".!", ["?"] = ".?", [":"] = ".:", [","] = ".,", [";"] = ".;", }, [":"] = { ["!"] = "!", ["?"] = "?", ["."] = ":", [","] = ":,", [";"] = ":;", }, [","] = { ["!"] = ",!", ["?"] = ",?", [":"] = ",:", ["."] = ",.", [";"] = ",;", }, [";"] = { ["!"] = "!", ["?"] = "?", [":"] = ";", [","] = ";,", ["."] = ";", } } richtext.in_quote_punctuations = { [","] = true, ["."] = true, ["?"] = true, ["!"] = true, } richtext.tag_pairs = { [""] = "", [""] = "", [""] = "", [""] = "", [""] = "", [''] = "", [''] = "", [''] = "", ['"'] = '"', [util.unicode['left double quotation mark']] = util.unicode['right double quotation mark'], ["'"] = "'", [util.unicode['left single quotation mark']] = util.unicode['right single quotation mark'], } richtext.tags = {} richtext.end_tags = {} for start_tag, end_tag in pairs(richtext.tag_pairs) do richtext.tags[start_tag] = true richtext.tags[end_tag] = true richtext.end_tags[end_tag] = true end return richtext