summaryrefslogtreecommitdiff
path: root/biblio/citation-style-language/citeproc-richtext.lua
diff options
context:
space:
mode:
authorNorbert Preining <norbert@preining.info>2022-01-22 03:01:16 +0000
committerNorbert Preining <norbert@preining.info>2022-01-22 03:01:16 +0000
commit26bfc07f2b7829213356de43cf5b81c3ba99e338 (patch)
treeefb3037df2c42c45c904ad131334e8fc626cea19 /biblio/citation-style-language/citeproc-richtext.lua
parent5d5f239204f5c0d1f7573c9ebc64b5ad1ae75b49 (diff)
CTAN sync 202201220301
Diffstat (limited to 'biblio/citation-style-language/citeproc-richtext.lua')
-rw-r--r--biblio/citation-style-language/citeproc-richtext.lua777
1 files changed, 777 insertions, 0 deletions
diff --git a/biblio/citation-style-language/citeproc-richtext.lua b/biblio/citation-style-language/citeproc-richtext.lua
new file mode 100644
index 0000000000..50f78a4466
--- /dev/null
+++ b/biblio/citation-style-language/citeproc-richtext.lua
@@ -0,0 +1,777 @@
+--[[
+ Copyright (C) 2021 Zeping Lee
+--]]
+
+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, '<span%s+style="font%-variant:%s*small%-caps;?">', '<span style="font-variant:small-caps;">')
+ str = string.gsub(str, '<span%s+class="nocase">', '<span class="nocase">')
+ str = string.gsub(str, '<span%s+class="nodecor">', '<span class="nodecor">')
+
+ 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 == '<span class="nodecor">' 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 = {
+ ["<i>"] = {["font-style"] = "italic"},
+ ["<b>"] = {["font-weight"] = "bold"},
+ ["<sup>"] = {["vertical-align"] = "sup"},
+ ["<sub>"] = {["vertical-align"] = "sub"},
+ ["<sc>"] = {["font-variant"] = "small-caps"},
+ ['<span style="font-variant:small-caps;">'] = {["font-variant"] = "small-caps"},
+ ['<span class="nocase">'] = {["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 = {
+ ["<i>"] = "</i>",
+ ["<b>"] = "</b>",
+ ["<sup>"] = "</sup>",
+ ["<sub>"] = "</sub>",
+ ["<sc>"] = "</sc>",
+ ['<span style="font-variant:small-caps;">'] = "</span>",
+ ['<span class="nocase">'] = "</span>",
+ ['<span class="nodecor">'] = "</span>",
+ ['"'] = '"',
+ [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